Featured image of post [UnitTesting] 01. 단위 테스트의 목표: 지속 가능한 성장

[UnitTesting] 01. 단위 테스트의 목표: 지속 가능한 성장

단위 테스트의 목표는 버그를 줄이는 것이 아니라 프로젝트의 지속 가능한 성장을 가능하게 하는 것입니다. 좋은 테스트와 나쁜 테스트를 가르는 기준, 코드·분기 커버리지 지표의 함정을 실제 코드 예제로 자세히 다룹니다.

01. 단위 테스트의 목표: 지속 가능한 성장

단위 테스트를 도입하는 이유를 물으면 흔히 “버그를 줄이려고"라고 답합니다. 틀린 말은 아니지만, 이 답으로는 “테스트를 얼마나 짤지”, “어디까지 테스트할지” 같은 실무 판단을 내릴 수 없습니다. 이 편은 단위 테스트의 목표를 더 구체적으로 정의하고, 그 목표를 기준으로 테스트의 좋고 나쁨을 가르는 방법을 다룹니다.

학습 목표

  • 단위 테스트의 목표를 “버그 감소"가 아니라 “지속 가능한 성장"으로 재정의할 수 있다.
  • 테스트가 있어도 프로젝트가 느려지는 이유를 설명할 수 있다.
  • 코드 커버리지·분기 커버리지 지표의 의미와 한계를 구분할 수 있다.

테스트 없는 프로젝트, 테스트 있는 프로젝트

테스트가 전혀 없는 프로젝트는 초반에 빠르게 진행됩니다. 검증 절차가 없으니 기능을 붙이는 속도 자체는 빠릅니다. 문제는 코드베이스가 커지면서 나타납니다. 한 부분을 고치면 다른 부분이 깨지고, 그 깨짐을 사람이 수동으로 찾아야 합니다. 시간이 지날수록 새 기능 하나를 추가하는 데 걸리는 시간이 늘어나고, 결국 팀은 “이 코드를 건드리는 게 무섭다"는 말을 하게 됩니다.

테스트는 이 흐름을 바꾸는 안전망입니다. 코드를 변경했을 때 기존 기능이 여전히 동작하는지 자동으로 확인해주므로, 리팩터링과 신규 기능 추가에 드는 심리적·시간적 비용이 줄어듭니다. 이 효과를 한 문장으로 요약하면 다음과 같습니다.

단위 테스트의 목표는 소프트웨어 프로젝트가 지속 가능하게 성장하도록 만드는 것이다. 더 나은 설계는 테스트를 작성하는 과정에서 따라오는 부수 효과이지, 목표 그 자체가 아니다.

테스트가 있어도 느려지는 이유

여기서 흔히 놓치는 지점이 있습니다. 테스트를 짰다는 사실 자체가 지속 가능한 성장을 보장하지 않습니다. 테스트가 많아도 프로젝트가 여전히 느려지는 팀이 있습니다. 원인은 대체로 다음 세 가지 중 하나입니다.

  • 거짓 양성(false positive)이 잦다: 코드 동작은 그대로인데 리팩터링만 해도 테스트가 깨진다. 팀은 곧 테스트 실패를 무시하는 습관이 든다.
  • 거짓 음성(false negative)이 잦다: 실제 버그가 들어가도 테스트가 통과한다. 테스트가 있다는 안도감만 주고 실질적인 보호는 못 한다.
  • 유지비가 이득보다 크다: 프로덕션 코드를 1줄 바꿀 때마다 테스트를 5줄씩 고쳐야 한다. 테스트 자체가 개발 속도를 깎아 먹는 짐이 된다.

즉 테스트는 “많을수록 좋다"가 아니라 **“목표에 기여하는 만큼만 가치가 있다”**는 관점으로 봐야 합니다. 이 관점은 이어지는 4~11편에서 반복해서 등장하는 핵심 기준입니다.

코드도, 테스트 코드도 자산이 아니라 부채다

이 관점을 실무에 적용할 때 자주 부딪히는 오해가 있습니다. “테스트 코드는 프로덕션 코드에 안전하게 얹는 보험이니 많을수록 좋다"는 생각입니다. 실제로는 정반대에 가깝습니다.

코드 한 줄은 그 자체로 자산이 아니라 유지해야 할 책임입니다. 코드가 많아질수록 이해해야 할 표면적이 넓어지고, 버그가 숨을 자리도 늘어나며, 수정할 때 함께 손봐야 할 곳도 늘어납니다. 테스트 코드도 예외가 아닙니다. 테스트 역시 버그가 있을 수 있고, 유지비가 들며, 잘못 작성되면 오히려 프로덕션 코드의 변경을 방해합니다. 그래서 “테스트를 얼마나 짤 것인가"는 “이 테스트가 짊어질 유지비보다 더 큰 가치를 돌려주는가"라는 질문으로 바꿔야 합니다.

커버리지 지표로 테스트 품질을 측정할 수 있을까

테스트 스위트의 품질을 숫자로 요약하고 싶은 유혹은 자연스럽습니다. 가장 널리 쓰이는 두 지표를 살펴봅시다.

**코드 커버리지(code coverage)**는 테스트 실행 중 통과한 코드 라인의 비율입니다.

$$\text{코드 커버리지} = \frac{\text{테스트가 실행한 라인 수}}{\text{전체 라인 수}}$$

다음 함수와 테스트를 예로 들어봅니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def calculate_shipping_fee(weight_kg: float, is_express: bool) -> int:
    if weight_kg <= 0:
        raise ValueError("weight_kg must be positive")
    base_fee = 3000 if weight_kg <= 5 else 3000 + int((weight_kg - 5) * 500)
    if is_express:
        base_fee += 2000
    return base_fee


def test_standard_shipping_under_5kg():
    assert calculate_shipping_fee(3.0, is_express=False) == 3000

이 함수는 실행 가능한 문장이 4개(가드 조건, 기본 요금 분기, 특급 요금 가산, 반환)이고, 위 테스트 하나는 그중 2개만 통과시킵니다. 코드 커버리지는 대략 50%입니다. 만약 무게 초과 분기와 특급 배송 분기를 검증하는 테스트를 추가하면 커버리지는 100%까지 오릅니다.

**분기 커버리지(branch coverage)**는 라인이 아니라 if/else 같은 분기 중 실제로 실행된 비율을 봅니다.

$$\text{분기 커버리지} = \frac{\text{통과한 분기 수}}{\text{전체 분기 수}}$$

위 함수에는 조건 분기가 3개(가드, 무게 기준, 특급 여부) 있으므로 총 6개 경로가 존재합니다. 테스트 하나로는 이 중 일부만 통과하므로, 코드 커버리지보다 분기 커버리지가 더 엄격하게 낮게 나오는 경우가 많습니다.

커버리지 숫자를 목표로 삼으면 안 되는 이유

분기 커버리지가 코드 커버리지보다 더 정교해 보이지만, 어떤 커버리지 지표도 테스트 품질을 완전히 보증하지 못합니다. 이유는 두 가지입니다.

첫째, 검증문 없는 테스트도 커버리지를 채운다. 아래 테스트는 calculate_shipping_fee의 모든 분기를 실행하지만, 반환값을 전혀 검증하지 않습니다.

1
2
3
4
5
6
7
def test_meaningless_but_full_coverage():
    calculate_shipping_fee(3.0, is_express=False)
    calculate_shipping_fee(7.0, is_express=True)
    try:
        calculate_shipping_fee(-1.0, is_express=False)
    except ValueError:
        pass

이 테스트는 코드 커버리지와 분기 커버리지를 모두 100%로 만들지만, 실제로는 아무것도 보장하지 않습니다. “100% 커버리지"라는 목표가 팀에 부과되면, 팀은 무의식적으로 이런 검증 없는 테스트를 양산하게 됩니다.

둘째, 외부 라이브러리·서드파티 코드의 내부 분기는 어떤 커버리지 지표도 잡아내지 못합니다. 예를 들어 int(input_str) 같은 표준 라이브러리 호출이 내부적으로 처리하는 예외 케이스(공백, 자릿수 초과, 잘못된 문자)는 우리 코드의 커버리지 수치에 전혀 반영되지 않습니다.

결론적으로 커버리지 지표는 좋은 부정 지표(커버리지가 낮으면 확실히 문제가 있다)이지만 나쁜 긍정 지표(커버리지가 높다고 품질이 보장되지는 않는다)입니다. 목표로 삼을 숫자가 아니라, 테스트가 손대지 않은 위험한 영역을 찾는 참고 자료로만 사용해야 합니다.

성공적인 테스트 스위트의 조건

커버리지 숫자 대신 다음 세 가지 특성으로 테스트 스위트의 성숙도를 판단하는 편이 실무적으로 더 신뢰할 수 있습니다.

  • 개발 주기에 통합돼 있다: 코드가 바뀔 때마다(로컬 실행이든 CI든) 자동으로 실행된다.
  • 코드베이스에서 가장 중요한 부분을 우선한다: 비즈니스 로직처럼 복잡도와 변경 빈도가 높은 영역에 투자를 집중한다.
  • 최소한의 유지비로 최대한의 가치를 낸다: 유지비가 가치를 넘어서는 테스트는 과감히 제거한다.

이 세 특성을 판단하는 구체적인 기준(무엇이 “가치"이고 무엇이 “유지비"인가)은 04편에서 다루는 4대 요소로 이어집니다.

실무 체크리스트

  • 새 테스트를 추가하기 전에 “이 테스트가 없으면 어떤 회귀를 놓치는가"를 설명할 수 있는가?
  • 팀에 강제된 커버리지 목표(예: “80% 이상”)가 검증 없는 테스트를 양산하고 있지 않은가?
  • 리팩터링할 때마다 관련 없어 보이는 테스트가 자주 깨지는가? (05편에서 다룰 리팩터링 내성 문제의 신호)
  • 유지비만 들고 실제 회귀를 잡아낸 적 없는 테스트가 스위트에 남아 있지 않은가?

연습 과제

기초(★☆☆)

  • 여러분의 프로젝트에서 함수 하나를 골라 코드 커버리지와 분기 커버리지를 각각 손으로 계산해보세요.

중급(★★☆)

  • 검증문 없이 커버리지만 채우는 테스트를 하나 찾아(또는 의도적으로 작성해), 그 테스트를 삭제해도 실제로 아무 위험이 늘지 않는지 확인해보세요.

고급(★★★)

  • 최근 3개월간 CI에서 실패한 테스트 로그를 모아, “실제 버그를 잡은 경우"와 “리팩터링 때문에 깨진 경우"의 비율을 계산해보세요. 후자가 높다면 05편의 리팩터링 내성 문제를 의심해보세요.

요약

  • 단위 테스트의 목표는 버그 감소가 아니라 프로젝트의 지속 가능한 성장이다.
  • 코드도 테스트도 자산이 아니라 유지해야 할 부채이며, 가치가 유지비를 넘어설 때만 남길 가치가 있다.
  • 커버리지 지표는 좋은 부정 지표이지만 나쁜 긍정 지표다. 목표가 아니라 참고 자료로 쓴다.

참고 문헌 및 출처(추천)

  • Vladimir Khorikov, 『Unit Testing: Principles, Practices, and Patterns』(Manning, 2020) — “지속 가능한 성장"을 단위 테스트의 목표로 제시
  • Michael Feathers, 『Working Effectively with Legacy Code』(2004) — 테스트를 안전망으로 보는 관점의 대표적 원전
  • Martin Fowler, “TestCoverage”(martinfowler.com bliki) — 커버리지 지표의 한계에 대한 논의

다음 글