콘텐츠로 이동

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

4장 — 프라이버시

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


0. 이 장을 시작하기 전에

0.1 학습 목표

  • 3장에서 만든 equals 덕분에 amount 필드를 외부에 노출할 이유가 사라졌음 을 발견한다.
  • 필드를 private 으로 내려 캡슐화 한다.
  • "테스트가 외부 행동만 검증하면, 내부는 자유롭게 변경 가능" 이라는 TDD 의 보너스를 손에 익힌다.
  • Effective Java Item 15 (접근 권한 최소화) 가 어떻게 TDD 사이클 안에서 자연스럽게 등장하는지 본다.

0.2 큰 그림 — "테스트가 의도를 검증하면 구현은 숨길 수 있다"

[ 3장까지 ]                          [ 4장 ]
 amount 필드 외부 노출 (public)       equals 가 동등성 비교를 대신해줌
 ↓                                    ↓
 테스트가 amount 를 직접 읽음          테스트가 객체 동등성으로 검증
 (구현 누설)                          (외부 행동만 검증)
                                      amount 를 private 으로 내려도 무방
                                      (캡슐화)

비유 — "주방의 비밀 레시피"

손님은 식당에서 나온 음식의 맛만 보고 평가합니다. 주방에서 무슨 도구로 어떤 순서를 거쳐 요리했는지는 알지도 못하고, 알 필요도 없습니다. 그래서 주방장은 조리법을 얼마든지 바꿀 수 있습니다. 맛만 그대로라면 손님은 아무 불만이 없습니다.

테스트도 이 손님과 같습니다. 손님이 맛만 평가하듯 테스트가 객체의 외부 행동만 검증하면, 주방에 해당하는 내부 구현은 마음대로 바꿔도 테스트가 깨지지 않습니다.

0.3 현업에서 왜 중요한가

  • JPA 엔티티의 public 필드는 사고 단골 — 외부가 직접 변경해 불변식 깨뜨림.
  • Spring 빈의 필드 도 마찬가지 — 외부 접근 가능하면 동시성 사고.
  • TDD 가 안 된 코드는 보통 "테스트가 구현 디테일을 검증" → 캡슐화 하면 테스트가 같이 깨짐 → 캡슐화 못 함 → 부패 사이클.

1. 할 일 목록 갱신

[x] $5 * 2 = $10
[x] Dollar 부작용 제거
[x] equals()
[ ] amount 를 private 으로     ← 이번 장
[ ] hashCode()
[ ] $5 + 10 CHF = $10
[ ] CHF * 2 = 10 CHF

3장의 빚 (hashCode) 은 일단 미루고, 하나씩 처리. 두 마리 토끼 동시 잡기 = TDD 안티패턴.


2. 현 상태 점검 — amount 는 왜 public 이었나

public class Dollar {
    int amount;   // package-private — 사실상 노출
    public Dollar(int amount) { this.amount = 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;
    }
}
@Test
void 곱셈() {
    Dollar five = new Dollar(5);
    assertEquals(10, five.times(2).amount);   // ← 여기서 amount 직접 접근
}

테스트가 amount 를 직접 읽음 → 필드를 private 으로 못 내림 (테스트가 같이 깨짐).

이게 TDD 가 없을 때 흔히 생기는 함정 — 테스트가 구현 디테일을 검증하면 구조 변경 시 같이 깨짐.


3. RED 아닌 REFACTOR — 테스트를 외부 행동 기준으로

equals 가 이미 있으니, 테스트를 객체 동등성 으로 다시 쓸 수 있음:

단계 1: 테스트 다시 쓰기 (초록 유지)

@Test
void 곱셈() {
    Dollar five = new Dollar(5);
    assertEquals(new Dollar(10), five.times(2));   // ← amount 직접 접근 없음
}

equals 가 작동하므로 통과 유지. 더 이상 어떤 테스트도 amount 를 직접 읽지 않음.

단계 2: 필드를 private 으로

이제 안전하게 캡슐화 가능:

public class Dollar {
    private int amount;                                       // ← public/package → private
    public Dollar(int amount) { this.amount = 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;
    }
}

테스트 모두 통과. amount 가 외부에서 보이지 않음.

단계 3: final 도 같이

값 객체는 불변이어야 (2장 회고). 필드를 final 로 만들면 컴파일러가 변경 시도를 막아 줌:

public class Dollar {
    private final int amount;   // private + final = 진짜 불변
    public Dollar(int amount) { this.amount = 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;
    }
}

→ 3·4장에서 "값 객체" 의 핵심 3종 (불변·equals·private final) 거의 다 갖춤. 남은 건 hashCode.


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

3·4장은 Dollar 하나만 다뤘습니다. 현실에는 통화가 여럿 (USD·EUR·CHF·JPY...). 만약 Franc 가 추가되면?

Franc five = new Franc(5);
five.times(2);   // ?

거의 똑같은 코드를 또 짜야 할 것 같은 예감. 5·6장에서 그 중복을 어떻게 처리할지가 다음 무대.

TDD 리듬 다시: 한 번에 한 가지 (private 화) 만 했고, 다음 빚 (hashCode·통화 추가) 은 목록에 적어둠. 흐름 끊지 말고, 잊지도 말고.


5. 현업 예제 — JPA·Spring 에 옮기면

JPA Entity 의 필드

// ❌ Lombok @Data·@Setter 남용
@Entity @Data
public class Order {
    @Id private Long id;
    private OrderStatus status;
    // 외부에서 order.setStatus(...) 로 검증 우회 가능
}

// ✅ 외부 행동만 노출
@Entity
public class Order {
    @Id private Long id;
    private OrderStatus status;

    public void cancel() {
        if (status != PAID) throw new IllegalStateException();
        this.status = CANCELLED;
    }
    public OrderStatus status() { return status; }   // 읽기만
    // setStatus 노출 X
}

같은 원리 — 외부 행동 (cancel()) 만 테스트하면, 내부 필드는 자유.

record 가 진짜 깔끔

public record Dollar(int amount) {}

한 줄에 private final 필드 + 생성자 + accessor + equals + hashCode + toString 다 자동. 4장에서 한 모든 작업이 record 한 줄로.


6. 함정 / 주의

  • 테스트가 구현 디테일 검증 = 캡슐화의 적. 객체 동등성·메서드 결과·예외 발생만 검증.
  • @Data / @Setter 무지성 사용은 모든 필드 노출 — 캡슐화 무력화.
  • 캡슐화를 너무 잘게 — private getter 까지 만드는 건 과함. 외부에 의미 있는 경계 만.
  • record 는 불변이라 setter 가 없음 — JPA Entity 처럼 가변 필요한 곳엔 부적합.

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

  • 모든 필드가 private 인가
  • 가능한 필드가 final 인가
  • 테스트가 객체 동등성·메서드 결과로만 검증하는가 (five.amount 같은 직접 접근 0)
  • 외부에 노출된 메서드가 의미 있는 동작인가 (getter 남발 X)
  • record 로 단순화 가능한 경우 검토했는가

8. 퀴즈

  1. 4장에서 RED 단계가 등장하지 않은 이유는?
  2. 테스트가 five.amount 처럼 필드를 직접 읽으면 무엇이 어려워지는가?
  3. 같은 클래스를 record 로 쓰면 4장의 작업 중 무엇이 자동으로 해결되는가?
  4. JPA Entity 에 @Setter 를 무지성으로 붙이면 안 되는 이유는?
  5. "한 번에 한 가지" 원칙을 4장에서는 어떻게 지켰는가?

정답·해설

  1. 새 기능 추가가 아니라 정련 (REFACTOR) 만 했기 때문. 3장의 equals 가 이미 검증 도구를 제공해, 새 테스트를 짤 필요 없이 기존 테스트를 다시 쓰고 필드만 내렸음. TDD 의 RED 는 새 동작이 필요할 때만 등장.
  2. 캡슐화 못 함. 필드를 private 으로 내리면 테스트가 같이 깨짐 → 캡슐화 안 함 → 구조 변경 자유 잃음. 결과적으로 코드 부패 가속. 테스트는 외부 행동만 검증 하는 게 안전망.
  3. private final 필드 + accessor + equals + hashCode + toString 자동 생성. 4장에서 손으로 한 모든 작업이 한 줄. 단, 상속 불가·setter 없음의 제약은 있음.
  4. 모든 필드의 setter 가 노출 → 외부에서 검증 없이 상태 변경 → 도메인 불변식 깨짐. 예: 결제 안 한 주문이 setStatus(PAID) 로 결제됨 처리. 대신 의미 있는 메서드 (cancel(), pay(), ship()) 만 노출.
  5. 캡슐화만 했음. hashCode 도입·통화 추가는 모두 목록에 적어두고 다음 장으로 미룸. 한 PR 에 두 가지 변경을 섞으면 리뷰·롤백 모두 어려움 (2장의 "두 모자" 와 같은 결).

다음 장 예고 — 5장: 솔직히 말하자면

지금까지는 Dollar 하나만 있었지만, 현실은 여러 통화. Franc 를 도입하면 거의 같은 코드를 또 짜야 합니다. 그 중복을 어떻게 처리할지 — 5장에서는 일단 복붙으로 빠르게 통과, 6장에서 슈퍼클래스 추출로 정리. "복붙은 죄가 아니라 신호" 라는 TDD 의 실용주의를 손에 익힙니다.