콘텐츠로 이동

테스트 주도 개발 실전 강의 교재

5장 — 솔직히 말하자면

대상: Java/Spring 백엔드 입문~중급 수강생 형식: 할 일 → RED → GREEN → REFACTOR → 함정 → 체크리스트 → 퀴즈 전제 환경: Java 17+, JUnit 5


0. 이 장을 시작하기 전에

0.1 학습 목표

  • 두 번째 통화 Franc 을 도입하고, Dollar 코드를 통째로 복붙 해서라도 가장 빠르게 초록을 만든다.
  • "복붙은 죄가 아니라 신호" — 두 클래스의 닮음이 명확해진 후에 좋은 추상화를 발견할 수 있음을 손에 익힌다.
  • 의도된 중복 (intentional duplication) 을 할 일 목록에 적어 다음 사이클(6장)에서 정리.
  • 리팩터링 의 "3의 법칙" 과 Clean Code 의 보이스카우트 규칙이 어떻게 TDD 사이클 안에서 연결되는지 본다.

0.2 큰 그림 — "패턴은 두 번째에 보인다"

[ 처음 ]                              [ 두 번째 ]                   [ 세 번째 ]
 한 가지 사례만 있음 → 추상화 미루기     비슷한 사례 등장 → 닮음 발견    같은 닮음 또 등장 → 추출
 (Dollar)                            (Dollar + Franc — 5장)        (6장 슈퍼클래스로)

비유 — "원피스 vs 정장 라인"

디자이너가 첫 옷 (원피스) 한 벌을 만들 때는 "원피스" 라는 카테고리를 의식 안 함. 두 번째 디자인 (정장) 이 나오고 나서야 "이 둘은 모두 '상의'" 라는 공통점이 보임. 처음부터 "상의" 라는 카테고리부터 만들면 잘못된 추상화 위험.

0.3 현업에서 왜 중요한가

  • 신입이 가장 자주 하는 실수 — 처음부터 인터페이스·슈퍼클래스·제네릭 도입. 사용 사례 1개로는 잘못된 추상화.
  • 시니어가 가장 자주 미루는 것 — 사용 사례 3개 모인 후의 정리. 그 사이 코드는 부패.
  • TDD 가 두 시기를 모두 짚어줌 — 5장에서 복붙, 6장에서 추출.

1. 할 일 목록 갱신

[x] $5 * 2 = $10
[x] Dollar 부작용 제거
[x] equals()
[x] amount 를 private 으로
[ ] hashCode()
[ ] $5 + 10 CHF = $10
[ ] CHF * 2 = 10 CHF              ← 이번 (Franc 추가)
[ ] Dollar 와 Franc 중복 제거       ← 6장 빚

통화 추가 = 하나, 중복 제거 = 다른 사이클. 두 모자 분리.


2. RED — Franc 첫 테스트

비유 — "두 번째 손님, 같은 메뉴 다른 음료"

첫 손님 (Dollar) 이 만족했으니, 두 번째 손님 (Franc) 도 같은 메뉴 (times) 를 다른 통화로 주문합니다.

class FrancTest {
    @Test
    void Franc_곱셈() {
        Franc five = new Franc(5);
        assertEquals(new Franc(10), five.times(2));
    }
}

컴파일 실패. Franc 가 아직 없음. 정상 — 1·2장처럼 컴파일 실패도 빨강의 한 종류.


3. GREEN — 가장 빠른 길 (복붙)

단계 1: 컴파일되게 만든다 (껍데기)

Dollar 와 거의 같은 모양 — 복붙해서 만든다.

public class Franc {
    private final int amount;
    public Franc(int amount) { this.amount = amount; }
    public Franc times(int multiplier) { return new Franc(0); }   // 일단 가짜
    @Override public boolean equals(Object o) { return false; }   // 일단 가짜
}

→ 컴파일은 되지만 테스트 실패 (가짜 반환). 빨강 확인.

단계 2: 빠르게 초록으로

public class Franc {
    private final int amount;
    public Franc(int amount) { this.amount = amount; }
    public Franc times(int multiplier) { return new Franc(amount * multiplier); }
    @Override public boolean equals(Object o) {
        if (!(o instanceof Franc f)) return false;
        return amount == f.amount;
    }
}

→ 초록. Dollar 와 글자만 다르고 구조는 똑같음.

단계 3: 복붙 인정

여기서 멈춥니다. 중복 제거는 6장 빚. 한 사이클에 한 변경만.

왜 5장에서 추출 안 하나? 추출을 하려면 두 클래스의 정확한 공통 부분을 알아야 함. 지금은 막 만든 직후 — 한 박자 쉬고 패턴을 본 후 6장에서 추출. 너무 빠른 추상화 = 잘못된 추상화.


4. 통과했지만 — 닮음의 명확화

DollarFranc 를 나란히 두면 거의 데칼코마니:

// Dollar                                 // Franc
public class Dollar {                     public class Franc {
    private final int amount;                 private final int amount;
    public Dollar(int amount) { ... }         public Franc(int amount) { ... }
    public Dollar times(int m) {              public Franc times(int m) {
        return new Dollar(amount * m);            return new Franc(amount * m);
    }                                         }
    @Override public boolean equals(...)      @Override public boolean equals(...)
    ...                                       ...
}                                         }

차이: 1. 클래스 이름 (Dollar / Franc) 2. 생성자 호출 (new Dollar / new Franc) 3. instanceof 대상

같은 점: 1. 필드 (amount) 2. 생성자 본문 3. times 본문 구조 4. equals 본문 구조

공통 부모 가 보이기 시작. 6장에서 추출.


5. 현업 예제 — Spring·도메인에서의 "의도된 중복"

사례: 결제 수단 추가

// 기존
public class CardPayment {
    public PaymentResult process(Order o, Card c) { ... }
}

// 새 요구사항: 가상계좌 결제 추가
public class VirtualAccountPayment {
    public PaymentResult process(Order o, VirtualAccount va) { ... }
}

처음에는 복붙해서 빠르게 동작 확인. 세 번째 결제 수단 (간편결제) 추가 시점에 공통 인터페이스 PaymentProcessor 추출이 자연스러움.

리팩터링 의 "3의 법칙"

첫 번째: 그냥 한다. 두 번째: 중복을 인지하지만 그대로 둔다. 세 번째: 추출한다.

TDD 5장은 정확히 두 번째 단계 — 인지하되 미룸.


6. 함정 / 주의

  • 추상화를 너무 빨리 도입: 사용 사례 1개로 인터페이스 만들면 잘못된 인터페이스. 결과적으로 더 큰 리팩토링 비용.
  • 복붙을 영구로 방치: 6장으로 넘어가지 않고 그대로 두면 코드 부패. 할 일 목록에 적어두는 게 핵심.
  • "의도된 중복" 을 핑계로 모든 복붙 정당화: 6장 단계로 넘어가는 의지가 있어야 정당함.
  • 5장만 보고 멈추면 사실 나쁜 코드. 6·7장이 완결.

7. 체크리스트 (5장 완료 기준)

  • Franc 클래스를 만들고 곱셈 테스트가 초록인가
  • DollarFranc 의 공통 구조를 머리로 인지했는가
  • 중복 제거 항목이 할 일 목록에 적혔는가 (다음 장 빚)
  • 5장 PR 이 통화 추가 의도만 담고 있는가 (중복 제거 섞이지 않음)

8. 퀴즈

  1. 5장에서 FrancDollar 코드를 복붙해서 만든 이유는?
  2. "처음부터 슈퍼클래스를 만들면 안 되는" 이유?
  3. 리팩터링 의 "3의 법칙" 과 5장이 연결되는 지점은?
  4. 의도된 중복을 6장에서 정리하지 않고 그대로 두면 어떻게 되나?
  5. 도메인 (결제 수단·할인 정책 등) 에 이 원칙을 어떻게 적용?

정답·해설

  1. 빠른 초록. 새 통화 (Franc) 동작 자체 검증이 이번 사이클 목표. 추상화는 다음 사이클 목표 (두 모자 분리). 복붙은 가장 빠른 길이고, 같은 PR 에 두 일을 섞으면 리뷰·롤백 어려움.
  2. 잘못된 추상화 위험. 사용 사례 1개 (Dollar) 만으로는 공통 부분이 무엇인지·어떻게 다를지 모름. 두 번째 사례 (Franc) 가 나타나야 진짜 공통점·차이가 보임. 빨리 추상화한 인터페이스는 두 번째 사례에 안 맞아 다시 리팩토링하는 비용 폭증.
  3. 두 번째 단계. 첫 번째는 그냥, 두 번째는 인지하되 그대로, 세 번째에 추출. 5장이 정확히 두 번째 — 인지 + 적어두기 + 미루기. 6장에서 세 번째 단계의 추출.
  4. 코드 부패의 시작. 의도된 중복을 영구 방치하면 두 클래스가 서로 다른 방향으로 진화 → 미래 추출이 더 어려워짐. 할 일 목록 + 다음 사이클의 약속이 있어야 정당.
  5. 결제 수단 첫 추가 시 복붙 → 세 번째 결제 수단 추가 시 PaymentProcessor 인터페이스 추출. 첫 인터페이스가 두 사례를 모두 안고, 세 번째 사례에서 확신을 얻은 후 추상화. 할인 정책·검색 전략 모두 같은 패턴.

다음 장 예고 — 6장: 돌아온 '모두를 위한 평등'

5장에서 만든 Franc.equalsDollar.equals 와 거의 똑같습니다. 슈퍼클래스 Money 를 추출 해서 equals 를 부모로 끌어올립니다. 동시에 새로운 빨강이 나타납니다 — Franc(5).equals(Dollar(5)) 가 어떻게 동작해야 할까? 7장의 "사과와 오렌지" 로 이어집니다.