테스트 주도 개발 실전 강의 교재¶
7장 — 사과와 오렌지¶
대상: Java/Spring 백엔드 입문~중급 수강생 형식: 할 일 → RED → GREEN → REFACTOR → 함정 → 체크리스트 → 퀴즈 전제 환경: Java 17+, JUnit 5
0. 이 장을 시작하기 전에¶
0.1 학습 목표¶
- 6장 끝의 빨강 (
Franc(5) == Dollar(5)) 을 해결한다. equals에 클래스 비교 를 추가 — "통화가 다르면 다른 객체".getClass()vsinstanceof의 트레이드오프를 안다.- Effective Java Item 10 의 equals 일반 규약 (반사성·대칭성·이행성·일관성·null) 과 LSP 의 충돌을 본다.
0.2 큰 그림 — "동일성의 의미는 도메인이 결정"¶
[ 6장 끝 ] [ 7장 ]
Money.equals 가 amount 만 비교 Money.equals 가 클래스도 비교
Franc(5) == Dollar(5) true (❌) Franc(5) != Dollar(5) (✅)
비유 — "5달러 지폐 vs 5프랑 지폐"
둘 다 액면가 "5" 이지만 누구도 같다고 인정 안 함. 환율 따라 가치가 다르고, 가게에서 받는 통화도 다름. 숫자만 같다고 같은 게 아님.
0.3 현업에서 왜 중요한가¶
- 도메인 객체의 동일성은 비즈니스 의미 가 결정. 단순 필드 비교 X.
- JPA Entity 의
equals가 같은 사고 단골 — ID 만 비교? 모든 필드? 비즈니스 키? - LSP 위배가 거의 항상
equals와 얽힘 — 상속 + equals = 함정.
1. 할 일 목록 갱신¶
[x] $5 * 2 = $10
[x] Dollar 부작용 제거
[x] equals()
[x] amount private
[x] CHF * 2 = 10 CHF
[x] Dollar 와 Franc 중복 제거
[ ] 통화가 다르면 equals false ← 이번
[ ] hashCode()
[ ] $5 + 10 CHF = $10
2. RED — 통화 비교 테스트¶
→ 빨강 (현재 코드는 true 반환).
3. GREEN — getClass() 비교¶
단계 1: equals 에 getClass 추가¶
public class Money {
protected final int amount;
protected Money(int amount) { this.amount = amount; }
@Override
public boolean equals(Object o) {
if (o == null) return false;
if (getClass() != o.getClass()) return false; // ← 추가
Money m = (Money) o;
return amount == m.amount;
}
}
→ 통과. Franc.getClass() ≠ Dollar.getClass() 라서 false.
단계 2: 기존 테스트 모두 초록 확인¶
@Test void Dollar_5_equals_Dollar_5() { ... } // OK (같은 클래스)
@Test void Franc_5_equals_Franc_5() { ... } // OK
@Test void Franc_5_notEquals_Dollar_5() { ... } // OK (다른 클래스)
전부 초록. 추출이 가져온 빨강을 7장에서 해결.
4. REFACTOR — getClass vs instanceof¶
getClass() 의 함정 — LSP¶
public class Money {
public boolean equals(Object o) {
if (getClass() != o.getClass()) return false; // ← 자식 클래스 거부
...
}
}
문제: Money 를 상속한 새 자식 (예: LoggingDollar) 이 자기 부모 Dollar 와 다르다고 판정됨. 자식이 부모를 대체할 수 있어야 한다는 LSP (리스코프 치환 원칙) 위배 가능성.
instanceof 의 함정 — 대칭성¶
public class Money {
public boolean equals(Object o) {
if (!(o instanceof Money m)) return false; // 자식도 같은 타입
return amount == m.amount;
}
}
문제: LoggingDollar(5).equals(Dollar(5)) true 이면, Dollar(5).equals(LoggingDollar(5)) 도 true 여야 함 (대칭성). 자식이 자기 필드를 비교에 추가하면 대칭성 깨짐.
→ Effective Java Item 10 의 유명한 문제. 결론: 상속 가능한 클래스에 equals 추가는 매우 신중.
TDD 책의 선택¶
이 책에서는 현재 getClass() 로 갑니다. 이유:
1. Money 가 곧 단일 클래스 + 통화 필드로 통합될 예정 (8~11장)
2. 자식 클래스가 사라지면 LSP 문제도 사라짐
3. 일단 통과 → 다음 단계에서 더 좋은 구조로
TDD 실용주의: 현재 가장 단순한 해법으로 통과시키고, 더 좋은 구조가 보이면 다음 사이클에서 정련.
5. 현업 예제 — JPA Entity 의 equals¶
사례 1: 잘못된 ID 만 비교¶
@Entity
public class Order {
@Id private Long id;
private String number;
// ...
@Override public boolean equals(Object o) {
if (!(o instanceof Order other)) return false;
return Objects.equals(id, other.id); // ❌ 영속화 전 id == null
}
}
함정: 영속화 전 두 새 Order 가 둘 다 id null → equals true. HashSet 에 넣으면 사고.
사례 2: 비즈니스 키 비교¶
@Override public boolean equals(Object o) {
if (!(o instanceof Order other)) return false;
return Objects.equals(number, other.number); // 비즈니스 키
}
→ 도메인 의미 있는 키로 비교. JPA 영속화 상태와 무관하게 일관.
사례 3: record DTO¶
→ 자동 equals (모든 필드). DTO 는 단순 데이터라 가장 안전.
6. 함정 / 주의¶
equals+ 상속 = 거의 항상 함정. Effective Java Item 10 의 결론. 상속 가능 클래스는 신중.getClass()만 쓰면 LSP 위배 가능성.instanceof만 쓰면 대칭성 위배 가능성. 둘 다 만족하는 해법은 컴포지션.- JPA Entity 의
equals는 ID 만 X — 영속화 전 null 사고. 비즈니스 키 또는 신중한 설계. hashCode도 같이 재정의 — 다음 장 빚.
7. 체크리스트 (7장 완료 기준)¶
- 통화 다른 Money 가 equals false 인가
- 같은 통화 같은 금액은 equals true 인가
-
hashCode가 다음 장 빚으로 적혔는가 - LSP·대칭성 트레이드오프를 인지했는가
8. 퀴즈¶
- 6장 끝 빨강을 7장에서 해결한 방법은?
getClass()와instanceof의 트레이드오프?- TDD 책이 이 장에서
getClass()를 선택한 실용적 이유? - JPA Entity 의
equals가 ID 만 비교하면 위험한 이유? - record 가 이 문제에 깔끔한 이유?
정답·해설¶
equals에getClass()비교 추가.Franc.getClass() != Dollar.getClass()라서 false 반환. 도메인 의미 "통화 다르면 다른 객체" 가 코드에 명시.getClass(): 정확한 타입 일치만 — LSP 위배 가능 (자식이 부모와 다르다고 판정).instanceof: 자식도 같은 타입 — 대칭성 위배 가능 (자식이 추가 필드 비교 시). 둘 다 완벽 해법 아님 → EJ Item 10 의 결론 "상속 + equals 는 매우 신중, 가능하면 컴포지션".- 자식 클래스가 곧 사라질 예정. 8~11장에서
Dollar·Franc자체를 제거하고Money단일 클래스 +currency필드로 통합. 자식이 없으면 LSP 문제도 없음. 현재 가장 단순한 해법 + 다음 사이클의 진화 계획. - 영속화 전 ID 가 null. 두 새 Order 둘 다 id null → equals true. HashSet 에 넣으면 같은 객체로 취급되어 중복 저장 사고. 비즈니스 키 또는 자연 키로 비교 권장.
- record 는 상속 불가 + 자동 equals (모든 필드 + 클래스 비교) + 불변. 7장의 모든 함정 (LSP·대칭성·상속) 이 구조적으로 불가능. 단, 도메인 엔티티는 가변 필요해 record 부적합.
다음 장 예고 — 8장: 객체 만들기¶
Dollar·Franc 자식 클래스를 호출자에게 노출하지 않도록 정적 팩터리 메서드 (Money.dollar(5)) 도입. Effective Java Item 1 의 살아있는 사례. 호출자가 구체 클래스를 모르면, 11장에서 자식 클래스 자체를 제거할 때 호출자 영향 0.