Red-Green-Refactor

개요

Red-Green-Refactor는 TDD의 단위 작업 루프다. 세 단계가 각각 다른 목적을 가지며, 한 번에 한 가지만 한다는 원칙이 전부다. Red 단계에서는 설계를 생각하고, Green 단계에서는 통과만 생각하고, Refactor 단계에서는 정리만 생각한다.

단계질문금지 사항
Red이 코드를 어떻게 쓰고 싶은가구현 코드를 건드리는 것
Green어떻게 하면 가장 빨리 통과하는가일반화, 미리 하는 설계
Refactor무엇이 중복인가동작 변경, 기능 추가

Robert C. Martin은 같은 루프를 세 가지 금지 규칙으로 다시 썼다. 사이클을 초 단위까지 조인 형태다.

  1. 실패하는 단위 테스트를 통과시키기 위해서가 아니면 프로덕션 코드를 작성할 수 없다.
  2. 실패를 만들기에 충분한 정도를 넘어서 단위 테스트를 더 작성할 수 없다. 컴파일 실패도 실패로 친다.
  3. 실패 중인 그 테스트 하나를 통과시키기에 충분한 정도를 넘어서 프로덕션 코드를 더 작성할 수 없다.

규칙 2 때문에 테스트를 쓰다가 컴파일이 깨지는 순간 즉시 구현으로 넘어가야 하고, 규칙 3 때문에 통과하는 순간 즉시 테스트로 돌아와야 한다. 이 규칙을 문자 그대로 지키면 한 사이클이 수십 초 단위가 된다. 실무에서 매번 이 보폭을 쓰지는 않지만, 막혔을 때 어디까지 보폭을 줄일 수 있는지의 하한선으로 기억해 둘 만하다.


Red - 실패하는 테스트를 먼저 쓴다

반드시 실패를 확인한다

테스트를 쓴 뒤 실행해서 실패하는 것을 눈으로 본다. 이 단계를 건너뛰면 테스트가 실제로는 아무것도 검증하지 않아도 알 수 없다. assert가 빠졌거나, 애초에 통과하는 조건을 걸었거나, 테스트 러너가 그 메서드를 집어가지 않는 경우가 실제로 자주 발생한다.

실패 메시지가 의도한 이유를 가리키는지도 함께 본다. 기대값 불일치로 실패해야 할 테스트가 NullPointerException으로 실패한다면 테스트 준비 코드가 잘못된 것이다.

컴파일 에러도 Red다

아직 존재하지 않는 클래스나 메서드를 호출하는 테스트는 컴파일되지 않는다. 이것도 정상적인 Red 상태다. 컴파일을 통과시키려고 껍데기를 만드는 것까지가 이 단계의 작업이다.

@Test
void 비어있는_장바구니의_합계는_0원이다() {
    Cart cart = new Cart();          // Cart 클래스가 아직 없다

    assertThat(cart.total()).isZero();
}

테스트 하나에 검증 하나

한 사이클에서 실패하는 테스트는 하나여야 한다. 여러 개가 동시에 Red이면 어느 코드가 어느 테스트를 통과시켰는지 대응이 흐려지고, 되돌아갈 지점도 사라진다.


Green - 가장 빠른 방법으로 통과시킨다

이 단계의 목표는 좋은 코드가 아니라 초록 막대다. 지저분해도 상관없다. 정리는 다음 단계에서 하고, 그때 테스트가 지켜 준다. Beck은 Green을 만드는 방법을 세 가지로 분류한다.

1. Fake It - 상수를 반환한다

기대값을 그대로 반환한다. 우스꽝스러워 보이지만 테스트와 구현이 연결됐다는 사실을 즉시 확인시켜 주고, 이후 리팩터링으로 상수를 변수로 바꿔 나가는 출발점이 된다.

public int total() {
    return 0;   // 일단 통과
}

2. Triangulation - 두 번째 예제로 일반화를 끌어낸다

Fake It 상태에서 다른 입력을 요구하는 테스트를 하나 더 추가한다. 상수로는 두 테스트를 동시에 통과시킬 수 없으므로 일반화가 강제된다. 어떻게 일반화할지 감이 오지 않을 때 쓰는 방법이다.

@Test
void 상품_한_개를_담으면_그_가격이_합계다() {
    Cart cart = new Cart();
    cart.add(new Item("커피", 4_500));

    assertThat(cart.total()).isEqualTo(4_500);
}

이제 return 0은 깨진다. 두 테스트를 모두 통과시키려면 실제로 담긴 상품을 더해야 한다.

public int total() {
    return items.stream()
            .mapToInt(Item::price)
            .sum();
}

3. Obvious Implementation - 그냥 구현한다

구현이 명백하면 바로 작성한다. 실무에서 가장 많이 쓰이는 방법이다. 단 이 방법을 쓰다가 예상과 다르게 Red가 나오면 즉시 더 작은 스텝으로 내려간다. 세 전략의 관계는 보폭 조절이며, 막힐수록 보폭을 줄인다는 것이 규칙이다.

flowchart TD S{"구현이 명백한가?"} S -->|예| O["Obvious Implementation"] S -->|아니오| T{"일반화 방향이 보이는가?"} T -->|예| F["Fake It 후 리팩터링"] T -->|아니오| TR["Triangulation
예제를 하나 더 추가"] O -->|예상 밖 실패| F

Refactor - 중복을 제거한다

가장 자주 생략되고, 생략했을 때 손해가 가장 큰 단계다. 이 단계를 빼면 테스트만 쌓이고 설계는 개선되지 않으므로 TDD를 하는 이유의 절반이 사라진다.

여기서 말하는 중복은 복사된 코드 조각만이 아니다. Beck은 테스트 코드와 구현 코드 사이의 중복을 특히 강조한다. assertThat(...).isEqualTo(4_500)의 4,500과 구현 안의 상수 4,500이 같이 존재하는 상태가 전형적인 예이고, 이 중복을 제거하는 과정이 곧 일반화다.

정리 대상은 구현 코드에 국한되지 않는다. 테스트 코드도 유지보수 대상이므로 준비 코드의 중복, 의미가 드러나지 않는 테스트 이름, 매직 넘버를 함께 다룬다.

원칙은 하나다. 리팩터링 중에는 동작을 바꾸지 않는다. 기능을 추가하고 싶어지면 그 항목은 테스트 목록에 적어 두고 다음 사이클로 미룬다.


테스트 목록

Beck은 사이클을 돌리기 전에 테스트 목록(test list) 부터 적으라고 권한다. 구현해야 할 동작을 테스트 이름 수준으로 나열한 메모다.

□ 빈 장바구니의 합계는 0원
□ 상품 한 개를 담으면 그 가격이 합계
□ 같은 상품을 두 번 담으면 수량이 2가 된다
□ 5만원 이상이면 10% 할인
□ 재고보다 많은 수량은 담을 수 없다

목록은 두 가지 역할을 한다. 첫째, 다음에 무엇을 할지 매번 다시 판단할 필요가 없어진다. 둘째, 사이클 도중에 떠오른 아이디어를 지금 처리하지 않고 적어 두는 곳이 생긴다. Refactor 단계에서 기능 추가로 새는 것을 막아 주는 장치가 이 목록이다.

목록은 고정된 것이 아니라 작업 중 계속 추가·삭제된다. 목록이 비면 그 기능은 끝난 것으로 본다.


흔한 실패 패턴

패턴증상교정
Refactor 생략테스트는 많은데 구현은 계속 지저분하다Green 직후를 완료로 보지 않는다. 중복이 없는지 매번 확인
보폭이 너무 크다한 테스트를 통과시키는 데 30분 이상 걸린다테스트를 더 작은 동작으로 쪼갠다
Red를 건너뜀테스트가 실제로는 아무것도 검증하지 않는다실패를 눈으로 확인한 뒤에만 구현을 시작한다
구현 이후 테스트 작성테스트가 구현 구조를 그대로 따라 쓴다순서를 되돌린다. 설계 피드백은 순서에서 나온다
테스트가 구현 세부에 결합리팩터링만 해도 테스트가 깨진다내부 호출이 아니라 관측 가능한 결과를 검증한다
한 테스트에 여러 검증실패해도 원인을 특정하기 어렵다하나의 동작만 검증하도록 분리

마지막 항목은 Classicist vs Mockist에서 다루는 선택과 직결된다. 목으로 호출 순서와 횟수를 검증할수록 테스트는 구현에 결합되고, 리팩터링 안전망으로서의 가치는 떨어진다.


관련 문서


참고 자료

  • Kent Beck, Test-Driven Development: By Example (2002) - Part I의 Money 예제가 세 전략의 원전
  • Martin Fowler, Refactoring: Improving the Design of Existing Code (2nd ed., 2018)
  • Robert C. Martin, The Three Rules of TDD