TDD 도입 전략 — 팀에 TDD를 어떻게 심는가

TDD를 혼자 잘한다고 팀에 번지지 않는다. “좋은 거 같던데, 다들 써볼까요?”로 시작한 TDD 도입 시도는 대부분 2주를 못 넘긴다. 저항이 오고, 일정 압박이 오고, 슬그머니 예전 방식으로 돌아간다. TDD를 팀에 심는 것은 기술 문제가 아니라 변화 관리 문제다.

레거시 팀의 현실

기존 팀에 TDD를 도입할 때 가장 흔한 실수는 “전부 바꾸려 하는 것”이다. 기존 코드를 모두 테스트로 감싸겠다는 계획은 현실에서 동작하지 않는다.

이유가 있다. 레거시 코드는 대체로 테스트하기 어렵게 작성되어 있다. 전역 상태에 의존하고, 정적 메서드를 직접 호출하고, 의존성이 내부에 new로 생성되어 있다. 이런 코드에 테스트를 붙이려면 코드를 먼저 리팩토링해야 하는데, 리팩토링하려면 테스트가 있어야 안전하다는 딜레마에 빠진다.

Michael Feathers가 “Working Effectively with Legacy Code”에서 제시한 원칙이 여기서 유효하다. 레거시를 바꾸려 하지 말고, 새 코드부터 TDD로 쓰기 시작해라.

전략: 새 코드는 반드시 TDD로 / 기존 코드는 건드릴 때만 테스트 추가

새 기능 추가, 새 API 엔드포인트, 새 서비스 클래스 — 이것들은 레거시가 없다. 처음부터 TDD로 쓸 수 있다. 기존 코드를 수정해야 할 때는 Characterization Test(현재 동작을 기록하는 테스트)를 먼저 작성한 뒤 수정한다. 점진적이지만 이 방향이 유일하게 지속 가능하다.

쉬운 것부터 시작한다

TDD를 처음 적용할 코드를 잘못 고르면 팀이 지친다. 네트워크 호출이 얽혀 있는 서비스, DB 연결이 필요한 레포지토리부터 시작하면 Mock 설정에만 시간을 다 쓴다.

시작하기 좋은 코드:

  • 순수 도메인 로직: 할인 계산, 포인트 적립 규칙, 날짜 유효성 검사
  • 유틸리티 함수: 문자열 파싱, 숫자 포맷팅, 데이터 변환
  • 비즈니스 규칙: if 분기가 복잡한 정책 판단 로직

이런 코드는 외부 의존성이 없다. 입력을 넣으면 출력이 나온다. 테스트가 단순하고 빠르고, 성공 경험을 빠르게 줄 수 있다.

// 이런 코드부터 시작한다 — 의존성 없는 순수 함수
function calculateShippingFee(cartTotal: number, memberGrade: 'standard' | 'vip'): number {
  if (memberGrade === 'vip') return 0;
  if (cartTotal >= 50000) return 0;
  return 3000;
}
 
// 테스트가 직관적이다
describe('calculateShippingFee', () => {
  it('VIP 회원은 배송비가 무료다', () => {
    expect(calculateShippingFee(10000, 'vip')).toBe(0);
  });
 
  it('5만원 이상이면 배송비가 무료다', () => {
    expect(calculateShippingFee(50000, 'standard')).toBe(0);
  });
 
  it('5만원 미만 일반 회원은 3000원이다', () => {
    expect(calculateShippingFee(49999, 'standard')).toBe(3000);
  });
});

Kata 연습: 근육을 만드는 법

실제 프로젝트에서 TDD를 처음 연습하는 건 좋은 방법이 아니다. 마감 압박, 요구사항 변경, 레거시 코드가 뒤섞인 환경에서 새 기술을 익히기엔 너무 시끄럽다.

Kata는 무술에서 온 개념이다. 기본 동작을 반복 훈련해 몸에 익히는 것. TDD Kata는 작고 명확한 문제를 반복해서 TDD로 풀면서 Red-Green-Refactor 리듬을 몸에 각인시키는 훈련이다.

입문 Kata로 추천하는 순서:

  1. FizzBuzz: 1부터 100까지 출력, 3의 배수는 Fizz, 5의 배수는 Buzz. 가장 작은 문제로 삼각측량을 연습한다.
  2. String Calculator: "1,2,3" 같은 문자열을 파싱해 합산하는 계산기. 점진적으로 복잡해지는 요구사항을 TDD로 처리하는 연습에 최적이다.
  3. Roman Numerals: 정수를 로마 숫자로 변환. 여러 구현 전략을 시도해볼 수 있어 설계 탐색에 좋다.
  4. Leap Year: 윤년 판별. 조건 분기를 삼각측량으로 하나씩 커버하는 연습.

팀에서 이 Kata를 함께 해보는 것만으로도 TDD에 대한 공통 언어가 생긴다.

Ping-Pong TDD: 페어 프로그래밍과 TDD의 결합

Ping-Pong TDD는 TDD를 팀에서 가르치는 가장 효과적인 방법 중 하나다. 두 사람이 번갈아가며 Red와 Green 역할을 맡는다.

A가 실패하는 테스트 작성
  → B가 최소 코드로 테스트 통과
  → B가 다음 실패 테스트 작성
  → A가 최소 코드로 테스트 통과
  → A가 다음 실패 테스트 작성
  → ...

리팩토링은 통과 직후 두 사람이 합의해서 진행한다.

이 방식의 장점이 있다. 테스트를 작성하는 사람은 “이게 어떻게 동작해야 하는가”에 집중하고, 구현하는 사람은 “최소한으로 통과시키려면 어떻게 해야 하는가”에 집중한다. 역할이 분리되면서 둘 다 TDD의 다른 측면을 경험한다. 또한 짧은 사이클마다 제어권이 바뀌어 집중력이 유지된다.

처음 Ping-Pong을 해보면 “이렇게 작은 단계로 가도 되나?”라는 불안함이 생긴다. 그 불안함과 함께 가는 것이 TDD를 배우는 과정이다.

팀 저항 처리

저항은 반드시 온다. 미리 알고 있으면 대응하기 쉽다.

“TDD로 하면 너무 느려요”

사실이다. 처음엔 느리다. 운전을 처음 배울 때 아는 길도 천천히 가는 것처럼, 익숙하지 않은 도구는 속도가 나지 않는다. 대신 질문을 바꾼다. “TDD 없이 빠르게 짠 코드를 디버깅하는 데 시간을 얼마나 쓰고 있나요?” 대부분의 팀에서 디버깅, 재발견된 버그 수정, 회귀 테스트에 쓰는 시간이 TDD 작성 시간보다 훨씬 많다.

“이미 동작하는 코드에 왜 테스트를 써요?”

“지금은 동작하는 것”과 “리팩토링 후에도 동작하는 것”은 다르다. 테스트가 없는 코드는 리팩토링할 때 두렵다. 두려움이 코드를 그대로 두게 하고, 기술 부채가 쌓인다.

“테스트 코드도 유지보수해야 하잖아요”

맞다. 하지만 테스트 없이 버그 추적하는 비용, QA 사이클 비용, 배포 후 롤백 비용과 비교하면 다른 결론이 나온다. 테스트는 비용이지만, 테스트 없음은 더 큰 비용이다.

TDD 도입 성공 지표

TDD 도입의 성공을 어떻게 측정하는가. 커버리지 숫자는 대표 지표가 되기 어렵다. 커버리지 80%가 목표가 되면 팀은 의미 없는 테스트를 양산한다.

더 나은 지표들:

  • 리팩토링 빈도: 팀이 코드를 얼마나 자주, 자신 있게 바꾸는가
  • 디버깅 세션 시간: 버그를 찾아 헤매는 시간이 줄었는가
  • 회귀 버그 비율: 같은 버그가 다시 나오는 빈도
  • 배포 후 핫픽스 빈도: 배포 직후 긴급 수정 배포가 줄었는가
  • PR 리뷰 속도: 테스트가 있으면 리뷰어가 동작을 확인하는 시간이 줄어든다

점진적 도입 로드맵

무리하게 빠른 속도로 밀어붙이면 반발을 만난다. 점진적으로 성공 경험을 쌓아가는 게 지속 가능하다.

1개월차: 인식 형성

  • 팀 전체 Kata 워크숍 1-2회 (FizzBuzz, String Calculator)
  • 새로 작성하는 유틸리티, 도메인 로직에 단위 테스트 필수
  • 기존 코드는 건드리지 않음

3개월차: 습관 형성

  • 새 기능은 TDD로 작성하는 것이 기본값
  • PR 체크리스트에 “테스트 포함 여부” 추가
  • 버그 수정 시 재현 테스트 먼저 작성
  • Ping-Pong TDD 페어 세션 정기화

6개월차: 문화 정착

  • CI에서 테스트 커버리지 게이트 적용
  • 레거시 코드 수정 시 테스트 추가가 자연스러워짐
  • 팀 내에서 TDD에 익숙한 사람이 코드 리뷰로 패턴 전파

TDD 도입은 기술 교육이 아니라 습관 변화다. 습관은 강요로 생기지 않는다. 성공 경험이 쌓이면서 생긴다. 작은 것부터, 같이 해보는 것이 유일한 방법이다.