콘텐츠로 이동

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

16장 — 드디어, 추상화

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


0. 이 장을 시작하기 전에

0.1 학습 목표

  • times·plus 모두 Expression 인터페이스로 일반화.
  • Money·Sum 모두 같은 Expression 메서드 셋 제공 — 컴포지트 완성.
  • "같은 일은 같은 인터페이스" 의 OO 깊이.
  • Plus 표현 추가 가능성 — 새 표현 = 새 클래스 1개 (OCP).

0.2 큰 그림 — 컴포지트 완성

Expression
├── plus(Expression) : Expression
├── times(int) : Expression
└── reduce(Bank, String) : Money

Money implements Expression       ← 잎 (leaf)
Sum implements Expression         ← 가지 (composite)
[ Times implements Expression ]    ← 가능
[ Difference implements Expression ] ← 가능

비유 — "수학식의 트리"

(5 + 10*2) - 3 = 빼기 (덧셈 (5, 곱셈 (10, 2)), 3). 각 노드 (Sum·Times·Difference) 가 Expression. 잎은 숫자 (Money). 트리가 깊어져도 같은 인터페이스로 처리.

0.3 현업에서 왜 중요한가

  • DSL (Domain-Specific Language) 의 기본 골조.
  • Spring Data Specification·QueryDSL·jOOQ 모두 같은 구조.
  • 새 연산 추가 = 새 Expression 구현 1개 (OCP·CQRS).

1. 할 일 목록 갱신

[x] $5 + 10 CHF = $10
[ ] times 도 Expression 으로            ← 이번
[ ] hashCode() (Money)

2. RED — Expression 의 times·plus 메서드 통일

@Test
void Sum도_times를_지원() {
    Bank bank = new Bank();
    bank.addRate("CHF", "USD", 2);
    Expression sum = Money.dollar(5).plus(Money.dollar(5));
    Expression doubled = sum.times(2);   // ← Sum 에 times 없음
    assertEquals(Money.dollar(20), bank.reduce(doubled, "USD"));
}

→ 컴파일 실패. Sum 에 times 없음.


3. GREEN — Expression 인터페이스 확장

단계 1: 인터페이스에 times·plus 선언

public interface Expression {
    Expression plus(Expression addend);
    Expression times(int multiplier);
    Money reduce(Bank bank, String to);
}

단계 2: Money 가 Expression 메서드 모두 구현

public class Money implements Expression {
    // ...
    @Override
    public Expression plus(Expression addend) {
        return new Sum(this, addend);
    }

    @Override
    public Expression times(int multiplier) {
        return new Money(amount * multiplier, currency);
    }

    @Override
    public Money reduce(Bank bank, String to) { ... }
}

단계 3: Sum 도 마찬가지

public class Sum implements Expression {
    private final Expression augend;
    private final Expression addend;

    public Sum(Expression augend, Expression addend) {
        this.augend = augend;
        this.addend = addend;
    }

    @Override
    public Expression plus(Expression addend) {
        return new Sum(this, addend);
    }

    @Override
    public Expression times(int multiplier) {
        return new Sum(augend.times(multiplier), addend.times(multiplier));
    }

    @Override
    public Money reduce(Bank bank, String to) {
        int amount = augend.reduce(bank, to).amount() + addend.reduce(bank, to).amount();
        return new Money(amount, to);
    }
}

→ 모든 테스트 초록.


4. REFACTOR — 컴포지트 완성 + Plus 표현 추가 가능

새 표현 추가 시나리오

public class Times implements Expression {
    private final Expression source;
    private final int multiplier;

    public Expression plus(Expression addend) { return new Sum(this, addend); }
    public Expression times(int n) { return new Times(this, multiplier * n); }
    public Money reduce(Bank bank, String to) {
        return new Money(source.reduce(bank, to).amount() * multiplier, to);
    }
}

→ Bank·Money·Sum 코드 변경 0. 새 표현 = 새 클래스 1개. OCP 충족.


5. 통과한 시점에서 — 1부 종착

15·16장 끝의 도메인 모델:

Expression (interface)
├── plus
├── times
└── reduce
   |
   ├── Money     (잎)
   ├── Sum       (가지)
   └── Times?    (필요시 추가)

5개 클래스 + 1개 인터페이스로 다중 통화 + 환산 + 표현 트리 도메인 완성.

17장 회고에서 1부 전체 통찰 정리.


6. 현업 예제 — 표현 트리의 다른 사례

Spring Specification

Specification<Order> spec = Specification
    .where(byStatus(PAID))
    .and(byPeriod(from, to))
    .or(byVip(true));
// where·and·or 모두 Specification 반환 — Expression 패턴

QueryDSL

BooleanExpression cond = qOrder.status.eq(PAID)
    .and(qOrder.user.id.eq(userId))
    .or(qOrder.amount.gt(1000));

→ 모두 16장의 Expression 패턴.


7. 함정 / 주의

  • times 가 Sum 의 모든 자식을 곱하는 구조 — 단순한 케이스만. 비선형 표현 (예: 평균) 은 다른 구조 필요.
  • 표현이 깊어지면 디버깅·성능 비용 — 보통은 무시 가능.
  • 모든 도메인에 컴포지트 도입 X — 표현 트리가 진짜 필요한 곳 (DSL·정책 빌더) 만.

8. 체크리스트 (16장 완료 기준)

  • Expression 인터페이스에 plus·times·reduce 모두 선언
  • Money·Sum 모두 같은 메서드 셋 구현
  • 새 표현 (예: Times) 추가 시 기존 코드 무변경 가능 검증
  • 모든 테스트 초록

9. 퀴즈

  1. 16장에서 times 를 Expression 으로 끌어올린 효과?
  2. 새 표현 (예: Difference) 추가 시 어떤 코드 변경 필요?
  3. 컴포지트 패턴의 정의를 16장 도메인으로 설명?
  4. Spring Specification·QueryDSL 이 16장과 같은 원리인 이유?
  5. 모든 도메인에 컴포지트를 도입하면 안 되는 이유?

정답·해설

  1. Money·Sum 둘 다 times 지원. 호출자가 단일 금액인지 합계인지 모르고도 times 호출 가능. 컴포지트 패턴의 완성 — 잎과 가지가 같은 인터페이스.
  2. Difference 클래스 1개만 추가 (Expression 구현). Bank·Money·Sum 무변경. OCP 충족. 새 연산 = 새 표현 1개.
  3. 부분 (Money 잎) + 전체 (Sum 가지) 가 같은 인터페이스 (Expression). 호출자는 둘을 구분 안 함. 트리 구조 + 일관된 처리. GoF 컴포지트 패턴 정의 그대로.
  4. 조건·쿼리도 표현 트리. and·or·where 모두 Specification 반환 (Expression) + 실제 SQL 생성은 평가 시점. 16장의 Expression + Bank.reduce 의 정확한 적용.
  5. 표현 트리가 진짜 필요한 곳에만 — DSL·정책 빌더·쿼리 빌더 등. 일반 도메인 (User·Order) 에 컴포지트 도입은 과한 추상화. 리팩터링 의 "추측성 일반화" 악취.

다음 장 예고 — 17장: Money 회고

1부 (1~16장) 전체 회고. TDD 의 본질·도메인 발견·작은 단계의 위력 정리. 다른 책 (Effective Java·리팩터링·Clean Code·오브젝트) 과의 연결.