TDD로 요구사항을 테스트 가능한 코드로 만드는 법
TDD의 Red-Green-Refactor 흐름과 ATDD 관계, 테스트 피라미드, 계약 테스트, Python pytest 예제로 품질을 내재화하는 방법을 정리한다.
2026-08-15 · 최초 발행 2025-12-14
요구를 테스트로 고정하는 개발 방식
TDD(Test-Driven Development)는 구현보다 테스트 케이스를 앞에 두는 개발 방법이다. 켄트 벡(Kent Beck)이 제안한 익스트림 프로그래밍(Extreme Programming)의 핵심 프랙티스로 자리한 이 방식은, 고객 요구를 단위 테스트로 표현하고 테스트가 실패하는 상태에서 시작해 통과할 만큼만 구현한 뒤 구조를 다듬는다. 통합 테스트 단계에 결함이 몰리거나 일정이 밀리는 문제를 개발 초기에 다루기 위한 방식이기도 하다.
기능을 먼저 만든 뒤 테스트를 덧붙이는 Code-First 방식과 달리, TDD는 테스트를 기준으로 기능을 조금씩 완성하는 상향식(Bottom-up) 접근이다. 구현 전에 어떤 기능을 만들어야 하는지, 객체 사이의 인터페이스와 책임은 무엇인지 검토하게 하므로 요구사항을 만족하는지 지속적으로 확인할 수 있다.
흐름은 Red-Green-Refactor로 정리할 수 있다.
- Red Code는 실행 가능한 테스트가 실패하는 상태다. 기대하는 동작을 먼저 확인할 수 있어야 한다.
- Green Code는 테스트를 통과시키는 최소 구현 상태다. 기능 완결 여부를 테스트로 판단한다.
- Clean Code는 Bad Smell을 제거하고 중복과 복잡도를 줄이는 단계다. 동작은 유지한 채 구조를 개선한다.
이 과정에서 테스트는 단순 검증 수단에 머물지 않는다. 요구사항 정의서, 개발자 간 커뮤니케이션 도구, 재사용 가능한 단위 테스트, 고객 검수 근거라는 역할을 함께 맡는다. 테스트 하나가 문서·스펙·검증 산출물로 쓰이는 셈이다.
빠른 피드백 루프가 만드는 개발 경계
테스트를 먼저 쓰면 구현 범위가 예제로 제한된다. 필요한 동작을 통과시키는 데 집중하므로 과설계와 불필요한 코드가 줄고, 테스트가 수용 기준으로 기능한다. 개발·QA·비즈니스가 같은 테스트를 읽으며 요구의 모호성을 줄일 수도 있다.
클래스와 메서드의 역할을 먼저 분명히 하는 과정은 단일 책임 원칙(SRP), 의존성 주입(DI) 같은 객체지향 설계 원칙을 적용하게 한다. 모듈 간 결합도는 낮추고 응집도는 높이는 방향으로 이어질 수 있다.
TDD는 모듈과 함수 수준의 내부 품질에 초점을 둔다. ATDD(Acceptance TDD)는 시스템의 행위와 상위 수준 품질을 검증하는 관점으로, TDD를 포함하는 외부 품질 관점에 가깝다.
보통 사용자 스토리에서 출발해 테스트 케이스를 쓰고, 최소 구현과 테스트 실행, 리팩터링을 반복한다. 반복 단위를 작게 유지하고 자주 통합할수록 회귀 위험을 낮출 수 있다.
입력은 사용자 스토리, Given-When-Then 같은 수용 기준, 예제 중심의 테스트 데이터다. 그 결과 자동화 테스트 스위트, 동작하는 코드, 회귀 방지 안전망, 실행 가능한 스펙이 남는다.
변경 유형별로 테스트를 먼저 두는 방법
신규 기능에서는 스토리에 맞춘 테스트를 먼저 작성해 구현 범위를 분명히 할 수 있다. API 계약 역시 테스트로 고정할 수 있다.
버그를 고칠 때는 결함을 재현하는 테스트부터 만든다. 수정 이후에도 그 테스트가 남으므로 재발 방지를 위한 회귀 테스트가 된다.
레거시 코드를 다룰 때는 먼저 보호용 캐릭터라이제이션 테스트를 만들고, 구조를 바꾼 뒤 기존 행위가 유지되는지 확인한다. 서비스 간 데이터와 계약이 중요하면 CDC(Consumer-Driven Contract) 테스트로 호환성을 검증해 배포 리스크를 낮출 수 있다.
도입 효과와 감수해야 할 비용
조직과 도메인의 성숙도에 따라 결과는 달라질 수 있지만, 결함 밀도는 3060% 감소하고 변경 실패율은 2040% 감소할 수 있다. 테스트 커버리지는 7090%를 유지하고, 리워크 비용은 1530% 절감하는 효과를 기대할 수 있다.
릴리스 리드타임은 초기에 515% 증가할 수 있다. 안정화 구간에 들어가면 1020% 단축될 수 있다.
정량 지표 외에도 요구 명세가 실행 가능한 문서로 남고, 커뮤니케이션 비용을 줄이는 효과가 있다. 리팩터링이 일상 흐름에 들어오면서 장기 유지보수성과 코드 일관성도 개선된다. 축적된 테스트는 새 기능을 추가하거나 기존 코드를 변경한 뒤 기존 동작이 깨지지 않았는지 빠르게 확인하는 회귀 테스트 자산이 된다.
요구사항 변경이 잦다면 예제 매핑과 수용 기준을 작은 배치로 관리하고, 테스트를 스펙과 함께 동기화해야 한다. 테스트 명세에는 버전 태그와 변경 이력을 남긴다.
테스트 독립성이 어렵다면 고정(Deterministic) 픽스처와 DI, 테스트 더블을 사용하고 공유 상태를 피한다. Given-When-Then 패턴은 의존성을 줄이는 데 도움이 된다. 반복 회귀 테스트 비용은 단위 테스트 비중을 높이고 E2E 비중을 낮추는 테스트 피라미드, CI 병렬화·캐싱, 플래키 테스트 격리·치유, 변경 영향도 기반 선택적 실행으로 다룬다.
화면 UI 요소가 많거나 복잡한 외부 시스템 연동이 중심인 프로젝트에서는 테스트 작성과 유지보수 비용이 실제 개발 비용보다 커질 수 있다. 모든 기능에 TDD를 강제하기보다 비즈니스 로직이 복잡한 핵심 도메인부터 적용 범위를 넓히고, 커버리지 수치 자체보다 테스트가 제공하는 신뢰와 설계 개선의 가치를 기준으로 판단해야 한다.
테스트 스위트를 운영할 때의 균형
테스트 피라미드는 단위:계약:통합:E2E를 ≈ 70:15:10:5로 두어 실행 속도와 신뢰성 사이의 균형을 잡는 방식이다. 커버리지는 라인 기준 80% 내외를 가이드로 삼되, 중요한 분기와 도메인 규칙을 우선한다. 100% 커버리지만을 목표로 삼으면 비용이 커질 수 있다.
단위 테스트(Unit Test)는 개별 함수나 클래스처럼 작은 단위의 동작을 독립적으로 확인한다. 데이터베이스와 네트워크 같은 외부 의존성은 Mock 객체로 격리하며, 실행 속도가 빨라 TDD 주기에서 가장 자주 수행된다. 통합 테스트(Integration Test)는 여러 모듈을 함께 연결했을 때 상호작용이 정상인지 검증하고, 시스템 테스트(System Test)는 운영 환경과 유사한 조건에서 사용자 관점의 시나리오와 E2E 테스트로 전체 요구사항을 확인한다.
Mocking은 격리와 실행 속도를 얻는 대신 실제 연동 신뢰성에는 한계가 있다. 통합 테스트는 반대의 특성을 갖는다. 계약 테스트는 그 사이를 조정하는 수단이 된다. CI 파이프라인에서 축적된 테스트를 회귀 테스트로 활용하면 시스템 안정성을 유지하는 기반이 된다.
테스트 데이터는 시드와 스냅샷, 격리된 DB(예: 테스트컨테이너)로 관리한다. 시간과 난수에 대한 의존성을 제거해야 재현성을 확보할 수 있다.
| 지표 | TDD 미도입 | TDD 도입 |
|---|---|---|
| 일관성 | 구현자별 편차 큼, 문서-코드 불일치 빈번 | 테스트가 실행 가능한 스펙 역할, 편차 축소 |
| 안정성 | 통합 단계 결함 집중, 회귀 잦음 | 단위 단계 결함 조기 발견, 회귀 안전망 확보 |
| 운영 편의 | 수작업 검증 의존, 변경 두려움 | 자동화 회귀로 안전한 배포, 변경 용이 |
| 성능 | 초기 기능 급조, 최적화 시점 불확실 | 리팩터링 내재화로 성능 개선 여지 확보 |
| 확장성 | 결합도 높아 변경 영향 큼 | 작은 단위·느슨한 결합으로 확장 용이 |
pytest로 보는 Red-Green-Refactor
Python 3.11+, pytest 8.x 환경에서 로컬 실행한다고 가정한다. 사용자 스토리는 다음과 같다. 사용자는 '1,2,3' 문자열을 입력하면 합계 6을 얻을 수 있어야 한다.
먼저 실패하는 테스트를 작성한다.
# tests/test_string_calc.py
# pytest -q 로 실행
from string_calc import add
def test_add_with_commas():
assert add("1,2,3") == 6
def test_add_empty_returns_zero():
assert add("") == 0
테스트를 통과시키는 최소 구현은 다음과 같다.
# string_calc.py
def add(s: str) -> int:
if not s:
return 0
return sum(int(x) for x in s.split(","))
그다음 동작을 유지하면서 입력 처리 구조를 정리한다.
# string_calc.py
from typing import Iterable
def _to_ints(tokens: Iterable[str]) -> Iterable[int]:
return (int(t.strip()) for t in tokens if t.strip() != "")
def add(s: str) -> int:
tokens = s.split(",") if s else []
return sum(_to_ints(tokens))
TDD의 핵심은 테스트를 많이 작성하는 데 있지 않다. 요구를 검증 가능한 형태로 만들고, 작은 변경을 안전하게 반복할 수 있는 개발 흐름을 만드는 데 있다.
Sources
- Beck, K. (2002). Test-Driven Development: By Example. Addison-Wesley Professional.
- Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.
- Wikipedia - Test-driven development
- Martin Fowler - Test Driven Development