| 비교 | 뜻 | 기본 동작 |
|---|---|---|
a == b |
두 참조가 같은 객체를 가리키는가 | 재정의 불가 |
a.equals(b) |
두 객체가 같은 내용인가 | Object.equals 는 == 와 동일. 클래스가 직접 정의해야 의미가 생긴다 |
String, Integer, LocalDate, BigDecimal, 모든 컬렉션은 equals 를 내용 기준으로 정의해 두었습니다. 그래서 "a".equals("a") 는 true 입니다. 우리가 만든 클래스는 정의하기 전까지 == 입니다.
동등성 기준을 무엇으로 할지가 설계 결정입니다. 회원은 id 가 같으면 같은 회원이고(이름은 개명될 수 있음), 좌표는 x·y 가 모두 같아야 같은 점이며, 금액은 값이 같으면 같습니다. "이 객체를 무엇으로 식별하는가" 를 정하고 그 필드만 비교합니다. 이 레슨의 Member 는 id 만, Point 는 x·y 를, record Order 는 모든 필드를 씁니다.
@Override
public boolean equals(Object o) {
if (this == o) return true; // ① 자기 자신이면 바로 true (성능 + 재귀 방지)
if (!(o instanceof Member other)) return false; // ② null 이거나 다른 타입이면 false. instanceof 는 null 에 false 를 준다
return id == other.id; // ③ 식별 필드만 비교. 참조 타입은 Objects.equals(a, b) 로 null 안전하게
}매개변수 타입이 Object 여야 합니다. equals(Member o) 로 쓰면 오버라이드가 아니라 오버로드가 되어 컬렉션은 여전히 Object.equals 를 부릅니다. @Override 를 붙이면 이 실수를 컴파일러가 잡습니다.
규약은 반사성(자기 자신과 같다), 대칭성(a=b 면 b=a), 추이성(a=b, b=c 면 a=c), 일관성(필드가 안 바뀌면 결과도 안 바뀜), null 과는 항상 false 입니다. 관용적 구현을 따르면 자동으로 만족하고, 상속 관계에서 하위 클래스가 필드를 추가하며 equals 를 또 정의할 때만 대칭성이 깨질 수 있습니다.
그런 경우 getClass() != o.getClass() 비교로 "정확히 같은 클래스" 만 같다고 보는 선택지가 있습니다.
HashMap 은 키의 hashCode() 로 버킷 번호를 먼저 정하고, 그 버킷 안에서만 equals 로 찾습니다. 그래서 규약이 이렇습니다.
a.equals(b)가 true 면a.hashCode() == b.hashCode()여야 한다. (역은 성립하지 않아도 된다: 해시가 같아도 다른 객체일 수 있다 = 충돌)
equals 만 만들고 hashCode 를 안 만들면, 내용이 같은 두 객체가 다른 버킷에 들어갑니다. HashSet 은 둘 다 받아 중복이 생기고, map.get(new Point(1,2)) 는 엉뚱한 버킷을 뒤져 null 을 돌려줍니다. 예제 2 가 이것을 그대로 보여줍니다. List 는 해시를 안 쓰니 멀쩡해서 더 발견하기 어렵습니다.
구현 규칙은 하나입니다. equals 에 쓴 필드로만 계산한다. 필드 하나면 Long.hashCode(id), 여럿이면 Objects.hash(a, b, c). equals 에 없는 필드를 해시에 넣으면 "같은데 해시가 다른" 객체가 생깁니다. 해시 충돌은 성능만 떨어뜨리지 정확성은 해치지 않으므로, 완벽한 분산보다 규약 준수가 우선입니다.
객체를 HashSet 에 넣은 뒤 해시에 쓰인 필드를 바꾸면 그 객체는 원래 버킷에 남아 있는데 새 해시는 다른 버킷을 가리키므로 contains 가 false 가 되고 remove 도 안 됩니다. 컬렉션 레슨의 실수 2 입니다. 해법은 둘 중 하나입니다. 키를 불변 필드(id)로만 식별하거나(이 레슨의 Member 는 이름을 바꿔도 안전), 키 클래스 자체를 불변으로 만듭니다(record).
public record Order(long no, String customer, BigDecimal amount, LocalDate date) { }
// 자동 생성: 모든 필드 기준 equals·hashCode, "Order[no=1, customer=kim, ...]" 형태 toString, 접근자 no(), customer() ..."모든 필드가 식별자인 불변 값" 이면 record 가 정답입니다. DTO, 좌표, 금액과 통화, 조회 결과 행이 여기 해당합니다. 반대로 Member 처럼 일부 필드만 식별자이고 나머지는 바뀌는 엔티티는 일반 클래스로 두고 직접 정의합니다.
record 의 자동 equals 는 필드의 equals 를 그대로 쓰므로 BigDecimal 필드는 1000 과 1000.0 을 다르게 봅니다(예제 3). 금액이 식별에 들어가면 스케일을 통일하거나 compareTo 기준의 별도 메서드를 두어야 합니다.
기본 toString 은 클래스명@해시코드16진수 라 정보가 없습니다. 오버라이드하면 문자열 결합, printf, 로그 프레임워크, 디버거 변수 창, 컬렉션 출력([Member{...}, ...]) 전부에 반영됩니다. 규칙은 식별에 필요한 필드를 넣고 민감 정보(비밀번호, 주민번호, 카드번호)는 빼는 것입니다.
실무에서 toString 에 비밀번호가 들어가 로그 파일로 유출되는 사고가 실제로 납니다. record 는 모든 필드를 넣으므로 민감 필드가 있는 record 는 toString 을 다시 정의합니다.
Comparable<T> |
Comparator<T> |
|
|---|---|---|
| 위치 | 클래스 안 compareTo(T o) |
클래스 밖, 별도 객체 |
| 개수 | 클래스당 하나(자연 순서) | 필요한 만큼 |
| 쓰이는 곳 | Collections.sort, list.sort(null), TreeSet 기본 |
list.sort(cmp), new TreeSet<>(cmp), sorted(cmp) |
| 예 | String(사전순), Integer, LocalDate |
금액 내림차순, 이름 다음 날짜 등 |
compareTo 는 음수(앞), 0(같음), 양수(뒤)를 돌려줍니다. 필드를 빼서 만들지 마세요. this.value - o.value 는 큰 양수에서 큰 음수를 빼면 int 가 넘쳐 부호가 뒤집힙니다(예제 6). Integer.compare, Long.compare, a.compareTo(b) 를 씁니다.
Comparator 는 조합이 핵심입니다.
Comparator.comparing(Order::amount).reversed().thenComparing(Order::no) // 금액 내림차순, 같으면 번호 오름차순
Comparator.comparing(Order::customer, Comparator.nullsLast(Comparator.naturalOrder())) // null 고객명은 마지막으로comparing 에 키 추출 함수를 주고 reversed, thenComparing, nullsFirst/nullsLast 를 이어 붙입니다. 같은 클래스에 정렬 기준이 여럿이면 Comparator 상수로 이름을 붙여 둡니다(Order.BY_AMOUNT_DESC).
HashSet 은 equals/hashCode, TreeSet 은 compareTo(또는 생성자에 준 Comparator) 로 "같은 원소" 를 정합니다. 금액 기준 Comparator 로 만든 TreeSet 에 금액이 같은 주문 두 건을 넣으면 하나가 조용히 사라집니다(예제 5).
BigDecimal 의 1.0 과 1.00 은 equals 가 false 인데 compareTo 가 0 이라, HashSet 에는 둘 다 들어가고 TreeSet 에는 하나만 들어갑니다.
규약은 "compareTo 가 0 이면 equals 도 true 가 되게 하라(권장)" 이지만 BigDecimal 이 이를 어기는 대표 사례입니다. 금액 비교는 항상 compareTo() == 0 으로 합니다.