기술 부채보다 위험한 이유와 측정 방법
"테스트 커버리지 85%입니다. 우리 시스템은 안전합니다."
많은 개발팀이 이런 숫자에 안도합니다. 하지만 높은 커버리지 수치가 실제 품질을 보장할까요? 테스트는 통과하지만 프로덕션에서는 버그가 발생하는 경험, 한 번쯤은 있으실 겁니다. 이것이 바로 테스트 코드 부채(Test Debt)의 전형적인 증상입니다.
Martin Fowler가 기술 부채(Technical Debt)라는 개념을 대중화한 이후, 우리는 레거시 코드와 아키텍처 문제에는 민감하게 반응하게 되었습니다. (영문: https://martinfowler.com/bliki/TechnicalDebt.html / 국문: https://tech.hancom.com/2023-06-27-technical-debt) 하지만 정작 더 위험한 것은 따로 있습니다. 바로 우리가 의존하고 있는 테스트 코드 자체의 부채입니다. 테스트가 존재하지만 제대로 작동하지 않고, 유지보수되지 않으며, 때로는 잘못된 신호를 보내는 상황 말입니다.
이 글에서는 테스트 부채가 왜 기술 부채보다 위험한지, 어떻게 측정할 수 있는지, 그리고 실무에서 어떻게 해결해 나갈 수 있는지를 다룹니다. 특히 제가 제안하는 TDI(Test Debt Index), TMB(Test Maintenance Burden), TES(Test Effectiveness Score) 프레임워크를 통해 테스트 부채를 정량화하고 관리하는 방법을 소개하겠습니다.
기술 부채는 적어도 눈에 보입니다. 복잡한 코드, 중복된 로직, 낡은 의존성 - 이런 것들은 개발자가 코드를 읽으면 즉시 알아차릴 수 있습니다. IDE가 복잡도를 경고하고, 린터가 코드 스멜을 지적하며, 코드 리뷰에서 동료들이 문제를 제기합니다.
하지만 테스트 부채는 다릅니다. 테스트가 "통과"하는 한, 아무도 그 내부를 들여다보지 않습니다. 녹색 체크 마크는 모든 것이 괜찮다는 착각을 만들어냅니다. 실제로는 많은 테스트가 아무것도 검증하지 않거나 잘못된 것을 검증하고 있을 수 있습니다. 이런 "좀비 테스트"들이 계속해서 CI/CD 파이프라인에서 실행되며, 개발자들에게 잘못된 확신을 심어줍니다.
더 심각한 것은 이러한 문제가 보통 프로덕션 이슈가 발생한 후에야 발견된다는 점입니다. 테스트를 통과했는데 왜 버그가 발생했는지 분석하다 보면, 테스트가 실제로는 핵심 로직을 검증하지 않고 있었다는 사실을 뒤늦게 깨닫게 됩니다. 이런 발견의 지연은 조직 전체의 품질 인식을 왜곡시키는 심각한 문제로 이어집니다.
잘못된 테스트는 테스트가 없는 것보다 위험합니다. 적어도 테스트가 없으면 우리는 조심하기라도 합니다. 하지만 통과하는 테스트가 있으면, 우리는 안전하다고 착각하게 됩니다.
테스트를 통과했지만 실제로는 제대로 검증하지 못한 기능에서 발생하는 프로덕션 인시던트는 해결 시간도 더 오래 걸립니다. 개발자들이 "테스트를 통과했으니 문제없을 것"이라는 선입견 때문에 실제 원인을 찾는 데 더 많은 시간을 낭비하기 때문입니다.
특히 문제가 되는 것은 "행복한 경로(happy path)"만을 검증하는 테스트들입니다. 정상적인 입력과 예상되는 시나리오만 테스트하고, 실제 프로덕션에서 발생할 수 있는 엣지 케이스나 예외 상황은 전혀 다루지 않는 경우가 많습니다. 이런 테스트들은 많은 수의 테스트가 존재한다는 착각을 주지만, 실제로는 시스템의 견고성을 전혀 보장하지 못합니다.
기술 부채는 대체로 선형적으로 증가합니다. 코드가 복잡해지면 그에 비례해서 유지보수가 어려워집니다. 하지만 테스트 부채는 기하급수적으로 증가하는 경향이 있습니다. 왜냐하면 테스트는 프로덕션 코드에 의존하기 때문입니다.
프로덕션 코드가 변경될 때마다 관련된 모든 테스트를 수정해야 하는데, 제대로 관리되지 않은 테스트 코드는 이 작업을 극도로 어렵게 만듭니다. 예를 들어, 하나의 메서드 시그니처를 변경했는데 수십 개의 테스트를 수정해야 하는 상황이 발생합니다. 더 나쁜 것은, 어떤 테스트를 수정해야 하는지조차 명확하지 않은 경우입니다.
시간이 지날수록 이런 비용은 가속화됩니다. 3년 이상 된 테스트 코드는 복잡하게 얽힌 의존성, 오래된 assertion 라이브러리, 더 이상 존재하지 않는 비즈니스 로직을 검증하는 테스트들이 쌓여 있을 가능성이 높습니다. 마치 복리 이자처럼, 방치된 테스트 부채는 시간이 지날수록 감당하기 어려운 수준으로 불어납니다.
"또 랜덤하게 실패했네요. 다시 돌려볼까요?"
이 말을 들어보신 적 있으신가요? 플레이키 테스트는 같은 코드에서도 통과와 실패가 일정하지 않은 테스트를 의미합니다. 이는 테스트 부채의 가장 명확한 신호입니다.
플레이키 테스트의 원인은 다양합니다. Google Testing Blog에서 정리한 주요 원인들을 보면, 비동기 처리의 타이밍 문제, 테스트 간 공유 상태, 외부 의존성(네트워크, 파일 시스템), 비결정적 알고리즘(난수, 시간) 등이 있습니다. 이러한 문제들은 개별적으로는 작아 보이지만, 전체 테스트 스위트의 신뢰도를 크게 떨어뜨립니다. (영문: https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html / 국문: https://rachel0115.tistory.com/entry/SpringBootTest-에서-테스트-격리하기)
플레이키 테스트의 진짜 문제는 "양치기 소년 효과"입니다. 테스트가 자주 거짓 경보를 울리면, 개발자들은 실제 문제가 발생했을 때도 무시하게 됩니다. 연구에 따르면, 플레이키 테스트가 전체의 일정 비율을 넘어서면 개발자들의 테스트 신뢰도가 급격히 떨어지며, 대부분의 개발자가 첫 번째 테스트 실패를 무시하고 자동으로 재실행하는 습관을 갖게 됩니다. (영문: https://engineering.fb.com/2020/12/10/developer-tools/probabilistic-flakiness/)
예시 계산 (가정 기반) 100명의 개발자가 하루 평균 3번씩 플레이키 테스트로 인한 재실행을 한다고 가정 각 재실행에 평균 5분 소요 하루 1,500분(25시간) 낭비 연간 약 6,000시간의 생산성 손실
이에 대한 자세한 분석은 "플레이키 테스트의 경제학: 불안정성이 조직에 미치는 실제 비용 분석" 글에서 별도로 다룰 예정입니다.
Mock 객체는 테스트를 격리시키고 빠르게 실행하기 위한 유용한 도구입니다. 하지만 과도하게 사용되면 테스트를 프로덕션 환경과 완전히 동떨어지게 만듭니다.
과도한 모킹의 전형적인 증상은 다음과 같습니다.
첫째, Mock 설정 코드가 실제 테스트 로직보다 길어집니다.
둘째, 프로덕션 코드를 변경할 때마다 수많은 Mock 설정을 함께 수정해야 합니다.
셋째, Mock이 실제 시스템의 동작을 정확히 반영하지 못해 테스트는 통과하지만 실제로는 작동하지 않는 코드가 만들어집니다.
특히 외부 API나 데이터베이스를 모킹할 때 주의가 필요합니다. 실제 시스템의 응답 형식이나 에러 처리 방식이 변경되었을 때, Mock은 여전히 옛날 방식으로 동작하여 문제를 감지하지 못할 수 있습니다. 이는 "Mock의 역설: 격리와 통합 사이에서 균형 찾기" 글에서 별도로 다룰 예정입니다.
테스트 코드가 프로덕션 코드보다 크고 복잡해지는 현상은 흔히 볼 수 있습니다. 동일한 시나리오를 테스트하는 코드가 여러 개 존재하고, 각각은 미묘하게 다른 방식으로 구현되어 있습니다. "이 테스트가 뭘 검증하는지 모르겠지만, 깨지면 안 되니까 그냥 복사해서 새로 만들었다"는 접근이 이런 상황을 만듭니다.
테스트 헬퍼 함수와 유틸리티의 과도한 증식도 문제입니다. 처음에는 테스트를 쉽게 작성하기 위해 만들었지만, 시간이 지나면서 이 헬퍼들 자체가 복잡한 레거시가 됩니다. 어떤 헬퍼가 어떤 용도인지, 어떤 것이 실제로 사용되고 있는지 파악하기 어려워집니다.
더 심각한 것은 테스트 간의 숨겨진 의존성입니다. 테스트 A가 데이터베이스에 특정 데이터를 생성하고, 테스트 B가 그 데이터를 암묵적으로 사용하는 경우가 있습니다. 이런 숨겨진 의존성은 테스트를 병렬로 실행할 수 없게 만들고, 테스트 스위트의 일부만 실행할 때 예측할 수 없는 실패를 야기합니다. (국문: https://mangkyu.tistory.com/264)
테스트 부채를 개선하려면 먼저 측정할 수 있어야 합니다. 여기서는 제가 실무 경험을 바탕으로 제안하는 세 가지 측정 프레임워크를 소개합니다.
TDI는 테스트 부채를 종합적으로 평가하기 위해 제가 고안한 지표입니다. 여러 팀과의 협업 경험을 통해 다음과 같은 공식을 도출했습니다:
TDI = (플레이키 테스트 비율 × 2) + (테스트 실행 시간 증가율) + (테스트 커버리지 감소율) + (테스트 코드 복잡도 / 프로덕션 코드 복잡도)
실무 수집 방법
플레이키 테스트 비율: CI/CD 파이프라인에서 동일 커밋에 대해 5회 실행 후 실패 비율 계산
테스트 실행 시간 증가율: 분기별 평균 실행 시간을 데이터베이스에 저장하고 추세 분석
커버리지 감소율: 코드 커버리지 도구의 히스토리 데이터 활용
복잡도 비율: SonarQube 같은 정적 분석 도구로 측정
영문: https://docs.sonarsource.com/sonarqube-server/10.8/user-guide/code-metrics/metrics-definition
국문: https://sonarqubekr.atlassian.net/wiki/spaces/SON/pages/395311)
계산 예시 플레이키 테스트 10%, 테스트 실행 시간 20% 증가, 커버리지 5% 감소, 테스트 코드 복잡도가 프로덕션의 1.5배 TDI = (0.1 × 2) + 0.2 + 0.05 + 1.5 = 1.95
임계치: TDI > 1.0 즉시 조치 필요 / TDI < 0.5 건강한 상태
TMB는 테스트 유지보수에 실제로 소요되는 노력을 측정하기 위해 제안하는 지표입니다:
TMB = (테스트 수정 커밋 수 / 전체 커밋 수) × 100
더 정교한 측정을 원한다면 시간 기반 버전을 추천합니다.
Advanced TMB = (테스트 수정 시간 / 전체 개발 시간) × 100
실무 수집 방법
Git 커밋 로그 분석: 테스트 파일 변경 커밋을 자동 분류
IDE 플러그인 활용: 파일별 편집 시간을 추적
PR 라벨링: 테스트 수정 PR에 자동 태그 부여
임계치: TMB > 15% 리팩토링 시급 / TMB < 10% 건강한 상태
지금 바로 작가의 멤버십 구독자가 되어
멤버십 특별 연재 콘텐츠를 모두 만나 보세요.