콘텐츠로 이동

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

6장 — 돌아온 '모두를 위한 평등'

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


0. 이 장을 시작하기 전에

0.1 학습 목표

  • 슈퍼클래스 Money 를 추출DollarFranc 의 중복을 제거한다.
  • equals 를 슈퍼클래스로 끌어올리고, 숨겨진 문제 (통화 다른데 같다고 판정) 를 새 빨강으로 드러낸다.
  • 리팩터링12.8 슈퍼클래스 추출 절차를 손에 익힌다.
  • "하나의 추출이 새 문제를 드러낸다" — TDD 가 진화의 다음 단계를 자연스럽게 안내함을 본다.

0.2 큰 그림 — "추출 + 새 빨강"

[ 5장 끝 ]                    [ 6장: 추출 ]                     [ 6장 끝: 새 빨강 ]
 Dollar / Franc 평행 코드      → Money 슈퍼클래스 추출           → equals 가 Money 비교
 (equals 두 곳에 중복)         (equals 부모로 끌어올림)            → Franc(5) == Dollar(5) ❌
                                                                  (7장에서 해결)

비유 — "두 가게의 공통 주방 만들기"

두 카페가 (Dollar·Franc) 각자 같은 음료 (equals) 를 만들고 있었음. 공동 주방 (Money) 으로 합치면 작업 효율 ↑. 그런데 합친 주방에서 두 카페의 손님이 섞이는 새 문제 발생 (통화 무시 비교) — 7장에서 해결.

0.3 현업에서 왜 중요한가

  • 슈퍼클래스 추출은 리팩터링 카탈로그에서 가장 자주 쓰는 기법 중 하나.
  • 추출 후 새로 드러난 문제 (LSP·equals 대칭성) 는 OO 설계의 단골 함정.
  • TDD 사이클이 "한 단계 → 새 문제 → 다음 단계" 로 안내하는 살아있는 사례.

1. 할 일 목록 갱신

[x] $5 * 2 = $10
[x] Dollar 부작용 제거
[x] equals()
[x] amount 를 private 으로
[x] CHF * 2 = 10 CHF
[ ] Dollar 와 Franc 중복 제거       ← 이번 (슈퍼클래스 추출)
[ ] 통화가 다르면 equals false      ← 6장 끝의 새 빨강 → 7장
[ ] hashCode()
[ ] $5 + 10 CHF = $10

2. RED — 추출 전제 테스트

추출 전에, 추출 후 동작 보장 을 위한 테스트를 명시적으로 적어둡니다.

@Test
void Dollar_equals() {
    assertEquals(new Dollar(5), new Dollar(5));
    assertNotEquals(new Dollar(5), new Dollar(6));
}

@Test
void Franc_equals() {
    assertEquals(new Franc(5), new Franc(5));
    assertNotEquals(new Franc(5), new Franc(6));
}

→ 이미 통과. 안전망 확인.


3. GREEN/REFACTOR — 슈퍼클래스 추출

단계 1: 빈 Money 슈퍼클래스 도입

public class Money {
    // 일단 비워둠
}

public class Dollar extends Money { ... }   // 기존 그대로
public class Franc extends Money { ... }    // 기존 그대로

→ 테스트 통과 (변경 없음). 컴파일도 OK.

단계 2: amount 필드 부모로

public class Money {
    protected final int amount;
    protected Money(int amount) { this.amount = amount; }
}

public class Dollar extends Money {
    public Dollar(int amount) { super(amount); }
    public Dollar times(int multiplier) { return new Dollar(amount * multiplier); }
    @Override public boolean equals(Object o) {
        if (!(o instanceof Dollar d)) return false;
        return amount == d.amount;
    }
}

public class Franc extends Money {
    public Franc(int amount) { super(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;
    }
}

→ 테스트 그대로 통과. 작은 한 걸음.

단계 3: equals 부모로 — 그러나 instanceof 를 Money 로

여기가 핵심 단계. equals 두 개의 본문이 사실상 동일 (타입 체크 빼고). 부모로 끌어올리면:

public class Money {
    protected final int amount;
    protected Money(int amount) { this.amount = amount; }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Money m)) return false;
        return amount == m.amount;   // ← 통화 무시
    }
}

public class Dollar extends Money {
    public Dollar(int amount) { super(amount); }
    public Dollar times(int multiplier) { return new Dollar(amount * multiplier); }
    // equals 제거
}

public class Franc extends Money {
    public Franc(int amount) { super(amount); }
    public Franc times(int multiplier) { return new Franc(amount * multiplier); }
    // equals 제거
}

→ 테스트 모두 통과. 중복 제거 완료.

단계 4: 진짜 빨강 발견

@Test
void Franc_와_Dollar_는_같지_않다() {
    assertNotEquals(new Franc(5), new Dollar(5));   // ❌ 빨강!
}

현재 equalsMoney 타입만 보고 amount 만 비교 → Franc(5) == Dollar(5) true. 도메인적으로 틀림 (5달러 ≠ 5프랑).

진짜 문제 발견. 추출이 가져온 새 통찰. 6장은 여기서 일단 멈추고, 빨강을 할 일 목록에 적습니다. 7장에서 해결.


4. 통과했지만 — 다음 장의 빚

추출은 끝났고, 새 빨강이 등장. 6장의 성과:

equals 가 Dollar·Franc 두 곳에 중복 Money.equals 한 곳
새 통화 추가 시 equals 도 또 복붙 새 통화 = extends Money
통화 무시 문제 잠재 통화 무시 문제 명시적 빨강

TDD 의 안내: 추출이 끝나면 다음에 풀 문제가 빨강으로 모습을 드러냄. 한 단계 → 새 문제 사이클.


5. 현업 예제 — Spring 에서의 슈퍼클래스 추출

사례: 결제 처리기 공통화

// Before — 중복
public class CardPaymentProcessor {
    public PaymentResult process(Order o, PaymentInfo i) {
        log("결제 시작: " + o.id());
        // 카드 특수 로직
        log("결제 종료");
    }
}

public class VirtualAccountPaymentProcessor {
    public PaymentResult process(Order o, PaymentInfo i) {
        log("결제 시작: " + o.id());   // 중복
        // 가상계좌 특수 로직
        log("결제 종료");              // 중복
    }
}

// After — 슈퍼클래스 추출
public abstract class PaymentProcessor {
    public final PaymentResult process(Order o, PaymentInfo i) {
        log("결제 시작: " + o.id());
        PaymentResult result = doProcess(o, i);
        log("결제 종료");
        return result;
    }
    protected abstract PaymentResult doProcess(Order o, PaymentInfo i);
}

→ Template Method 패턴. 리팩터링 12.8 슈퍼클래스 추출 + concept-design-patterns Template Method.


6. 함정 / 주의

  • 추출 시 한 단계씩. 빈 슈퍼클래스 → 필드 올리기 → 메서드 올리기. 한 번에 다 옮기면 실패 시 원인 추적 어려움.
  • equals 슈퍼클래스 추출 = LSP 함정 가능성. 자식 타입 차이가 의미 있다면 (Dollar vs Franc) 부모 equals 가 그 차이를 무시하면 위험.
  • 상속을 가볍게. Effective Java Item 18 — 상속보다 컴포지션. 5·6·7장은 상속을 다루지만 11장에서 자식 클래스 자체를 제거하는 방향으로 진화.
  • 자동 IDE 리팩토링 도구 (IntelliJ Pull Members Up) 가 안전. 수동 추출은 사고 위험.

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

  • 추출 전 테스트가 모두 통과하는가 (안전망)
  • 빈 슈퍼클래스 → 필드 → 메서드 순으로 단계별 추출했는가
  • 매 단계마다 테스트가 초록인가
  • 추출 후 새로 드러난 빨강 (통화 비교) 을 할 일 목록에 적었는가
  • 슈퍼클래스가 도메인적으로 의미 있는 이름인가 (Money)

8. 퀴즈

  1. 슈퍼클래스 추출의 절차를 한 줄로?
  2. 6장에서 equals 를 부모로 끌어올린 직후 새 빨강이 등장한 이유?
  3. 한 단계씩 추출하는 게 안전한 이유?
  4. 슈퍼클래스 추출이 entity-refactoring 의 어떤 기법?
  5. Template Method 패턴이 슈퍼클래스 추출과 어떻게 연결되나?

정답·해설

  1. 빈 슈퍼클래스 → 필드 올리기 → 메서드 올리기 — 각 단계마다 테스트 초록 유지. 한 번에 다 옮기지 않음.
  2. equalsMoney 타입까지만 보고 amount 비교 — 자식 타입 (Dollar/Franc) 차이를 무시. 도메인적으로 5달러 ≠ 5프랑인데 같다고 판정. 7장에서 통화 필드 또는 클래스 비교 추가로 해결.
  3. 실패 시 원인 추적. 한 단계 = 한 변경 = 실패 시 원인 명확. 여러 단계 동시 = 실패 시 어느 단계 책임인지 모름. TDD 의 핵심 리듬.
  4. 12.8 슈퍼클래스 추출 (Extract Superclass). 형제 클래스의 공통 부분을 새 부모로. 12.1 메서드 올리기·12.2 필드 올리기와 짝.
  5. 슈퍼클래스가 알고리즘 골격 을 정의 (process 메서드의 log 시작/종료), 자식이 단계 구현 (doProcess). 슈퍼클래스 추출의 정형화된 형태. 6장의 Money.equals 도 유사 — 공통 비교 알고리즘을 부모가, 자식이 차이만.

다음 장 예고 — 7장: 사과와 오렌지

6장 끝의 빨강 (Franc(5) == Dollar(5)) 을 해결합니다. 통화가 다르면 다른 객체equals 에 클래스 비교를 추가. getClass() vs instanceof 의 트레이드오프와 LSP 문제까지 다룹니다.