본문으로 건너뛰기
고급24

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

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

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

개념

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

레슨 7에서 구문·분기·조건 커버리지를 배웠다. 그런데 실무에서 코드를 받으면 가장 먼저 드는 궁금증은 이거다 — "이 함수를 제대로 테스트하려면 케이스가 대체 몇 개나 필요한 거지?" 감으로 "많아 보인다"고 답하는 대신, 이 질문에 숫자로 답하는 방법이 있다.

순환 복잡도(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% 조건 커버리지로 만드는 데 세 번의 호출 (50000,true/50000,false/10000,true)이 필요했던 걸 떠올려보면, 순환 복잡도 숫자가 실제로 필요했던 최소 테스트 케이스 수와 거의 일치한다는 걸 알 수 있다. 이게 우연이 아니다 — 순환 복잡도는 정의상 "이 코드의 모든 독립 경로를 한 번씩 실행하는 데 필요한 최소 테스트 케이스 수"이기 때문이다.

복잡도가 커지면 무슨 일이 생기는가

복잡도 1~10   : 단순하고 테스트하기 쉬움
복잡도 11~20  : 조금 복잡함. 리팩터링을 고려할 만함
복잡도 21~50  : 복잡하고 위험함. 결함이 숨어있을 가능성 높음
복잡도 50 이상: 테스트도 유지보수도 사실상 불가능한 수준

이 기준은 절대적인 법칙은 아니지만, 복잡도가 10을 넘는 함수는 버그 밀도가 통계적으로 높다는 연구가 여러 번 반복 확인됐다. SonarQube 같은 정적 분석 도구(레슨 7)가 "이 함수는 복잡도가 높습니다"라고 경고하는 게 바로 이 지표다.

경로 테스트(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(동등분할·경계값)이 "입력값 관점"에서 케이스를 뽑았다면, 경로 테스트는 "코드가 갈 수 있는 모든 길" 관점에서 뽑는다는 점이 다르다.

그레이박스 테스트와의 연결

레슨 7에서 화이트박스(코드를 보고 테스트 설계)와 명세 기반 기법(코드를 안 보고 설계)을 구분했다. 실무에서는 이 둘을 완전히 분리하기보다, 코드 구조를 대략 참고하면서 명세 기반으로 테스트를 설계하는 경우가 많다 — 이걸 **그레이박스 테스트(Grey Box Testing)**라고 부른다. 예를 들어 이 레슨의 순환 복잡도 지표로 "이 함수는 복잡하니 더 꼼꼼히 봐야겠다"고 판단한 뒤, 실제 테스트 케이스는 명세 기반으로 설계하는 것이 전형적인 그레이박스 접근이다 — 코드 내부를 완전히 몰라도, 복잡도라는 힌트만으로 어디에 힘을 쏟을지 판단할 수 있다.

실무에서 왜 필요한가

"이 PR, 테스트가 부족해 보이는데 얼마나 더 필요할까요?"라는 질문에 순환 복잡도는 근거 있는 답을 준다 — "이 함수 복잡도가 12인데 테스트 케이스는 3개뿐이니, 최소 9개는 더 있어야 독립 경로를 다 커버합니다." 이건 모듈 10(단위 테스트)에서 리뷰할 때 "테스트가 충분한가"를 감이 아니라 숫자로 논쟁할 수 있게 해준다. 또한 코드 리뷰에서 복잡도가 유난히 높은 함수를 미리 짚어내면, 버그가 나기 전에 "이 함수부터 쪼개는 게 어떨까요?"라고 제안할 수 있다 — 레슨 1에서 배운 "일찍 찾을수록 싸다"는 원칙이 설계 단계에도 적용되는 사례다.

실습 과제

과제 1 — 순환 복잡도 계산하기 (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개, 삼항연산자 hasCoupon ? ... : ...도 분기 1개로 센다. 총 4개 분기 + 1 = 순환 복잡도 5.

이 숫자는 "이 함수의 모든 독립 경로를 한 번씩 실행하려면 최소 5개의 테스트 케이스가 필요하다"는 뜻이다. 실제로 케이스를 나열해 보면 정확히 5가지 조합(경로)이 나온다는 걸 확인해보자.

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

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

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

어? 6개가 나왔는데 복잡도는 5였다. 이게 바로 이 과제의 핵심 함정이다 — isVipitemCount > 10||로 묶여 있어서, "OR 조건이라 어느 한쪽만 참이어도 같은 분기를 타는" 경로들이 합쳐진다. 순환 복잡도는 분기의 개수를 세는 것이지 입력 조합의 개수를 세는 게 아니다 — 조건이 &&/||로 복잡하게 얽히면 실제 필요한 케이스 수가 복잡도 숫자와 정확히 일치하지 않을 수 있다. 이 오차 자체가 실무의 교훈이다: 순환 복잡도는 "대략 얼마나 필요한지" 가늠하는 지표이지, 정확한 케이스 개수를 보장하는 공식이 아니다.

과제 3 — 복잡도 낮추기 제안하기 (10분)

과제 1의 getDiscount 함수가 나중에 조건이 두 개 더 추가되어 복잡도가 9까지 올라갔다고 하자. QA로서 개발자에게 어떤 제안을 할 수 있을지(테스트 케이스를 늘리는 것 외의 방법으로) 한두 문장 적는다.

해설 보기

"조건을 더 추가하기 전에, 이 함수를 여러 개의 작은 함수로 쪼개는 게 어떨까요?"가 방향이다. 예를 들어 "VIP 여부와 구매 수량으로 등급을 정하는 부분"과 "쿠폰 할인을 더하는 부분"을 별도 함수로 분리하면, 각 함수의 복잡도가 낮아지고 독립적으로 테스트하기 쉬워진다. 이건 QA가 테스트 용이성(testability) 관점에서 설계에 개입하는 대표적인 사례다 — 모듈 9에서 다룰 "테스트 용이성 판단"과 이어지는 지점이다.

자가 체크리스트

  • 순환 복잡도를 코드에서 직접 계산할 수 있다(분기 요소 개수 + 1)
  • 순환 복잡도가 최소 필요 테스트 케이스 수와 어떻게 연결되는지 설명할 수 있다
  • &&/||로 조건이 얽히면 왜 복잡도 숫자와 실제 필요 케이스 수가 정확히 일치하지 않을 수 있는지 설명할 수 있다
  • 경로 테스트가 동등분할·경계값과 다른 관점(입력값 vs 코드 경로)에서 케이스를 뽑는다는 것을 설명할 수 있다
  • 그레이박스 테스트가 화이트박스·명세 기반 기법과 어떻게 다른지 설명할 수 있다
  • 순환 복잡도가 높은 코드를 발견했을 때 테스트 케이스 추가 외의 대안을 제안할 수 있다

흔한 실수

  • 순환 복잡도를 정확한 테스트 케이스 개수 공식으로 오해한다. 과제 2에서 봤듯, 복합 조건이 얽히면 실제 필요한 조합 수가 달라질 수 있다. "대략적인 가늠자"로 쓰는 것이 맞다.
  • 복잡도가 낮은 코드는 테스트를 대충 해도 된다고 생각한다. 복잡도는 "결함이 숨어있을 가능성"과 상관관계가 있을 뿐, 복잡도 1짜리 함수에도 로직 자체가 틀린 결함은 얼마든지 있을 수 있다.
  • 경로 테스트를 만능으로 여겨 명세 기반 기법을 대체하려 한다. 경로 테스트는 "코드가 어떻게 짜여 있는가"만 본다. "그 코드가 요구사항을 제대로 구현했는가"는 여전히 명세 기반 기법(레슨 1~6)이 봐야 하는 영역이다.

참고 자료