이펙티브 자바 완벽 공략 1부
아이템 10. equals는 일반 규약을 지켜 재정의하라
아이템 10. equals는 일반 규약을 지켜 재정의하라
- 아이템 10. equals는 일반 규약을 지켜 재정의하라
- 핵심 정리: equals는 “필요할 때만” 재정의하라
- equals를 재정의하지 않아도 되는 경우
- Object 클래스와 equals
- equals를 재정의하는 이유
- 흔한 오해 정리
- 실무 관점 핵심 정리
- 이 글의 핵심 요약
- 핵심 정리: equals는 반드시 “5가지 규약”을 만족해야 한다
- 전체 규약 요약
- 실무 핵심 인사이트
- 최종 결론
- 한 줄 요약
- equals 규약: 추이성과 상속의 함정
- 추이성 (Transitivity)
- 문제 상황: Point와 ColorPoint
- 1. 단순한 구현 (대칭성 깨짐)
- 2. 타입까지 고려한 개선 (추이성 깨짐)
- ❌ 추이성 깨짐
- ❌ 더 큰 문제: 무한 재귀 (StackOverflow)
- getClass()로 해결하면 될까?
- ❌ 리스코프 치환 원칙(LSP) 위배
- ✔ 리스코프 치환 원칙 (LSP)
- 핵심 결론
- 실제 Java에서도 깨진 사례
- 해결 방법: 컴포지션 (Composition)
- 이 섹션의 핵심 요약
- 한 줄 결론
- 상속 대신 컴포지션을 사용하라: equals 문제를 피하는 가장 현실적인 방법
- 왜 상속이 아니라 컴포지션인가
- 컴포지션 방식의 예시
- 핵심은 자기 자신의 필드만 비교하면 된다는 점이다
- 그래도 Point처럼 보고 싶다면: Point View 제공
- 일관성(consistency): equals 결과는 가능하면 변하지 않아야 한다
- equals는 너무 복잡하게 구현하지 말아야 한다
- null 비교는 항상 false면 된다
- 정리
- equals 구현 방법: 실수 없이 재정의하는 정석 패턴
- Value 기반 클래스란 무엇인가
- StackOverflowError란 무엇인가
- 리스코프 치환 원칙(Liskov Substitution Principle, LSP)
핵심 정리: equals는 “필요할 때만” 재정의하라
equals()는 잘못 재정의하면 버그 + 컬렉션 문제 + 디버깅 지옥으로 이어진다. 그래서 가장 중요한 원칙은 다음이다.
재정의가 필요 없는 상황이라면, 아예 재정의하지 않는 것이 최선이다.
equals를 재정의하지 않아도 되는 경우
다음 조건 중 하나라도 만족하면 equals()를 재정의할 필요가 없다.
1. 인스턴스가 본질적으로 고유한 경우
각 객체가 “그 자체로 의미”를 가진다면 논리적 비교 자체가 필요 없다.
예시:
- Thread
- Socket
- Database Connection
👉 이런 객체는 “같다”의 의미가 없음 👉 단순히 동일한 객체인지 (==)만 중요
2. 논리적 동치성을 비교할 필요가 없는 경우
논리적 동치성(logical equality)이란:
서로 다른 객체라도 “의미적으로 같은 상태”인지 비교하는 것
하지만 모든 객체가 이런 비교를 필요로 하진 않는다.
예시:
new Object().equals(new Object()) // false
👉 대부분의 객체는 “같을 이유 자체가 없다”
3. 상위 클래스에서 이미 equals를 잘 구현한 경우
예시:
- List
- Set
- Map
이런 컬렉션 클래스는 이미:
- 값 기반 비교
- 순서 기반 비교 (List)
를 올바르게 구현하고 있다.
👉 굳이 재정의하면 오히려 규약 깨질 위험 있음
4. 클래스 사용 범위가 제한적인 경우
- private 클래스
- package-private 클래스
- 내부적으로만 사용하는 클래스
👉 외부에서 equals를 사용할 일이 없다면 👉 구현 자체가 의미 없음
Object 클래스와 equals
모든 클래스는 Object를 상속한다.
즉, 기본적으로 아래 메서드를 물려받는다.
- equals()
- hashCode()
- toString()
- clone()
- finalize()
👉 이 중에서 가장 많이 재정의되는 것이 equals()
Object의 기본 equals는 무엇인가?
public boolean equals(Object obj) {
return (this == obj);
}
👉 즉,
기본 equals는 “주소 비교”다 (참조 동일성)
equals를 재정의하는 이유
그렇다면 언제 equals를 재정의해야 할까?
👉 핵심 기준은 하나다.
“논리적으로 같은 객체”를 동일하게 취급해야 할 때
예시: Value Object
class User {
String name;
}
new User("A") vs new User("A")
- 주소: 다름
- 의미: 같음
👉 이런 경우 equals를 재정의해야 한다
흔한 오해 정리
❌ “문자열은 equals 필요 없다”
→ 틀린 설명
- String은 이미 equals를 재정의해 둔 상태
- 그래서 값 비교가 가능
"abc".equals("abc") // true
👉 직접 안 만들 뿐이지, 이미 구현되어 있음
❌ “싱글톤이라 equals 필요 없다”
→ 맞는 설명
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
}
👉 애초에 객체가 하나
👉 equals 의미 없음
❌ enum은 equals 필요 없다
→ 맞음
- enum은 JVM에서 단일 인스턴스 보장
- == 비교가 더 빠르고 안전
Color.RED == Color.RED
실무 관점 핵심 정리
이 부분이 진짜 중요하다.
equals를 잘못 구현하면 생기는 문제
- HashMap / HashSet 동작 깨짐
- 중복 데이터 허용됨
- 캐시 미스 발생
- 디버깅 난이도 폭증
👉 특히 backend에서는 치명적
그래서 실무에서는 이렇게 한다
- 대부분 IDE 생성 (IntelliJ)
- 또는 Lombok 사용
@EqualsAndHashCode
👉 직접 구현은 거의 하지 않음
이 글의 핵심 요약
- equals는 강력하지만 위험한 메서드다
- 필요 없는 경우 절대 재정의하지 않는다
- 필요하다면 반드시 규약을 지켜야 한다
- hashCode와 항상 함께 고려해야 한다
핵심 정리: equals는 반드시 “5가지 규약”을 만족해야 한다
equals()는 단순 비교 메서드가 아니다. 잘못 구현하면 컬렉션 동작 오류, 데이터 정합성 문제, 심각한 버그로 이어진다.
그래서 Object.equals()는 반드시 아래 5가지 규약을 지켜야 한다.
1. 반사성 (Reflexivity)
A.equals(A) == true
객체는 자기 자신과 항상 같아야 한다.
a.equals(a) == true
이건 가장 직관적인 규칙이다.
- 거울 속의 나를 보고 “저건 나다”라고 인식해야 한다
- 구현도 간단하다
if (this == obj) return true;
👉 반사성은 깨질 일이 거의 없다 👉 equals 구현의 “기본 전제 조건”
2. 대칭성 (Symmetry)
A.equals(B) == B.equals(A)
두 객체의 비교 결과는 양방향이 동일해야 한다
a.equals(b) == true
→ b.equals(a) 도 true
❌ 대칭성이 깨지는 대표적인 예: CaseInsensitiveString
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return str.equalsIgnoreCase(((CaseInsensitiveString) o).str);
if (o instanceof String)
return str.equalsIgnoreCase((String) o);
return false;
}
👉 이 코드는 “String도 비교 대상”으로 허용한다
문제 상황
CaseInsensitiveString cis = new CaseInsensitiveString("abc");
cis.equals("ABC") → true
"ABC".equals(cis) → false
👉 왜 깨질까?
cis는 String을 알고 있음 → 비교 가능String은cis를 모름 → 비교 불가능
👉 결과
- 한쪽은 true
- 한쪽은 false
👉 대칭성 완전히 깨짐
✔ 핵심 교훈
equals는 “자기 타입끼리만 비교”해야 한다
👉 다른 타입을 허용하면 거의 100% 깨진다
3. 추이성 (Transitivity)
A.equals(B) && B.equals(C) → A.equals(C)
직관
- A == B
- B == C
👉 그럼 A == C 여야 한다
❌ 깨지는 대표 사례: 상속 + equals
class Point {
int x, y;
}
class ColorPoint extends Point {
int color;
}
naive한 구현
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
문제 상황
Point p = new Point(1, 2);
ColorPoint red = new ColorPoint(1, 2, RED);
ColorPoint blue = new ColorPoint(1, 2, BLUE);
- p.equals(red) → true (좌표 같음)
- p.equals(blue) → true (좌표 같음)
👉 그러면?
- red.equals(blue) → false (색 다름)
👉 결과
p == red
p == blue
BUT red != blue
👉 추이성 깨짐
✔ 핵심 교훈
상속 기반 equals 확장은 거의 항상 문제를 만든다
👉 해결 방법
- 상속 대신 조합(composition) 사용
4. 일관성 (Consistency)
같은 객체에 대해 equals 결과는 항상 동일해야 한다
a.equals(b) == a.equals(b)
깨지는 경우
- 외부 상태에 의존
- 시간이 지나면서 값이 변함
- DB, 네트워크 결과에 의존
예시
@Override
public boolean equals(Object o) {
return currentTime() == other.currentTime();
}
👉 호출할 때마다 결과 바뀜 → ❌
✔ 핵심 교훈
equals는 “불변 값” 기반으로 비교해야 한다
5. null-아님 (Non-null)
A.equals(null) == false
a.equals(null) → false
👉 절대 예외 던지면 안 됨 👉 항상 false 반환
전체 규약 요약
| 규약 | 의미 |
|---|---|
| 반사성 | 자기 자신과 같아야 한다 |
| 대칭성 | 양방향 결과 동일 |
| 추이성 | 연쇄 비교 유지 |
| 일관성 | 항상 같은 결과 |
| null-아님 | null 비교 시 false |
실무 핵심 인사이트
이걸 한 문장으로 정리하면 이거다:
equals는 “객체의 의미”를 정의하는 메서드다
실무에서 가장 많이 터지는 문제
- HashSet 중복 저장됨
- Map 키 조회 실패
- 캐시 미스
- 데이터 정합성 깨짐
👉 특히 결제 / 정산 / 식별자 기반 시스템에서는 치명적
최종 결론
- equals는 단순 비교가 아니다
- 규약을 지키지 않으면 시스템이 망가진다
- 특히 상속 + equals 조합은 매우 위험하다
한 줄 요약
equals는 “논리적 동치성”을 정의하지만, 규약을 지키지 않으면 버그 생성기가 된다
equals 규약: 추이성과 상속의 함정
앞서 equals의 반사성과 대칭성을 살펴봤다면, 이제 가장 많은 문제가 발생하는 추이성(transitivity)을 이해해야 한다.
추이성 (Transitivity)
A.equals(B) == true && B.equals(C) == true → A.equals(C) == true
즉,
- A와 B가 같고
- B와 C가 같다면 👉 A와 C도 반드시 같아야 한다
이 규칙은 단순해 보이지만, 상속이 개입되는 순간 매우 복잡해진다.
문제 상황: Point와 ColorPoint
class Point {
int x, y;
}
class ColorPoint extends Point {
int color;
}
Point는 좌표(x, y)만 비교하면 되지만, ColorPoint는 색상(color)까지 추가되었다.
1. 단순한 구현 (대칭성 깨짐)
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
문제:
- Point vs ColorPoint 비교 시
- 한쪽은 true, 한쪽은 false
👉 대칭성 깨짐
2. 타입까지 고려한 개선 (추이성 깨짐)
그래서 이런 식으로 개선하려고 한다.
@Override
public boolean equals(Object o) {
if (!(o instanceof Point)) return false;
// o가 Point지만 ColorPoint가 아닌 경우
if (!(o instanceof ColorPoint))
return o.equals(this);
// 둘 다 ColorPoint인 경우
return super.equals(o) && this.color == ((ColorPoint) o).color;
}
겉보기에는 더 정교해 보인다.
- Point도 비교 가능
- ColorPoint도 비교 가능
👉 하지만 이 코드는 더 위험하다
❌ 추이성 깨짐
ColorPoint p1 = new ColorPoint(1, 2, RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, BLUE);
결과:
- p1.equals(p2) → true
- p2.equals(p3) → true
- p1.equals(p3) → false
👉 true, true, false
👉 추이성 완전히 깨짐
❌ 더 큰 문제: 무한 재귀 (StackOverflow)
이 방식은 클래스가 하나 더 추가되면 더 위험해진다.
- ColorPoint
- CounterPoint (또 다른 서브 클래스)
이런 구조에서 equals가 서로를 호출하면:
A.equals(B) → B.equals(A) → A.equals(B) → ...
👉 무한 호출 👉 StackOverflow 발생
getClass()로 해결하면 될까?
이 문제를 해결하려고 흔히 이런 생각을 한다.
if (o.getClass() != this.getClass()) return false;
👉 “같은 클래스끼리만 비교하면 되지 않을까?”
❌ 리스코프 치환 원칙(LSP) 위배
이 방식은 또 다른 문제를 만든다.
예제
Set<Point> set = Set.of(
new Point(1, 0),
new Point(0, 1),
new Point(-1, 0),
new Point(0, -1)
);
set.contains(new CounterPoint(1, 0));
👉 기대 결과: true 👉 실제 결과: false
왜 문제가 되는가?
- CounterPoint는 Point의 하위 타입
- 좌표도 동일
👉 논리적으로 같아야 한다
하지만 getClass() 비교 때문에:
- Point != CounterPoint 👉 equals → false
✔ 리스코프 치환 원칙 (LSP)
상위 타입을 사용하는 코드는 하위 타입으로도 동일하게 동작해야 한다
즉,
Point → 정상 동작
CounterPoint → 동일하게 동작해야 함
👉 그런데 equals가 이를 깨버린다
핵심 결론
여기서 중요한 결론이 나온다.
❗ 구체 클래스 + 필드 추가 + 상속
equals 규약을 만족시키는 방법은 존재하지 않는다
왜냐하면
- instanceOf → 추이성 깨짐
- getClass → LSP 깨짐
- 혼합 → 무한 재귀 가능
👉 어떤 방식도 완벽하지 않다
실제 Java에서도 깨진 사례
대표적인 예:
java.sql.Timestampjava.util.Date
👉 equals 규약 위반 (대칭성 깨짐)
해결 방법: 컴포지션 (Composition)
상속 대신 포함 관계를 사용한다.
class ColorPoint {
private Point point;
private int color;
}
👉 비교도 명확해진다
- Point vs ColorPoint 비교 없음
- ColorPoint끼리만 비교
👉 규약 유지 가능
이 섹션의 핵심 요약
- equals는 단순 비교 메서드가 아니다
- 상속이 개입되면 규약 유지가 매우 어려워진다
- 특히 필드가 추가되는 상속은 거의 100% 문제 발생
한 줄 결론
equals를 구현할 때 상속으로 확장하려는 순간, 이미 잘못된 방향일 가능성이 높다
상속 대신 컴포지션을 사용하라: equals 문제를 피하는 가장 현실적인 방법
앞에서 본 것처럼 Point를 상속한 ColorPoint에 색상 같은 새로운 필드를 추가하는 순간, equals()는 금방 복잡해진다. 어떤 방식으로 구현하든 대칭성, 추이성, 리스코프 치환 원칙 가운데 하나를 깨기 쉽다.
이 지점에서 Effective Java가 주는 결론은 분명하다.
구체 클래스를 상속해서 새로운 값을 추가하려고 하지 말고, 컴포지션을 사용하라.
즉, ColorPoint extends Point처럼 만드는 대신, ColorPoint 안에 Point를 하나의 필드로 포함시키는 방식으로 설계하라는 뜻이다.
왜 상속이 아니라 컴포지션인가
상속을 사용하면 ColorPoint는 Point이기도 하다. 겉으로 보기에는 자연스럽지만, equals() 관점에서는 이 관계가 오히려 문제를 만든다.
왜냐하면 Point는 좌표만 비교하면 되지만, ColorPoint는 좌표와 색상까지 비교해야 하기 때문이다.
즉,
Point의 입장: x, y가 같으면 같다ColorPoint의 입장: x, y도 같고 color도 같아야 한다
이 두 기준이 한 계층 구조 안에 공존하는 순간, equals() 규약을 만족시키기 어려워진다.
반면 컴포지션을 사용하면 ColorPoint는 더 이상 Point가 아니다. 그 대신 Point를 내부에 가지고 있는 별도의 클래스가 된다. 이렇게 되면 ColorPoint의 equals()는 오직 자기 자신의 타입만 신경 쓰면 된다.
컴포지션 방식의 예시
예를 들어 이런 식이다.
public class ColorPoint {
private final Point point;
private final Color color;
public ColorPoint(int x, int y, Color color) {
this.point = new Point(x, y);
this.color = color;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) {
return false;
}
ColorPoint cp = (ColorPoint) o;
return point.equals(cp.point) && color.equals(cp.color);
}
@Override
public int hashCode() {
return Objects.hash(point, color);
}
}
이 방식의 장점은 단순하다.
equals()가 자기 타입만 비교하므로 대칭성이 깨질 일이 적다Point비교는Point의equals()에 위임하면 된다Color비교는Color의equals()를 쓰면 된다- 상속 구조에서 생기던 추이성 문제를 피할 수 있다
결국 ColorPoint는 “좌표를 가진 점”이 아니라 “점 정보와 색상 정보를 함께 가진 새로운 값 객체”가 되는 것이다.
핵심은 자기 자신의 필드만 비교하면 된다는 점이다
컴포지션을 사용하면 equals() 구현이 훨씬 자연스러워진다.
비교 기준은 아주 명확하다.
- 전달받은 객체가 내 타입인가
- 내가 가진 모든 필드가 같은가
이게 끝이다.
상속 기반 equals()처럼
- 상대가 부모 타입이면 어떻게 할지
- 자식 타입이면 어디까지 비교할지
- 부모와 자식 사이를 같다고 봐야 할지
이런 고민이 사라진다.
즉, 비교 기준이 흔들리지 않는다.
그래도 Point처럼 보고 싶다면: Point View 제공
컴포지션을 쓰면 한 가지 아쉬운 점이 생긴다.
ColorPoint는 더 이상 Point를 상속하지 않으므로, Point가 필요한 곳에 바로 넣을 수는 없다.
예를 들어 상속 구조였다면 이런 식이 가능했을 것이다.
Point p = new ColorPoint(1, 2, RED);
하지만 컴포지션 구조에서는 이게 불가능하다. 왜냐하면 ColorPoint는 Point가 아니라, Point를 포함한 별도의 클래스이기 때문이다.
이럴 때 사용할 수 있는 방식이 바로 Point View다. 즉, 내부에 들고 있는 Point를 외부에 노출하는 메서드를 제공하는 것이다.
public Point asPoint() {
return point;
}
이렇게 해두면 ColorPoint를 “점으로서 바라보고 싶은” 상황에서 안전하게 사용할 수 있다.
ColorPoint cp = new ColorPoint(1, 2, RED);
Point p = cp.asPoint();
이 방식의 장점은 명확하다.
- 상속으로 타입 관계를 강제로 만들지 않는다
- 필요할 때만 Point의 관점으로 노출한다
- equals 규약을 깨지 않으면서도 Point처럼 활용할 수 있다
즉, 상속의 위험은 피하고, 필요한 표현력은 유지할 수 있다.
일관성(consistency): equals 결과는 가능하면 변하지 않아야 한다
equals()의 또 다른 중요한 규약은 일관성이다.
같은 두 객체를 여러 번 비교했을 때, 결과는 항상 같아야 한다.
즉,
a.equals(b)
를 한 번 호출했을 때 true였다면, 바로 다시 호출했을 때도 true여야 한다.
이 규약은 특히 불변 객체(immutable object)에서 자연스럽게 만족된다. 객체가 생성된 뒤 내부 값이 바뀌지 않기 때문이다.
반대로 가변 객체(mutable object)는 내부 상태가 바뀔 수 있기 때문에 비교 결과도 달라질 가능성이 있다. 그래서 값 비교를 해야 하는 클래스일수록 가능하면 불변으로 설계하는 것이 좋다.
equals는 너무 복잡하게 구현하지 말아야 한다
일관성을 깨뜨리는 대표적인 예로 URL.equals()가 자주 언급된다.
URL은 단순히 문자열만 비교하는 것이 아니라, 호스트가 실제로 가리키는 IP 주소까지 확인하려고 한다. 문제는 이 IP 주소가 시간이 지나면서 바뀔 수 있다는 점이다.
예를 들어 같은 도메인이라도
- 지금은 특정 IP를 가리키고
- 나중에는 다른 IP를 가리킬 수 있다
그러면 같은 두 URL을 비교했는데 시점에 따라 결과가 달라질 수 있다. 이런 비교는 일관성을 해칠 위험이 있다.
그래서 equals()는 지나치게 복잡한 외부 상태에 의존하지 말아야 한다.
- 네트워크 조회
- DNS 해석
- 파일 시스템의 최종 경로 추적
- 실행 시점에 따라 바뀌는 상태
이런 것들까지 끌어들여 비교하기 시작하면, equals()는 더 이상 안정적인 값 비교 메서드가 아니게 된다.
핵심은 이렇다.
equals는 객체가 “지금 가지고 있는 논리적 값”만 비교해야 한다. 바깥 세상의 변하는 정보까지 끌고 들어오면 안 된다.
null 비교는 항상 false면 된다
마지막 규약은 가장 단순하다.
어떤 객체든
null과 같을 수는 없다.
즉,
a.equals(null)
은 항상 false여야 한다.
이건 매우 당연한 규칙이지만, equals() 구현에서는 반드시 자연스럽게 만족되어야 한다. 보통 instanceof 검사만 잘 해도 이 규칙은 어렵지 않게 지킬 수 있다.
정리
equals()는 단순히 “같다/다르다”를 판단하는 메서드처럼 보이지만, 실제로는 객체의 논리적 동일성을 정의하는 매우 중요한 계약이다.
특히 상속과 함께 사용하면 문제가 빠르게 커진다.
- 부모는 부모 기준으로 비교하고
- 자식은 자식 기준으로 비교하게 되면서
- 대칭성, 추이성, 치환 가능성이 쉽게 깨진다
그래서 새로운 값을 추가하고 싶을 때는 상속보다 컴포지션이 더 안전하다.
정리하면 다음과 같다.
- 필드를 추가하는 하위 클래스로
equals()를 안전하게 확장하는 것은 사실상 어렵다 - 이런 경우에는 상속 대신 컴포지션을 사용해야 한다
equals()는 자기 타입과 자기 필드만 기준으로 단순하게 구현하는 편이 안전하다- 가능하면 불변 객체로 설계해야 일관성을 지키기 쉽다
- 외부 환경에 따라 바뀌는 값까지 비교에 포함하면 안 된다
결국 좋은 equals()의 핵심은 복잡한 영리함이 아니라, 명확하고 안정적인 비교 기준에 있다.
equals 구현 방법: 실수 없이 재정의하는 정석 패턴
equals()는 단순 비교 메서드처럼 보이지만, 실제로는 객체의 논리적 동일성을 정의하는 핵심 로직이다.
따라서 구현할 때는 반드시 일정한 정해진 패턴을 따라야 하며, 이 패턴을 벗어나면 컬렉션 동작 오류나 데이터 정합성 문제가 발생할 수 있다.
equals 구현 4단계 (핵심 흐름)
강의에서 강조한 것처럼, equals()는 아래 4단계를 따르면 안정적으로 구현할 수 있다.
1. 자기 자신인지 먼저 확인
if (this == o) return true;
- 가장 빠른 비교
- 같은 객체라면 무조건 true
- equals 규약 중 반사성을 만족시키는 기본 조건
2. 타입 검사 (instanceof)
if (!(o instanceof Point)) return false;
- 비교 가능한 타입인지 확인
- 잘못된 타입이면 즉시 false 반환
3. 타입 캐스팅
Point p = (Point) o;
- 타입 검사를 했기 때문에 안전하게 캐스팅 가능
- 이후 필드 비교를 위해 필요
4. 핵심 필드 비교
return x == p.x && y == p.y;
- 객체의 “논리적 동일성”을 정의하는 핵심
- 반드시 핵심 필드만 비교해야 한다
핵심 포인트: 모든 필드를 비교하면 안 된다
equals 구현에서 중요한 기준은 다음이다.
객체의 의미를 정의하는 필드만 비교해야 한다
❌ 비교하면 안 되는 필드
- 동기화용 lock 객체
- 캐시 필드
- 내부 상태 관리용 필드
👉 이런 값들은 객체의 “논리적 의미”와 무관하다
타입별 비교 방법
1. primitive 타입
x == p.x
2. reference 타입
Objects.equals(name, p.name)
- null-safe 비교 필요
3. float / double
Double.compare(d1, d2) == 0
- 부동소수점 오차 때문에
==사용 금지
null 처리
equals 규약 중 하나:
x.equals(null) == false
이건 별도로 처리하지 않아도 instanceof 검사만으로 자연스럽게 만족된다.
구현 예시 (정석 형태)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
👉 이 패턴이 equals 구현의 표준이다.
직접 구현하지 말고 도구를 사용하라
이론적으로는 직접 구현할 수 있지만, 실무에서는 도구를 사용하는 것이 훨씬 안전하다.
1. Lombok
@EqualsAndHashCode
- 가장 많이 사용되는 방법
- 간단하고 유지보수 용이
2. Java Record (Java 17+)
public record Point(int x, int y) {}
- equals / hashCode 자동 생성
- Value Object에 최적화
3. AutoValue
- 구글에서 제공하는 코드 생성 도구
- 정석적인 Annotation Processor 방식
4. IDE 자동 생성
- IntelliJ Generate 기능 사용
- 빠르지만 유지보수 시 주의 필요
반드시 기억할 것: hashCode 함께 구현
equals()를 재정의했다면 반드시 함께 구현해야 한다.
@Override
public int hashCode() { ... }
👉 그렇지 않으면 HashMap / HashSet이 정상 동작하지 않는다
가장 흔한 실수: 오버로딩 vs 오버라이딩
public boolean equals(Point p) { ... }
👉 이건 equals 재정의가 아니다
- Object.equals(Object) 오버라이딩이 아님
- 단순히 새로운 메서드 추가 (오버로딩)
결과:
list.contains(new Point(1, 2)) → false
👉 equals가 호출되지 않는다
핵심 요약
- equals는 정해진 패턴으로 구현해야 한다
- 핵심 필드만 비교해야 한다
- 타입, null, 부동소수점 비교 주의
- 가능하면 도구 사용
- equals 재정의 시 hashCode도 반드시 구현
Value 기반 클래스란 무엇인가
Value 기반 클래스(Value-Based Class)는 클래스처럼 생겼지만 int나 String처럼 값으로 동작하는 클래스를 말한다. Effective Java에서는 이를 값 클래스라고 설명하고, DDD에서는 비슷한 개념으로 Value Object라는 표현을 자주 사용한다.
이 둘은 문맥에 따라 약간씩 다르게 쓰이기도 하지만, 핵심은 거의 같다.
- 식별자가 없다
- 불변(immutable)이다
- 객체의 참조가 아니라 내부 상태로 동등성을 판단한다
- 동일한 값을 가진 인스턴스는 서로 교환 가능하다
즉, “이 객체가 누구냐”가 중요한 것이 아니라, “이 객체가 어떤 값을 표현하느냐”가 중요한 클래스다.
값처럼 동작하는 클래스란 무엇인가
보통 일반 클래스는 각 인스턴스가 고유한 개체처럼 다뤄진다. 반면 Value 기반 클래스는 클래스 형태를 하고 있어도, 실제로는 값처럼 다뤄진다.
예를 들어 다음과 같은 것들이 대표적인 값 클래스다.
Point(x, y)Address(country, street, state, zipCode)Name(firstName, middleName, lastName)Money(amount, currency)
이 객체들은 모두 클래스이지만, 우리가 중요하게 보는 것은 객체의 참조값이 아니다. 그 안에 들어 있는 데이터 자체다.
예를 들어 (1, 0) 좌표를 가진 Point 두 개가 있다면, 그 둘은 서로 다른 인스턴스더라도 같은 값이라고 봐야 한다.
마찬가지로 50달러를 표현하는 Money 객체도 참조가 다르더라도 금액과 통화가 같다면 같은 값으로 취급하는 것이 자연스럽다.
Value 기반 클래스의 핵심 특징
1. 식별자가 없다
Entity와 Value Object를 나누는 가장 중요한 기준 중 하나가 바로 식별자다.
- Entity는 식별자가 있다
- Value Object는 식별자가 없다
Value 기반 클래스에는 보통 id 같은 필드가 들어가면 안 된다. 왜냐하면 이 객체를 유일하게 구분하는 기준은 식별자가 아니라 값 그 자체여야 하기 때문이다.
예를 들어 Point(1, 0)은 “이 인스턴스가 몇 번 객체인가”가 중요한 것이 아니라, “좌표가 1, 0인가”가 중요하다.
2. 불변이다
값 객체는 한 번 만들어진 뒤 상태가 바뀌지 않아야 한다. 그래야 equals의 일관성을 보장할 수 있고, 값으로서 안전하게 다룰 수 있다.
즉, 생성자로 값을 받고 나면 그 이후에는 변경할 수 없도록 만들어야 한다.
3. equals, hashCode, toString을 상태 기반으로 구현한다
Value 기반 클래스는 참조 동일성(==)이 아니라 상태 동일성으로 비교해야 한다.
즉, 다음처럼 동작해야 한다.
equals()→ 내부 값이 같으면 truehashCode()→ 내부 값 기준으로 계산toString()→ 내부 값이 잘 드러나도록 표현
4. ==가 아니라 equals()로 비교해야 한다
Value 기반 클래스는 서로 다른 인스턴스라도 같은 값을 가질 수 있다. 따라서 ==로 비교하면 안 되고, 반드시 equals()를 써야 한다.
new Point(1, 0) == new Point(1, 0) // false
new Point(1, 0).equals(new Point(1, 0)) // true
5. 동일한 객체는 상호교환 가능해야 한다
Value 기반 클래스의 중요한 특성 중 하나는 상호교환 가능성이다.
예를 들어 어떤 메서드가 Point(1, 0)을 받아서 동작한다면, 동일한 값을 가진 다른 Point(1, 0)을 넣어도 완전히 똑같이 동작해야 한다.
즉, 값이 같다면 어떤 인스턴스를 넣어도 결과가 같아야 한다. 이게 바로 값 객체가 값처럼 동작한다는 뜻이다.
Entity와 Value Object의 차이
DDD에서도 Value Object는 매우 중요한 개념이다. Entity와 비교하면 차이가 더 분명해진다.
Entity
- 식별자가 있다
- 생명주기를 가진다
- 상태가 바뀔 수 있다
- 동일성은 식별자로 판단한다
Value Object
- 식별자가 없다
- 보통 다른 Entity에 소속되어 사용된다
- 불변인 경우가 많다
- 동일성은 내부 값으로 판단한다
예를 들어 회원은 Entity일 수 있지만, 그 회원이 가진 주소, 이름, 금액 같은 것은 Value Object로 보는 것이 자연스럽다.
Java에서 Value 기반 클래스를 만드는 방법
Java 17 이전이라면 가장 전통적인 방법은 다음과 같다.
- 클래스를
final로 만든다 - 필드를
final로 둔다 - 생성자로 값을 초기화한다
- setter를 두지 않는다
equals,hashCode,toString을 구현한다
예를 들어 Point는 이런 구조에 매우 잘 어울린다.
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
// equals, hashCode, toString 구현
}
이렇게 만들면 Point는 식별자 없이, 오직 x와 y라는 값으로 자신을 표현하는 대표적인 Value 기반 클래스가 된다.
Value 기반 클래스에서 주의할 점
id를 넣지 말아야 한다
Value 기반 클래스에 id를 넣는 순간, 이 객체는 더 이상 값 객체답지 않게 된다.
예를 들어 Money 객체에 moneyId 같은 필드가 들어가고, 그걸 기준으로 equals를 판단하기 시작하면 이 객체는 값이 아니라 사실상 Entity처럼 동작하게 된다.
Value 기반 클래스는 이렇게 사용하면 안 된다.
- 식별자로 비교
- 참조로 비교
- 객체마다 고유해야 한다고 생각
반드시 이렇게 사용해야 한다.
- 값으로 비교
- 같은 값이면 같은 객체로 취급
- 서로 교환 가능
Java 17 이상이라면 Record가 가장 적절하다
Java 17을 사용할 수 있다면 Value 기반 클래스를 만드는 가장 좋은 방법은 Record다.
Record는 값 객체를 만들기 위해 거의 최적화된 문법이다.
public record Point(int x, int y) {
}
이게 끝이다.
이 한 줄만으로도 자바는 컴파일 시점에 다음을 자동으로 만들어 준다.
equals()hashCode()toString()
그리고 Record는 기본적으로 다음 성질을 가진다.
- 불변이다
- 상태 기반 동등성을 가진다
- getter 대신 필드명과 같은 접근 메서드를 제공한다
- 값 객체를 표현하기에 매우 간결하다
즉, Point 같은 Value 기반 클래스는 굳이 직접 equals, hashCode, toString을 작성하지 않아도 Record로 훨씬 자연스럽게 표현할 수 있다.
Record가 Value 기반 클래스에 잘 맞는 이유
1. 불변성 보장
Record는 setter가 없고, 생성 시점에 값이 정해진 뒤 바꿀 수 없다. 그래서 일관성을 지키기 쉽다.
2. 상태 기반 equals 자동 생성
Record는 내부 필드를 기준으로 equals를 자동 구현한다. 즉, Value 기반 클래스가 원하는 동작을 언어 차원에서 지원한다.
3. 코드가 매우 간결하다
클래스, 생성자, getter, equals, hashCode, toString을 일일이 만들 필요가 없다. 그래서 의도가 더 분명하게 드러난다.
4. 값 객체의 본질과 잘 맞는다
Record는 “이 클래스는 데이터를 표현하기 위한 타입이다”라는 의도를 코드 자체로 드러낸다. Value 기반 클래스의 본질과 정확히 맞아떨어진다.
String도 대표적인 Value 기반 클래스다
우리는 흔히 String을 너무 당연하게 써서 특별하게 느끼지 않지만, String 역시 대표적인 값 기반 타입이다.
- 식별자가 없다
- 불변이다
- equals는 내부 문자열 값으로 비교한다
- 같은 문자열은 서로 교환 가능하다
즉, String은 이미 우리가 매일 사용하는 Value 기반 클래스의 대표적인 예다.
그래서 Point, Money, Address 같은 객체도 가능하면 String처럼 생각하면 이해가 쉽다.
“이 객체가 누구냐”가 아니라 “이 객체가 무엇을 표현하느냐”가 중요하다.
정리
Value 기반 클래스는 클래스 형태를 하고 있지만, 실제로는 값처럼 사용되는 타입이다.
핵심 특징은 다음과 같다.
- 식별자가 없다
- 불변이다
- equals, hashCode, toString을 상태 기반으로 구현한다
==가 아니라equals()로 비교한다- 같은 값의 인스턴스는 상호교환 가능하다
그리고 Java 17 이상이라면 이런 클래스를 만들 때 가장 좋은 선택은 Record다.
결국 Value 기반 클래스의 핵심은 이것이다.
참조가 아니라 값이 객체를 대표해야 한다.
이 기준이 명확할수록 equals도 자연스러워지고, 객체 설계 역시 훨씬 단단해진다.
StackOverflowError란 무엇인가
StackOverflowError는 말 그대로 스택(Stack) 메모리가 넘쳐서 더 이상 사용할 수 없을 때 발생하는 에러다.
이 에러를 제대로 이해하려면 먼저 JVM 메모리 구조 중 스택(Stack)과 힙(Heap)의 역할을 구분해야 한다.
스택(Stack)과 힙(Heap)의 차이
1. 스택(Stack)
- 스레드마다 하나씩 존재하는 메모리 공간
- 메서드 호출 시 스택 프레임(Stack Frame)이 쌓인다
- LIFO 구조 (Last In First Out)
👉 쉽게 말하면 “메서드 실행 흐름을 관리하는 공간”
스택 프레임에 들어가는 정보
메서드가 호출될 때마다 하나씩 생성된다.
- 메서드 매개변수
- 지역 변수
- 힙 객체를 가리키는 참조(reference)
- 메서드 실행이 끝난 뒤 돌아갈 위치
👉 즉, 메서드 실행에 필요한 모든 정보
2. 힙(Heap)
- 객체(인스턴스)가 저장되는 공간
- GC(Garbage Collector)가 관리
👉 쉽게 말하면 “실제 데이터(객체)가 존재하는 공간”
StackOverflowError 발생 원리
스택은 무한히 커지는 구조가 아니다. OS와 JVM 설정에 따라 크기가 제한되어 있다.
👉 이 공간에 스택 프레임이 계속 쌓이다가 👉 한계를 넘으면
StackOverflowError 발생
언제 발생하는가
1. 재귀 호출 (Recursion)
가장 대표적인 원인이다.
public void recursive() {
recursive();
}
- 종료 조건 없음
- 계속 자기 자신 호출
- 스택 프레임 계속 쌓임
👉 결국 StackOverflowError 발생
2. 잘못된 equals 구현 (무한 호출)
Effective Java에서 나온 대표 사례다.
상속 구조에서 equals를 잘못 구현하면 서로의 equals를 계속 호출하는 구조가 생긴다.
A.equals(B)
→ B.equals(A)
→ A.equals(B)
→ ...
👉 스택 프레임 무한 증가 👉 StackOverflowError 발생
실제 시나리오 (강의 사례)
- ColorPoint
- SmellPoint (또 다른 하위 클래스)
이 둘이 서로 equals를 호출하면서:
ColorPoint → SmellPoint → ColorPoint → SmellPoint ...
👉 무한 루프 👉 스택 프레임 계속 쌓임 👉 결국 터짐
스택은 어떻게 쌓이는가
스택은 “벽돌처럼” 쌓인다.
[ method C ]
[ method B ]
[ method A ]
- A 호출 → 프레임 생성
- B 호출 → 프레임 추가
- C 호출 → 프레임 추가
👉 계속 쌓이다가 한계 도달하면 터진다
스택 크기
스택 크기는 환경에 따라 다르지만 보통:
- Linux / Mac: 약 1MB
- JVM 옵션으로 조정 가능
스택 사이즈 조정 방법
-Xss1M
-Xss2M
-Xss2048k
예:
java -Xss2M MyApp
⚠️ 중요한 원칙
스택 사이즈를 늘리는 것은 해결책이 아니다
왜냐하면:
- 근본 원인은 코드 문제
- 재귀 종료 조건 없음
- 무한 호출 구조
👉 스택을 늘리면 “시간만 벌 뿐” 결국 터진다
올바른 해결 방법
1. 재귀 종료 조건 추가
if (condition) return;
2. 반복문으로 변경
while (...) {
...
}
3. equals 설계 수정
- 상속 기반 equals 제거
- 컴포지션 사용
👉 이전 Item 10과 연결되는 핵심 포인트
스택 vs 힙 한 줄 정리
| 구분 | 스택 | 힙 |
|---|---|---|
| 역할 | 메서드 실행 | 객체 저장 |
| 구조 | LIFO | 자유 구조 |
| 관리 | 스레드별 | JVM 전체 |
| 오류 | StackOverflowError | OutOfMemoryError |
핵심 요약
- StackOverflowError는 스택 프레임이 넘쳐서 발생한다
- 메서드 호출마다 스택 프레임이 쌓인다
- 재귀나 무한 호출 구조에서 자주 발생한다
- equals 잘못 구현해도 발생할 수 있다
- 스택 크기 조정보다 코드 수정이 우선이다
한 줄 결론
StackOverflowError는 메모리 문제가 아니라 “호출 구조 설계 문제”다
리스코프 치환 원칙(Liskov Substitution Principle, LSP)
리스코프 치환 원칙은 객체지향 설계의 핵심 원칙 중 하나로, SOLID 원칙 중 “L”에 해당한다.
SOLID 원칙 간단 정리
객체지향 설계에서 자주 언급되는 5가지 원칙이다.
- S: 단일 책임 원칙 (Single Responsibility)
- O: 개방-폐쇄 원칙 (Open-Closed)
- L: 리스코프 치환 원칙 (LSP)
- I: 인터페이스 분리 원칙 (ISP)
- D: 의존성 역전 원칙 (DIP)
이 중 리스코프 치환 원칙은 상속과 다형성의 올바른 사용 기준을 정의한다.
리스코프 치환 원칙 정의
하위 클래스의 객체는 상위 클래스의 객체를 대체해도 프로그램의 동작이 변하면 안 된다
조금 더 쉽게 풀면 다음과 같다.
상위 타입을 사용하는 코드는, 하위 타입으로 바꿔도 동일하게 동작해야 한다
핵심 포인트: 문법이 아니라 “의미(semantic)”
리스코프 치환 원칙에서 가장 중요한 키워드는 이것이다.
syntactic(문법)이 아니라 semantic(의미)
❌ 잘못된 이해
Point p = new CounterPoint(1, 0);
- 문법적으로는 문제 없음
- 상속 관계이므로 타입 호환 가능
👉 하지만 이것만으로 충분하지 않다
✔ 올바른 기준
Point로 동작하던 코드가
CounterPoint로 바뀌어도
동일한 의미로 동작해야 한다
👉 이것이 LSP의 핵심이다
직관적인 예시
Bird.fly()
👉 모든 하위 클래스는 날 수 있어야 한다
❌ 잘못된 설계
Penguin extends Bird
- Penguin은 날지 못함
- fly() 호출 시 의미 깨짐
👉 LSP 위반
equals와 리스코프 치환 원칙
이 원칙은 equals() 구현에서 매우 중요하게 드러난다.
문제 상황
Set<Point> set = ...
set.contains(new CounterPoint(1, 0));
기대 결과
true
왜냐하면:
- 좌표가 동일 (1, 0)
- 논리적으로 같은 값
실제 결과 (잘못된 equals)
false
왜 문제가 되는가
Point기준에서는 trueCounterPoint기준에서는 false
👉 같은 데이터를 다르게 판단
👉 상위 타입 기준 동작이 하위 타입에서 깨짐
👉 LSP 위반
문제의 본질
리스코프 치환 원칙은 이렇게 말한다.
“상속은 단순한 코드 재사용이 아니라, 의미를 유지해야 하는 계약이다”
즉,
- 단순히 extends 했다고 해서 올바른 설계가 아니다
- 하위 클래스는 상위 클래스의 “의미”까지 유지해야 한다
equals에서 LSP가 깨지는 이유
특히 이런 코드에서 문제가 발생한다.
if (o.getClass() != this.getClass()) return false;
👉 같은 값이어도 클래스가 다르면 false
결과:
- Point vs CounterPoint → false
- 논리적으로는 같아야 하는데 다르게 판단
👉 의미 깨짐 👉 LSP 위반
올바른 접근 방식
1. equals는 의미 기반으로 구현
- 값이 같으면 같다고 판단
- 타입이 아니라 상태 기준
2. 상속 사용 시 매우 신중해야 한다
특히 다음 경우는 위험하다.
- 필드 추가
- equals 재정의
👉 대부분 문제 발생
3. 컴포지션 사용 권장
class ColorPoint {
private Point point;
}
👉 상속 대신 포함
👉 equals 문제 해결
👉 LSP 유지 가능
한 문장으로 정리
상속은 “is-a 관계”가 아니라 “같은 의미를 유지할 수 있는가”로 판단해야 한다
핵심 요약
- 리스코프 치환 원칙은 SOLID 중 하나
- 상위 타입을 하위 타입으로 대체해도 동일하게 동작해야 한다
- 문법이 아니라 “의미 유지”가 핵심이다
- equals 구현에서 자주 깨진다
- 상속보다 컴포지션이 더 안전한 경우가 많다
한 줄 결론
LSP는 “상속이 가능한가?”가 아니라 “대체해도 의미가 유지되는가?”를 묻는 원칙이다