개념
"이 함수, 테스트가 몇 개나 필요할까?"
레슨 7의 커버리지는 이미 만든 테스트를 평가하는 자 였다. 그런데 코드를 처음 받았을 때 드는 궁금증은 평가가 아니라 계획이다 — "이 함수를 제대로 테스트하려면 케이스가 대체 몇 개나 필요한 거지?"
"많아 보인다"고 감으로 답하면 대화가 거기서 끝나지만, 숫자로 답하면 그 숫자가 협상 테이블에 올라간다.
커버리지가 테스트를 돌린 뒤 "몇 %를 밟았나"를 재는 지표라면, 순환 복잡도는 테스트를 설계하기 전 "다 밟으려면 몇 개가 필요한가"를 알려주는 지표다. 커버리지는 코드를 실행해야 알 수 있지만 순환 복잡도는 눈으로 읽는 것만으로 셀 수 있다 — PR diff를 보다가 그 자리에서 판단할 수 있다는 뜻이다.
순환 복잡도(Cyclomatic Complexity)란
토마스 매케이브(Thomas McCabe)가 1976년에 제안한 지표로, 코드 안에 독립적인 경로가 몇 개 있는지 를 숫자 하나로 나타낸다. 계산법은 단순하다 — 분기를 만드는 요소(if, while, for, case, &&, ||)의 개수 + 1.
function getShippingFee(amount, isMember) {
if (amount >= 30000 && isMember) { // if 1개 + && 1개 = 분기 2개
return 0;
}
return 3000;
}이 함수의 순환 복잡도는 2(분기) + 1 = 3 이다. 레슨 7에서 이 함수를 100% 조건 커버리지로 만드는 데 세 번의 호출이 필요했던 걸 떠올려보면 복잡도 숫자가 실제로 필요했던 최소 케이스 수와 거의 일치 한다. 우연이 아니다 — 순환 복잡도는 정의상 "모든 독립 경로를 한 번씩 실행하는 데 필요한 최소 테스트 케이스 수"이기 때문이다.
"분기 + 1"은 원 논문의 정의를 실무용으로 줄인 것이다. 원래 정의는
코드를 제어흐름그래프(control flow graph) 로 그린 뒤
V(G) = E − N + 2로 센다(E는 간선 수, N은 노드 수). 두 방법은 항상
같은 값을 내므로 뒤에 나올 기저 경로 집합 을 뽑을 때 검산에 쓸 수
있다. 마지막에 1을 더하는 이유도 여기 있다 — 분기가 없어도 코드를
통과하는 길은 이미 하나 있다.
코드 패턴별로 분기를 세어보기
if만 나오는 코드는 세기 쉽다. 문제는 실무 코드가 switch·반복문·
삼항·단축 평가를 뒤섞어 쓴다는 점이다. 패턴별로 세어보자.
if / else if / else
if (score >= 90) return "A"; // +1
else if (score >= 80) return "B"; // +1
else return "C"; // +0분기 2개 + 1 = 복잡도 3. 자주 나오는 실수가 else를 세는 것이다.
else는 새 판정이 아니라 "앞 판정이 거짓일 때 가는 길" 이라 이미
if를 셀 때 계산됐다. else if는 안에 새 조건식이 있으므로 다르다.
switch-case
switch (grade) {
case "GOLD": return 0; // +1
case "SILVER": return 1000; // +1
case "BRONZE": return 2000; // +1
default: return 3000; // +0
}case 3개 + 1 = 복잡도 4. default는 else와 같은 이유로 세지
않는다. 복잡도는 break 유무와 무관하게 같은 값을 내므로, break가
빠져 다음 case로 흘러내리는(fall-through) 버그 는 눈으로 확인해야
한다.
for / while 반복문
for (const item of items) { // +1
total += item.price;
}분기 1개 + 1 = 복잡도 2. 반복문이 분기인 이유는 "한 번이라도
도는가 / 한 번도 안 도는가"라는 두 갈래 를 만들기 때문이다. 반복문을
보는 순간 케이스가 떠올라야 한다 — 빈 배열, 1개, 여러 개.
items.map(...) 처럼 반복이 함수 뒤에 숨은 형태도 +1이다.
삼항 연산자와 단축 평가 &&, ||
const label = isVip ? "VIP 회원" : "일반 회원"; // +1
if (isMember && amount >= 30000 && !isBlocked) { // if +1, && +2
applyFreeShipping();
}삼항 연산자는 한 줄로 쓰인 if다. 한 줄이라 손으로 셀 때 가장
많이 놓친다. &&가 두 개면 +2인 이유는 단축 평가(short-circuit)
때문이다(레슨 7) — isMember가 거짓이면 나머지 두 조건은 평가되지
않으므로 갈라지는 지점이 실제로 세 군데다. &&와 ||는 눈에 보이지
않는 if 다. user.name || "손님" 같은 짧은 표현도 분기 하나이고,
catch 절도 하나당 +1이다.
한 장 요약
| 코드 요소 | 더하는 값 | 메모 |
|---|---|---|
if, else if | +1 | else if는 새 조건식이 있다 |
else, default | +0 | 새 판정이 아니다 |
switch의 각 case | +1 | |
for, while, do-while | +1 | 도는가 / 안 도는가 |
map, filter, forEach | +1 | 반복이 숨어 있다 |
삼항 ? : | +1 | 한 줄짜리 if |
&&, ||, ?? | 각 +1 | 단축 평가로 갈라진다 |
catch | 각 +1 | try·finally는 +0 |
| 마지막에 | +1 | 분기가 없어도 길은 하나 있다 |
도구마다 ?.나 catch를 세는 방식이 달라 손으로 센 값과 SonarQube
값이 1~2 차이 나는 일은 흔하다. 중요한 건 3인지 4인지가 아니라 3인지
30인지다.
복잡도 숫자를 어떻게 읽는가
복잡도 1~10 : 단순하고 테스트하기 쉬움
복잡도 11~20 : 조금 복잡함. 리팩터링을 고려할 만함
복잡도 21~50 : 복잡하고 위험함. 결함이 숨어있을 가능성 높음
복잡도 50 이상: 테스트도 유지보수도 사실상 불가능한 수준절대적인 법칙은 아니지만 복잡도가 10을 넘는 함수는 버그 밀도가 통계적으로 높다 는 연구가 반복 확인됐다. 다만 이 기준은 함수 단위로 읽어야 한다 — 잘 쪼개진 함수 30개가 모인 파일도 합계는 60이 넘는다.
복잡도 15짜리 함수 앞에서 — 왜 이게 위험 신호인가
숫자만 보면 실감이 안 난다. 어느 커머스 저장소에나 있을 법한 주문 금액 계산 함수를 보자.
function calculateOrderTotal(order, user, coupon) {
let total = 0;
for (const item of order.items) { // +1
if (item.isGift) continue; // +1
total += item.price * item.quantity;
}
if (user.grade === "VIP") total *= 0.9; // +1
else if (user.grade === "GOLD") total *= 0.95; // +1
if (coupon) { // +1
if (coupon.type === "FIXED") total -= coupon.amount; // +1
else if (coupon.type === "RATE") total -= total * coupon.rate; // +1
if (coupon.minAmount && total < coupon.minAmount) { // +1, && +1
total += coupon.amount || 0; // || +1
}
}
if (order.isExpress && !user.hasFreeShipping) { // +1, && +1
total += 5000;
}
const used = user.point > total ? total : user.point; // +1
total -= used;
if (total < 0) total = 0; // +1
return Math.round(total);
}분기 14개 + 1 = 복잡도 15. 100줄짜리 괴물이 아니라 스무 줄 남짓한 함수다. 이게 왜 위험 신호인가 — 이유는 세 가지이고 QA의 부담은 그중 하나일 뿐 이다.
첫째, 테스트 비용이 뛴다. 독립 경로가 최소 15개인데 이건 하한선이다. 쿠폰 타입 × 등급 × 특급배송으로 조합이 곱해지면 의미 있는 케이스는 수십 개가 되고, 이걸 릴리즈마다 회귀 확인해야 한다.
둘째, 개발자 자신도 전체 경로를 머릿속에 담지 못한다. 사람이 동시에 추적할 수 있는 조건은 대여섯 개가 한계인데 여기엔 14개가 얽혀 있다.
그래서 조건을 추가할 때 상호작용을 놓치고, 리뷰어는 전 경로를 따라가는 대신 "괜찮아 보인다"로 넘기며, 안 쓰는 것처럼 보이는 조건을 "지워도 되겠지" 하고 지운다.
위 함수에 실제로 그런 결함이 숨어 있다. coupon.amount || 0 줄은
"최소금액을 못 맞추면 정액 할인을 되돌린다"는 뜻인데 RATE 쿠폰에는
amount가 없으므로 0이 더해진다 — 율 할인 쿠폰은 최소금액을
어겨도 할인이 취소되지 않는다. 함수가 쪼개져 있었다면 리뷰에서
걸렸을 결함이다.
셋째, 회귀 범위를 좁힐 수 없다. 복잡도가 낮으면 "이 줄을 고쳤으니 이 경로만 보면 됩니다"가 성립하지만, 15에서는 함수 전체를 다시 확인 해야 한다.
그래서 순환 복잡도는 "케이스 계산기"이기 이전에 코드 품질 지표 다. 숫자가 높다는 건 "테스트가 많이 필요하다"는 뜻이면서 동시에 "이미 아무도 모르는 버그가 있을 확률이 높다" 는 뜻이다.
덧붙이면 순환 복잡도는 중첩 깊이를 구분하지 못한다. 나란히 놓인
if 다섯 개와 5중 중첩된 if는 둘 다 복잡도 6이지만 읽기 난이도는
다르다. 이 한계를 보완한 것이 SonarSource의 인지 복잡도(Cognitive
Complexity) 로, 중첩될수록 가중치를 더 준다.
경로 테스트(Path Testing) — 숫자를 실제 케이스로
순환 복잡도가 "몇 개가 필요한가"를 알려준다면, 경로 테스트는 "그 각각이 정확히 무엇인가"를 찾는 기법이다.
function classify(score, hasBonus) {
if (score >= 90) {
return hasBonus ? "S+" : "S";
} else if (score >= 70) {
return "A";
}
return "F";
}이 함수의 독립 경로 4가지를 그림 대신 표로 나열하면 이렇다.
| 경로 | 조건 | 결과 |
|---|---|---|
| 1 | score=95, hasBonus=true | S+ |
| 2 | score=95, hasBonus=false | S |
| 3 | score=75 | A |
| 4 | score=50 | F |
이 네 줄이 곧 네 개의 테스트 케이스다. 레슨 1(동등분할·경계값)이 "입력값 관점"이라면, 경로 테스트는 "코드가 갈 수 있는 모든 길" 관점 이라는 점이 다르다.
기저 경로 집합(basis path set)을 뽑는 절차
위 예시는 눈으로 됐지만 조건이 서너 개만 넘어가도 빠뜨린다. 순서를 정해놓고 따라가면 빠뜨리지 않는다.
function login(id, pw, isLocked) {
if (!id || !pw) { // ① ②
return "입력 누락";
}
if (isLocked) { // ③
return "계정 잠김";
}
if (pw.length < 8) { // ④
return "비밀번호 형식 오류";
}
return "로그인 성공";
}1단계 — 판정에 번호를 붙인다. !id(①), !pw(②), isLocked(③),
pw.length < 8(④). ||가 만든 ②를 빠뜨리지 않는 게 핵심.
2단계 — 제어흐름그래프를 텍스트로 그린다. 그림 도구는 필요 없다. 판정 노드와 참·거짓이 어디로 가는지만 적는다.
N1 진입 ──→ N2
N2 판정 ① !id ─ 참 → N4 / 거짓 → N3
N3 판정 ② !pw ─ 참 → N4 / 거짓 → N5
N4 return "입력 누락" ──→ N10
N5 판정 ③ isLocked ─ 참 → N6 / 거짓 → N7
N6 return "계정 잠김" ──→ N10
N7 판정 ④ pw.length < 8 ─ 참 → N8 / 거짓 → N9
N8 return "형식 오류" ──→ N10
N9 return "로그인 성공" ──→ N10
N10 종료3단계 — 복잡도를 두 방법으로 교차 검산한다. 분기 세기로는
if 3개 + || 1개 = 4, 그래서 5. 그래프로는 간선 13개, 노드
10개이므로 13 − 10 + 2 = 5. 어긋나면 판정을 빠뜨렸거나
화살표를 잘못 그린 것이니 여기서 되돌아간다.
4~5단계 — 기준 경로를 정하고 판정을 하나씩만 뒤집는다. 기준은 모든 판정이 거짓인 정상 경로(로그인 성공)다. 여기서 ①만 뒤집고, 다시 돌아와 ②만 뒤집고… 판정 수만큼 반복한다. 기준 1개 + 판정 4개 = 5개로 3단계의 복잡도와 맞는다.
6단계 — 각 경로에 실행 가능한 입력값을 붙인다. 여기서 테스트 케이스가 된다.
| 경로 | 뒤집은 판정 | id | pw | isLocked | 기대 결과 |
|---|---|---|---|---|---|
| P1 (기준) | — | "user01" | "password1" | false | 로그인 성공 |
| P2 | ① !id | "" | "password1" | false | 입력 누락 |
| P3 | ② !pw | "user01" | "" | false | 입력 누락 |
| P4 | ③ isLocked | "user01" | "password1" | true | 계정 잠김 |
| P5 | ④ 길이 | "user01" | "abc" | false | 비밀번호 형식 오류 |
여기서 반드시 짚어야 할 두 가지가 있다.
P2와 P3는 결과가 같지만 다른 케이스다. 둘 다 "입력 누락"이라 합치고
싶어지지만 지나간 길이 다르다. !pw 조건만 실수로 지워도 P2는
통과한다 — P3가 있어야 잡힌다.
모든 조합이 만들어지지는 않는다. ③이 참이면서 ④도 참인 경로는
없다 — isLocked가 참이면 거기서 리턴하기 때문이다. 이런 도달 불가
경로를 실행 불가능 경로(infeasible path) 라고 한다. 억지로 만들지
말고 빼되, 뺄 이유를 못 찾겠는데 값이 안 만들어지면 코드 쪽 결함
신호일 수 있다.
이 숫자를 실무에서 어디서 만나는가
손으로 세는 건 감을 잡기 위한 연습이고, 실무에서는 도구가 함수별로
자동 계산해준다. JavaScript는 ESLint의 complexity 룰, Python은
radon, Java는 PMD·Checkstyle이 대표적이다.
QA가 가장 자주 마주치는 건 SonarQube 다. PR을 열면 CI가 분석을 돌리고, 함수별 Cyclomatic·Cognitive Complexity가 나란히 표시되며, 임계값을 넘으면 Quality Gate에 걸려 머지가 막힌다. 스테이지 7 모듈 23 레슨 1에서 SonarQube 화면을 직접 다루므로, 여기서는 "그 화면의 숫자가 무엇을 세는 숫자인지"만 확실히 해두면 된다.
이 숫자가 손에 있으면 QA가 하는 말이 달라진다.
| 숫자 없이 | 숫자를 근거로 |
|---|---|
| "테스트가 좀 부족해 보여요" | "이 함수 복잡도가 20인데 단위 테스트가 3개입니다. 최소 17개 경로가 안 밟혔습니다" |
| "리팩터링하면 좋을 것 같아요" | "쿠폰 계산을 빼면 복잡도가 15에서 7로 떨어지고, 쿠폰 로직을 독립 검증할 수 있습니다" |
오른쪽 문장들의 공통점은 의견이 아니라 사실로 쓰였다는 것 이다. 그리고 이 지표는 레슨 7의 커버리지와 곱해서 볼 때 가장 쓸모가 있다.
| 커버리지 높음 | 커버리지 낮음 | |
|---|---|---|
| 복잡도 높음 | 관리되고 있음. 변경 시 회귀 확인은 필수 | 최우선 위험 구역 — 여기부터 본다 |
| 복잡도 낮음 | 안정적 | 대체로 낮은 위험. 단 결제·인증이면 예외 |
오른쪽 위 칸이 이 레슨의 실무적 결론이다. "복잡도가 높은데 커버리지가 낮은 함수" 가 곧 릴리즈 전 수동·탐색적 테스트를 집중할 목록이다.
그레이박스 테스트와의 연결
레슨 7에서 화이트박스(코드를 보고 설계)와 명세 기반 기법(코드를 안 보고 설계)을 구분했다. 실무에서는 코드 구조를 대략 참고하면서 명세 기반으로 설계하는 경우가 많은데, 이걸 그레이박스 테스트(Grey Box Testing) 라고 부른다. 순환 복잡도로 "이 함수는 복잡하니 더 꼼꼼히 봐야겠다"고 판단한 뒤 실제 케이스는 명세 기반으로 설계하는 것이 전형적인 그레이박스 접근이다 — 코드 내부를 몰라도 복잡도라는 힌트만으로 어디에 힘을 쏟을지 판단할 수 있다.
30개 함수가 바뀐 PR을 전부 읽을 수 없을 때 복잡도 순으로 상위 3개만 읽는 것 — 이게 모듈 4의 위험 기반 테스트 를 코드 레벨에서 실행하는 방법이다.
실무에서 왜 필요한가
"이 PR, 테스트가 부족해 보이는데 얼마나 더 필요할까요?"라는 질문에 순환 복잡도는 근거 있는 답을 준다 — "이 함수 복잡도가 12인데 테스트 케이스는 3개뿐이니, 최소 9개는 더 있어야 독립 경로를 다 커버합니다." 모듈 10(단위 테스트)에서 "테스트가 충분한가"를 감이 아니라 숫자로 논쟁하게 해주는 도구다. 코드 리뷰에서 복잡도가 높은 함수를 미리 짚어내면 버그가 나기 전에 "이 함수부터 쪼개는 게 어떨까요?"라고 제안할 수도 있다 — 레슨 1의 "일찍 찾을수록 싸다"가 설계 단계에 적용되는 사례다.
무엇보다 설계 단계에 개입할 명분 이 생긴다. "이 함수 쪼개주세요"는 월권으로 들리지만, "복잡도가 15라 케이스가 15개 필요한데 쿠폰 계산을 빼면 8과 5로 나뉘어 각각 독립 검증됩니다"는 협업 제안으로 들린다. 테스트 용이성(testability)은 QA가 요구할 자격이 있는 품질 속성 이고, 복잡도는 그 요구를 숫자로 표현하는 언어다.