본문으로 건너뛰기
고급약 24분

화이트박스 심화 — 순환 복잡도로 최소 테스트 케이스 수 계산하기

코드가 복잡할수록 필요한 테스트 케이스도 늘어난다. 이 관계를 감이 아니라 숫자(순환 복잡도)로 계산하고, 코드의 모든 독립 경로를 실제로 테스트하는 경로 테스트를 익힌다.

  • #순환복잡도
  • #CyclomaticComplexity
  • #경로테스트
  • #화이트박스

개념

"이 함수, 테스트가 몇 개나 필요할까?"

레슨 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+1else if는 새 조건식이 있다
else, default+0새 판정이 아니다
switch의 각 case+1
for, while, do-while+1도는가 / 안 도는가
map, filter, forEach+1반복이 숨어 있다
삼항 ? :+1한 줄짜리 if
&&, ||, ??각 +1단축 평가로 갈라진다
catch각 +1try·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가지를 그림 대신 표로 나열하면 이렇다.

경로조건결과
1score=95, hasBonus=trueS+
2score=95, hasBonus=falseS
3score=75A
4score=50F

이 네 줄이 곧 네 개의 테스트 케이스다. 레슨 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단계 — 각 경로에 실행 가능한 입력값을 붙인다. 여기서 테스트 케이스가 된다.

경로뒤집은 판정idpwisLocked기대 결과
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가 요구할 자격이 있는 품질 속성 이고, 복잡도는 그 요구를 숫자로 표현하는 언어다.

실습 과제

과제 1 — 코드 패턴별로 분기 세기 (10분)

switch·반복문·삼항·단축 평가가 섞인 함수의 복잡도를 센다. 요약표를 옆에 두고 한 줄씩.

function summarize(orders, filterType) {
  let count = 0, total = 0;
  for (const order of orders) {
    switch (order.status) {
      case "PAID": count += 1; total += order.amount; break;
      case "REFUNDED": total -= order.amount; break;
      default: break;
    }
    if (order.isGift && filterType !== "ALL") count -= 1;
  }
  return { count, total, average: count > 0 ? total / count : 0 };
}
해설 보기

for +1, case "PAID" +1, case "REFUNDED" +1, default +0, if (order.isGift && ...) +1, && +1, count > 0 ? ... : ... +1, 기본 경로 +1 = 복잡도 7.

놓치기 쉬운 두 자리가 &&와 마지막 줄의 삼항이다. 특히 삼항은 return 문 안에 객체 속성으로 숨어 있어 시선이 그냥 지나간다.

한 걸음 더 — REFUNDED만 있는 목록이면 count는 0인데 total은 음수라 average가 0이 된다. 맞는 동작인가? 복잡도 계산은 "어디를 봐야 하는지"만 알려준다.

과제 2 — 순환 복잡도 계산하기 (12분)

다음 함수의 순환 복잡도를 분기 요소를 하나씩 세어가며 계산한다.

function getDiscount(isVip, itemCount, hasCoupon) {
  if (isVip || itemCount > 10) {
    if (hasCoupon) {
      return 0.3;
    }
    return 0.2;
  }
  return hasCoupon ? 0.1 : 0;
}
해설 보기

if (isVip || itemCount > 10)에서 if 1개 + || 1개, if (hasCoupon)에서 if 1개, 삼항연산자도 분기 1개. 총 4개 분기 + 1 = 순환 복잡도 5. "모든 독립 경로를 한 번씩 실행하려면 최소 5개의 테스트 케이스가 필요하다"는 뜻이다.

과제 3 — 경로 테스트 케이스 뽑아내기 (15분)

과제 2의 getDiscount에 대해 복잡도 5에 맞춰 독립 경로를 표(입력값 조합 → 기대 결과)로 나열한다.

해설 보기
경로isVipitemCounthasCoupon기대 결과
1true(무관, 예: 3)true0.3
2true3false0.2
3false15true0.3
4false15false0.2
5false3true0.1
6false3false0

어? 6개가 나왔는데 복잡도는 5였다. 이게 이 과제의 핵심 함정이다 — isVip와 itemCount > 10이 ||로 묶여 있어 "어느 한쪽만 참이어도 같은 분기를 타는" 경로들이 합쳐진다. 순환 복잡도는 분기의 개수 를 세는 것이지 입력 조합의 개수 를 세는 게 아니다. 이 오차 자체가 교훈이다 — 복잡도는 가늠자이자 거의 항상 하한선이다. "12니까 12개면 충분하다"가 아니라 "12개는 있어야 시작이다"로 읽는 게 안전하다.

과제 4 — 제어흐름그래프에서 기저 경로 뽑기 (20분)

아래 함수에 개념 절의 6단계를 그대로 적용한다. 판정 번호 → 텍스트 그래프 → 복잡도 교차 검산 → 기준 경로 → 하나씩 뒤집기 → 입력값 붙이기 순서다.

function applyPoint(user, amount, usePoint) {
  if (amount <= 0) {
    return 0;
  }
  let payable = amount;
  if (usePoint && user.point > 0) {
    const used = user.point >= payable ? payable : user.point;
    payable -= used;
  }
  return payable;
}
해설 보기

1단계 — 판정 번호. ① amount <= 0, ② usePoint, ③ user.point > 0, ④ user.point >= payable(삼항). &&가 만든 ③과 삼항이 만든 ④를 빠뜨리지 않는 것이 핵심이다.

2단계 — 텍스트 그래프. 판정 노드만 늘어놓으면 이렇다.

N1 판정 ① amount <= 0        ─ 참 → return 0 / 거짓 → N2
N2 판정 ② usePoint            ─ 참 → N3 / 거짓 → return payable
N3 판정 ③ user.point > 0      ─ 참 → N4 / 거짓 → return payable
N4 판정 ④ point >= payable    ─ 참·거짓 모두 → return payable (used만 다름)

3단계 — 검산. 분기 세기로는 if 2개 + && 1개 + 삼항 1개 = 4, 따라서 5. 판정 노드도 4개이므로 독립 경로는 5개다.

4~6단계 — 기준 경로와 뒤집기.

경로뒤집은 판정amountusePointuser.point기대 결과
P1 (기준)—10000false500010000
P2①0false50000
P3② → 참10000true50005000
P4③ → 거짓10000true010000
P5④ → 참10000true300000

관찰할 것이 세 가지다.

P1과 P4는 결과가 똑같이 10000이지만 지나간 길이 다르다. P1은 usePoint가 거짓이라 포인트 로직에 들어가지도 않았고, P4는 들어갔다가 포인트가 0이라 못 쓴 것이다.

P1에서 user.point는 읽히지도 않는다. usePoint &&의 단축 평가 때문이다. user가 undefined라면 P1은 통과하고 P3에서 터진다 — "가끔만 터지는 버그"의 전형이다.

이 표에 없는 케이스도 있다. amount가 음수인 경우는 P2와 같은 경로지만, 경계값 분석(레슨 1)으로는 -1·0·1을 따로 봐야 한다. 경로 테스트는 "빠짐없이"를 보장하지 "충분히"는 보장하지 않는다.

과제 5 — 복잡도가 높은 함수에 대한 QA 의견 쓰기 (12분)

개념 절의 calculateOrderTotal(복잡도 15)이 이번 PR에 포함돼 있다. QA로서 남길 코멘트를 3~5문장으로 쓴다. ① 숫자로 표현한 현재 상태 ② 이번 릴리즈에서 QA의 대응 ③ 케이스를 늘리는 것 외의 구조 개선 제안을 반드시 포함한다.

해설 보기

예시 코멘트다.

calculateOrderTotal의 순환 복잡도가 15입니다(분기 14개). 쿠폰 타입·회원 등급 조합까지 보면 확인할 케이스는 그보다 많아 보여, 이번 릴리즈에서는 이 함수를 회귀 범위 최상단에 두겠습니다. 특히 coupon.amount || 0 줄이 RATE 쿠폰에서는 항상 0을 더하게 되어 있어서 율 할인 쿠폰이 최소금액을 못 맞춰도 할인이 취소되지 않는 것 같은데, 의도된 동작인지 확인 부탁드립니다. 쿠폰 적용 부분을 applyCoupon(total, coupon)으로 분리하면 복잡도가 8 정도로 내려가고 쿠폰 로직을 독립 검증할 수 있을 것 같습니다.

핵심은 개발자를 평가하는 문장이 하나도 없다는 것 이다. 복잡도 숫자는 사람이 아니라 코드에 대한 사실이라 방어적인 반응을 부르지 않는다.

자가 체크리스트

  • switch, 반복문, 삼항, &&/||가 각각 복잡도에 얼마를 더하는지 알고, else·default를 왜 세지 않는지 설명할 수 있다
  • E − N + 2 정의로 계산한 값과 "분기 + 1"이 같아야 한다는 것을 안다
  • &&/||로 조건이 얽히면 왜 복잡도 숫자와 실제 필요 케이스 수가 일치하지 않을 수 있는지 설명할 수 있다
  • 복잡도가 높은 코드가 QA 부담뿐 아니라 개발자의 실수 확률까지 높인다는 것을 설명할 수 있다
  • 순환 복잡도와 인지 복잡도의 차이(중첩 반영 여부)를 설명할 수 있다
  • 제어흐름그래프를 텍스트로 그리고 기저 경로 집합을 6단계 절차로 뽑아낼 수 있다
  • 결과가 같아도 경로가 다르면 별개의 케이스인 이유를 설명할 수 있고, 실행 불가능 경로를 만났을 때 어떻게 처리할지 안다
  • 복잡도와 커버리지를 교차해 "최우선 위험 구역"을 지목할 수 있다
  • 순환 복잡도가 높은 코드를 발견했을 때 테스트 케이스 추가 외의 대안을 제안할 수 있다

흔한 실수

  • 순환 복잡도를 정확한 테스트 케이스 개수 공식으로 오해한다. 과제 3에서 봤듯, 복합 조건이 얽히면 실제 필요한 조합 수가 달라질 수 있다. "대략적인 가늠자"이자 하한선 으로 쓰는 것이 맞다.
  • else·default를 세어 숫자를 부풀리고, 삼항과 &&/||는 빠뜨린다. 손으로 셀 때 가장 흔한 두 오류다. else if는 안에 새 조건식이 있으므로 세야 한다.
  • 파일·클래스 단위의 복잡도 합계를 보고 판단한다. 잘 쪼개진 파일도 합계는 크다. 봐야 할 것은 한 함수에 몰린 복잡도 다.
  • 복잡도가 낮으면 테스트를 대충 해도 된다고 생각한다. 복잡도는 "결함 가능성"과 상관관계가 있을 뿐, 복잡도 1짜리 함수에도 로직이 틀린 결함은 얼마든지 있다.
  • 결과가 같은 경로를 하나로 합친다. 같은 값을 돌려줘도 지나온 길이 다르면 다른 케이스다. 합치는 순간 그 길의 결함을 놓친다.
  • 경로 테스트를 만능으로 여겨 명세 기반 기법을 대체하려 한다. 경로 테스트는 "코드가 어떻게 짜여 있는가"만 본다. "그 코드가 요구사항을 제대로 구현했는가"는 명세 기반 기법(레슨 1~6)의 영역이다.
  • 복잡도를 개발자 평가 지표로 쓴다. 그 순간 숫자만 낮추려 함수를 억지로 쪼개는 대응이 나온다. 목적은 평가가 아니라 위험 지점 파악이다.

참고 자료

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

PR — 풀 리퀘스트
"내가 고친 코드를 본체에 합쳐 달라"고 올리는 요청. 합치기 전에 사람들이 보고 의견을 단다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
CI — 지속적 통합
코드를 올릴 때마다 자동으로 합쳐서 빌드하고 시험까지 돌리는 방식.
ISTQB — 국제 소프트웨어 테스팅 자격 위원회
테스터 자격 시험을 만들고 용어를 정리하는 국제 단체.

용어집 전체 보기