콘텐츠로 이동

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

2장 — 타락한 객체

원서: 켄트 벡 『Test-Driven Development: By Example』 1부(화폐 예제) 대상: Java/Spring 백엔드 입문~중급 수강생 형식: 개념 → 비유 → 따라하기(Red-Green-Refactor) → 핵심 교훈 → 현업 예제 → 함정 → 체크리스트 → 퀴즈(정답 분리) 전제 환경: Java 17+, JUnit 5


0. 이 장을 시작하기 전에

0.1 학습 목표

  • 1장에서 드러난 부작용(자기 자신을 변질시키는 연산) 문제를 테스트로 잡아 고친다.
  • 연산이 새 객체를 반환하게 만들어 Money를 값 객체(Value Object)로 진화시킨다.
  • "작은 테스트 하나가 설계를 밀어붙인다"를 다시 한 번 체감한다.

0.2 큰 그림 — 1장이 남긴 빚을 갚는 장

1장 끝:  five.times(2)  →  five 자신이 10으로 변함 (값이 타락)
2장 목표: five.times(2) →  '새로운' 10짜리 Dollar를 반환, five는 5 그대로

비유 — "자판기": 동전(5)을 넣으면 새 음료(10)가 나옵니다. 넣은 동전이 음료로 둔갑하지 않습니다. 마찬가지로 숫자 5에 2를 곱하면 새로운 10이 나올 뿐, 5 자체가 10이 되지는 않습니다. 값은 그렇게 다뤄야 자연스럽습니다.

0.3 현업에서 왜 중요한가

  • Money, 좌표, 기간 같은 값은 변하지 않아야 동시성·예측가능성이 좋아집니다(이펙티브 자바 아이템 17 불변).
  • 자바의 Integer/String/BigDecimal이 모두 이 방식(연산은 새 값을 반환)으로 동작합니다.

1. 할 일 목록 갱신

1장에서 새 항목이 보였습니다. 목록을 다듬습니다.

[x] $5 * 2 = $10
[ ] amount를 private으로
[ ] Dollar의 부작용을 없애기   ← 이번 장에서 처리
[ ] $5 + 10 CHF = $10

2. 부작용을 드러내는 테스트 (RED)

비유 — "같은 동전으로 두 번 사보기"

값이 변질되는지 확인하려면, 같은 객체로 연산을 두 번 해보면 됩니다. 첫 연산이 원본을 망쳤다면 두 번째가 틀어집니다.

@Test
void 곱셈() {
    Dollar five = new Dollar(5);
    Dollar product = five.times(2);
    assertEquals(10, product.amount);
    product = five.times(3);          // five가 그대로 5여야 15가 나온다
    assertEquals(15, product.amount);
}

1장의 구현(timesamount를 바꿈)이라면, 첫 times(2) 이후 five.amount가 10이 되어 두 번째에서 30이 나옵니다 → 실패(RED). 이 테스트가 부작용을 폭로합니다.

테스트의 형태도 바뀌었습니다. 이제 times값을 반환하는 형태로 쓰입니다("어떻게 쓰고 싶은가"를 테스트가 먼저 선언).


3. 새 객체를 반환하게 만들기 (GREEN)

비유 — "기존 그릇을 비우지 말고, 새 그릇에 담기"

연산은 입력을 건드리지 않고 결과를 새 그릇에 담아 돌려줍니다.

class Dollar {
    int amount;
    Dollar(int amount) { this.amount = amount; }

    Dollar times(int multiplier) {
        return new Dollar(amount * multiplier);   // 자신을 바꾸지 않고 새 Dollar 반환
    }
}

테스트 초록. five는 끝까지 5를 유지하고, times는 매번 새 Dollar를 만들어 줍니다.

[x] $5 * 2 = $10
[x] Dollar의 부작용을 없애기
[ ] amount를 private으로
[ ] $5 + 10 CHF = $10

4. 무엇이 좋아졌나 — "값 객체(Value Object)"의 탄생

이 작은 변경으로 Dollar값 객체의 성질을 갖기 시작했습니다.

  • 불변: 한 번 만든 Dollar의 값은 바뀌지 않는다.
  • 연산은 새 값을 반환: Integer, String, BigDecimal처럼.
  • 부작용 없음: 같은 객체를 여기저기 공유해도 안전(동시성에 강함).
Dollar a = new Dollar(5);
Dollar b = a.times(2);   // a는 그대로, b는 새 값
// 자바 표준 값들과 같은 사용감:
BigDecimal x = BigDecimal.valueOf(5);
BigDecimal y = x.multiply(BigDecimal.valueOf(2));  // x 불변, y는 새 값

다음 장으로: 값 객체는 "값이 같으면 같은 객체"로 취급되어야 자연스럽습니다. 그런데 지금 new Dollar(10)new Dollar(10)equals를 재정의하지 않아 서로 다른 객체로 취급됩니다(클린코드/이펙티브 자바 관점에서 미완성). 3장 "모두를 위한 평등"에서 equals를 추가합니다.


핵심 교훈

  1. 부작용은 같은 객체로 연산을 반복해 보면 드러난다 — 그것을 테스트로 고정하라.
  2. 값을 다루는 연산은 자신을 바꾸지 말고 새 객체를 반환하라(불변, 이펙티브 자바 17).
  3. 작은 테스트 하나가 설계를 "값 객체"라는 더 나은 방향으로 밀어붙였다.
  4. 자바의 Integer/String/BigDecimal이 이미 이 패턴이다 — 우리 도메인 값도 그렇게.

현업 예제 — 도메인 값 객체로

// 가변 Money (위험): 공유 시 한 곳의 변경이 다른 곳에 번진다
class MutableMoney { long amount; void add(long v){ amount += v; } }

// 불변 Money (권장): 연산은 새 값을 반환
record Money(long amount, String currency) {
    Money times(int multiplier) { return new Money(amount * multiplier, currency); }
    Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException("통화 불일치");
        return new Money(amount + other.amount, currency);
    }
}

교차 연결: 리팩터링 3.11(기본형 집착) 처방인 "기본형을 값 객체로", 이펙티브 자바 17(불변)·아이템 50(방어적 복사)과 한 흐름입니다. TDD는 이 설계를 테스트가 이끌어낸 결과로 도달합니다.


함정 / 주의

  • 불변이라고 무조건 좋은 건 아니다. 다단계 누적 연산이 매우 잦은 핫패스라면 중간 객체 생성 비용을 고려하세요(이펙티브 자바 17의 가변 동반 클래스 — 예: StringBuilder).
  • 테스트의 의도를 좁게 유지하라. 이번 테스트는 "부작용 없음"을 확인하는 것이지, 다른 기능을 섞지 마세요(클린코드 9장: 테스트 당 개념 하나).
  • 아직 equals가 없어 값 비교가 안 됩니다. 다음 장의 빚으로 목록에 남겨둡니다.

체크리스트 (값 객체 만들기)

  • 연산이 자기 자신을 변경하지 않고 새 객체를 반환하는가
  • 필드가 변하지 않는가(불변)
  • 같은 객체를 두 번 연산해도 결과가 일관적인가(부작용 없음 테스트)
  • (다음 장) 값이 같으면 같다고 볼 수 있도록 equals를 준비했는가

퀴즈

  1. 객체의 "부작용(값 변질)"을 드러내는 테스트는 어떻게 설계하면 되는가?
  2. times가 새 Dollar를 반환하도록 바꾸면 어떤 설계 성질을 얻는가?
  3. 이 변경이 자바의 어떤 표준 클래스들과 같은 사용감을 주는가?
  4. 2장이 끝난 뒤에도 남은 "빚"은 무엇이며, 어느 장에서 갚는가?
  5. 불변 값 객체가 동시성에 강한 이유는?

정답·해설

  1. 같은 객체로 연산을 두 번 수행해 봅니다. 첫 연산이 원본을 변질시켰다면 두 번째 결과가 틀어지므로(예: five.times(2)five.times(3)), 부작용이 테스트로 잡힙니다.
  2. 값 객체(불변)의 성질을 얻습니다. 입력을 바꾸지 않고 결과를 새 객체로 돌려주어, 공유·재사용이 안전해집니다(이펙티브 자바 17).
  3. Integer, String, BigDecimal 등. 이들도 연산이 원본을 바꾸지 않고 새 값을 반환합니다.
  4. equals가 아직 없어 "값이 같으면 같다"가 성립하지 않습니다. 3장(모두를 위한 평등)에서 equals를 추가해 갚습니다.
  5. 생성 후 상태가 절대 바뀌지 않으므로, 여러 스레드가 동시에 읽어도 경쟁 상태가 발생하지 않기 때문입니다.

다음 장 예고 — 3장: 모두를 위한 평등

값 객체의 다음 의무는 "값이 같으면 같은 객체"입니다. equals를 테스트로 이끌어 추가하고(이펙티브 자바 아이템 10과 직결), 이어 4장에서 hashCode와 캡슐화(필드 private화)로 나아갑니다.