TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 24 READS

순환 복잡도, 숫자 하나로 코드 위험을 읽는 법

순환 복잡도, 숫자 하나로 코드 위험을 읽는 법
SOURCE IMAGE · HACKER NEWS

C#뿐 아니라 대부분의 언어에서 코드 품질을 이야기할 때 자주 등장하는 지표가 순환 복잡도(Cyclomatic Complexity, CC)다. 정의 자체는 단순하다. 한 메서드를 통과하는 선형 독립 실행 경로의 수를 세는 것이고, 실무적으로는 '메서드 본문 안의 분기 구문 개수 + 1'로 계산한다. if, while, for, case, &&, ||, 삼항 연산자(?:), null 병합 연산자(??)가 모두 분기로 집계된다. 값이 1이면 분기 없는 일직선 경로이며, 숫자가 커질수록 읽기·테스트·안전한 수정이 어려워진다.

이 지표는 1976년 토머스 매케이브(Thomas McCabe)가 그래프 이론에서 착안해 제안했다. 모든 메서드는 문장 블록을 노드로, 블록 간 이동을 엣지로 하는 제어 흐름 그래프로 표현할 수 있고, 순환 복잡도는 '엣지 수 - 노드 수 + 2×연결 요소 수' 형태의 고전 공식으로 정의된다. 진입점과 종료점이 하나뿐인 일반적인 메서드에서는 이 식이 '1 + 결정 지점 수'로 단순해지며, 실제 대부분의 도구가 이 형태로 계산한다. 이 숫자가 반세기 가까이 살아남은 이유는 구조적 지표이면서도 운영상 의미가 매우 구체적이기 때문이다. 즉 메서드의 모든 독립 경로를 검증하는 데 필요한 최소 테스트 케이스 수를 알려준다.

계산에서 흔히 틀리는 지점

실무자들이 자주 헷갈리는 두 가지가 있다. 첫째, else는 점수를 올리지 않는다. 대체 경로는 이미 짝이 되는 if가 만들어냈기 때문이다. 둘째, switch는 키워드 자체가 아니라 case 하나당 1점, default에도 1점이 붙는다. 따라서 case 6개에 default가 있는 switch는 메서드 복잡도에 7을 더한다. C#의 패턴 매칭 구문, 즉 and/or 패턴이나 최신 switch 식도 고전적 대응물과 동일하게 점수에 반영된다. async/await는 컴파일러가 상태 머신으로 변환하지만 대부분의 도구는 소스 수준 메서드에서 계산하므로 await 자체는 세지 않고, 그 주변의 if·while·catch만 집계된다.

원문은 if를 여섯 번, &&를 한 번 사용해 복잡도 8이 되는 메서드를 예로 든다. 독립 경로가 여덟 개라는 것은 이 메서드 하나를 완전히 덮으려면 최소 여덟 개의 테스트가 필요하다는 뜻이고, 새 비즈니스 규칙을 넣을 때마다 회귀 버그가 조용히 스며들 자리이기도 하다. 이 메서드를 네 개의 작은 메서드로 분리하면 흥미롭게도 네 메서드의 복잡도 합계는 원래의 8보다 오히려 약간 커진다. 그래도 상관없다. 유지보수에서 중요한 것은 총합이 아니라 메서드당 복잡도이기 때문이다. 개발자가 한 번에 머릿속에 담고 추론하는 단위가 곧 메서드다.

어느 숫자를 경계로 삼을 것인가

절대적인 기준값은 없지만 문헌은 비슷한 범위로 수렴한다. 매케이브는 복잡도 10을 넘는 모듈을 분리하라고 권고했고, 마이크로소프트의 CA1502 분석기는 기본값으로 25 초과를 '과도한 복잡도'로 표시한다. 마크 시먼(Mark Seemann)은 인간 단기 기억의 '마법의 숫자 7±2'에 빗대 7 안팎의 더 엄격한 상한을 주장한다. 다만 적정 임계값은 코드베이스 성격에 따라 다르다. 파서, 직렬화기, 상태 머신은 15~25 구간에 머물러도 객관적으로 나쁜 코드가 아닌 경우가 흔하지만, 같은 25점이 비즈니스 로직에서 나오면 거의 항상 문제다.

측정하지 않으면 개선할 수 없다는 점에서 도구 활용은 필수다. 비주얼 스튜디오는 '분석 > 코드 메트릭 계산' 명령으로 메서드·타입·어셈블리별 복잡도를 보고하고, CA1502를 빌드에 연결하면 설정한 임계값을 넘는 새 코드에서 빌드를 실패시킬 수 있다. SonarAnalyzer.CSharp, ReSharper, CodeRush 같은 도구도 편집기 안에서 같은 지표를 노출한다. NDepend는 복잡한 메서드를 찾는 검색 기능과 '너무 크거나 복잡한 메서드를 피하라' 같은 규칙, 그리고 색상 트리맵 시각화를 제공한다. 트리맵에서는 평온한 영역 한가운데 크고 진한 빨간 사각형이 곧바로 눈에 띄는데, 이것이 아키텍처 논의가 필요한 메서드다.

레거시에는 절대값보다 기준선

규모가 큰 레거시 코드베이스에서 모든 복잡한 메서드를 리팩터링하는 것은 비현실적이다. 그래서 절대 임계값보다 기준선(baseline) 기반 접근이 더 중요하다. 잘 다져진 코드베이스에서 복잡도를 추적하기 시작하면 수년간 안정적으로 잘 테스트돼 온 복잡한 메서드가 반드시 나온다. 진짜 위험은 정적인 점수 자체가 아니라 그런 메서드가 다시 자라기 시작할 때다. NDepend의 CQLinq로는 '이미 복잡한(CC>10) 메서드가 두 분석 스냅숏 사이에 더 복잡해졌을 때' 경고하는 질의를 표현할 수 있어, 안정된 레거시는 건드리지 않으면서 실제로 위험한 변경만 드러낸다.

순환 복잡도의 진짜 가치는 테스트 커버리지와 결합할 때 나온다. 테스트 하나는 대개 하나의 실행 경로를 덮으므로 복잡도는 필요한 테스트 수의 대략적 하한이 된다. 여기서 CRAP(Change Risk Analyzer and Predictor) 점수가 등장한다. 이 지표는 복잡도의 제곱과 미커버 비율의 세제곱을 결합한 뒤 원 복잡도를 바닥값으로 더한다. 그 함의는 분명하다. 복잡도 30에 커버리지 100%인 메서드보다 복잡도 12에 커버리지 0%인 메서드가 훨씬 큰 부채라는 것이다. 모든 복잡도가 동일한 위험은 아니며, 커버리지라는 두 번째 데이터가 그 차이를 드러낸다. 한 걸음 더 나아가 브랜치 커버리지로 위험을 재면 같은 복잡도라도 완전히 다른 위험 프로파일을 구분할 수 있고, IL 수준 복잡도를 쓰면 소스가 없는 서드파티 DLL 내부 메서드가 얼마나 복잡한지도 측정할 수 있다.

낮추는 방법과 이 지표의 한계

복잡도를 낮추는 기법은 결국 결정을 핵심 경로 바깥으로 빼내는 일이다. 연속된 결정 블록을 이름 있는 메서드로 추출하고, 잘못된 입력은 가드 절로 조기 반환하며, 타입 판별용 긴 switch는 전략·템플릿 메서드 패턴이나 서브클래스 오버라이드로 옮겨 중심 메서드를 CC=1로 축소한다. '입력 X는 출력 Y'식 매핑은 Dictionary나 정적 배열 조회로 바꾸면 항목 수와 무관하게 CC=1이 되고, 여러 bool 플래그를 받는 메서드는 대개 여러 메서드가 트렌치코트를 걸친 것이므로 목적별로 쪼개는 편이 낫다. 다만 순환 복잡도는 경로 수만 잰다. 중첩 깊이와 불리언 조합까지 가중하는 인지 복잡도(Cognitive Complexity)와는 상호 보완적이며, 완전한 테스트 계약이 아니라 유용한 하한일 뿐이라는 점을 기억해야 한다. 실행 불가능한 경로 때문에 더 적은 테스트로 충분할 수도, 한 경로 안의 데이터 조합 때문에 더 많은 테스트가 필요할 수도 있다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://blog.ndepend.com/understanding-cyclomatic-complexity...
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기
처리 중...