테스트 주도 개발 실전 강의 교재¶
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 가 추가되면?
거의 똑같은 코드를 또 짜야 할 것 같은 예감. 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 가 진짜 깔끔¶
한 줄에 private final 필드 + 생성자 + accessor + equals + hashCode + toString 다 자동. 4장에서 한 모든 작업이 record 한 줄로.
6. 함정 / 주의¶
- 테스트가 구현 디테일 검증 = 캡슐화의 적. 객체 동등성·메서드 결과·예외 발생만 검증.
@Data/@Setter무지성 사용은 모든 필드 노출 — 캡슐화 무력화.- 캡슐화를 너무 잘게 —
privategetter 까지 만드는 건 과함. 외부에 의미 있는 경계 만. - record 는 불변이라 setter 가 없음 — JPA Entity 처럼 가변 필요한 곳엔 부적합.
7. 체크리스트 (4장 완료 기준)¶
- 모든 필드가
private인가 - 가능한 필드가
final인가 - 테스트가 객체 동등성·메서드 결과로만 검증하는가 (
five.amount같은 직접 접근 0) - 외부에 노출된 메서드가 의미 있는 동작인가 (getter 남발 X)
- record 로 단순화 가능한 경우 검토했는가
8. 퀴즈¶
- 4장에서
RED단계가 등장하지 않은 이유는? - 테스트가
five.amount처럼 필드를 직접 읽으면 무엇이 어려워지는가? - 같은 클래스를 record 로 쓰면 4장의 작업 중 무엇이 자동으로 해결되는가?
- JPA Entity 에
@Setter를 무지성으로 붙이면 안 되는 이유는? - "한 번에 한 가지" 원칙을 4장에서는 어떻게 지켰는가?
정답·해설¶
- 새 기능 추가가 아니라 정련 (REFACTOR) 만 했기 때문. 3장의
equals가 이미 검증 도구를 제공해, 새 테스트를 짤 필요 없이 기존 테스트를 다시 쓰고 필드만 내렸음. TDD 의 RED 는 새 동작이 필요할 때만 등장. - 캡슐화 못 함. 필드를
private으로 내리면 테스트가 같이 깨짐 → 캡슐화 안 함 → 구조 변경 자유 잃음. 결과적으로 코드 부패 가속. 테스트는 외부 행동만 검증 하는 게 안전망. private final필드 + accessor + equals + hashCode + toString 자동 생성. 4장에서 손으로 한 모든 작업이 한 줄. 단, 상속 불가·setter 없음의 제약은 있음.- 모든 필드의 setter 가 노출 → 외부에서 검증 없이 상태 변경 → 도메인 불변식 깨짐. 예: 결제 안 한 주문이
setStatus(PAID)로 결제됨 처리. 대신 의미 있는 메서드 (cancel(),pay(),ship()) 만 노출. - 캡슐화만 했음.
hashCode도입·통화 추가는 모두 목록에 적어두고 다음 장으로 미룸. 한 PR 에 두 가지 변경을 섞으면 리뷰·롤백 모두 어려움 (2장의 "두 모자" 와 같은 결).
다음 장 예고 — 5장: 솔직히 말하자면¶
지금까지는 Dollar 하나만 있었지만, 현실은 여러 통화. Franc 를 도입하면 거의 같은 코드를 또 짜야 합니다. 그 중복을 어떻게 처리할지 — 5장에서는 일단 복붙으로 빠르게 통과, 6장에서 슈퍼클래스 추출로 정리. "복붙은 죄가 아니라 신호" 라는 TDD 의 실용주의를 손에 익힙니다.