Kotlin/코틀린 일반 클래스에서 hashCode() 제대로 구현하기
✨ 개요
일반 클래스에서 hashCode() 제대로 구현하기 — equals 계약, 충돌 최소화, 실전 레시피
data class는 equals/hashCode를 자동 생성하지만, 일반 클래스(비–data) 에서는 우리가 직접 hashCode()를 구현해야 할 때가 많습니다.
이 글은 언제/왜/어떻게 hashCode()를 구현해야 하는지, 실전 레시피와 주의점을 한 번에 정리합니다.
1. 요약
- 계약:
a == b이면 반드시a.hashCode() == b.hashCode()여야 합니다. (역은 필수 아님) - 기본값: 아무것도 오버라이드하지 않으면
Any.hashCode()→ 객체 식별자 기반(참조 동일성) 입니다. 값 동등성을 원하면 반드시 재정의. - 핵심 필드만 사용:
equals에 참여하는 불변 필드들로만 계산하세요. - 배열/부동소수/Null 등 특수 타입은 전용 처리 필요 (
contentHashCode(),toBits(), null-safe). - 해시 캐싱은 진짜 불변 객체에만 고려.
2 equals/hashCode 계약 (필수 이해)
a == b⇒a.hashCode() == b.hashCode()(같은 객체면 같은 해시)- 같은 해시라고 해서 반드시
==는 아님(충돌 가능). hashCode()는 실행 중 일관성이 있어야 합니다:equals결과가 변하지 않았다면 해시도 변하면 안 됩니다.
→ 키로 쓰는 객체를 컬렉션에 넣은 후 필드를 바꾸지 마세요.
3 언제 오버라이드해야 하나?
- 이 객체를
HashSet,HashMap의 키로 쓰거나, - 값 동등성(내용이 같으면 같다고 보고 싶을 때)을 정의한 경우.
- 그렇지 않고 식별성(참조) 기준이면 기본 구현(오버라이드 X)으로 충분.
값 동등성이 목적이라면
data class가 기본 해답입니다. 다만 맞춤 동등성(일부 필드만 비교·계산 등)이 필요하면 일반 클래스에서 수동 구현이 낫습니다.
4. 기본 레시피(가장 흔한 패턴)
class User(
val id: Long,
val email: String?,
val name: String,
val flags: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is User) return false
return id == other.id &&
email == other.email &&
name == other.name &&
flags == other.flags
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + (email?.hashCode() ?: 0)
result = 31 * result + name.hashCode()
result = 31 * result + flags
return result
}
}
- 소수 배수(31) 를 곱하며 누적 → 분포 개선(전통적인 자바 관례).
- null은 ?: 0으로 안전 처리.
- equals에 포함한 필드만 해시에 포함하세요.
5. 타입별 처리 요령
| 타입 | 해시코드 계산 |
|---|---|
Int, Boolean, Char |
그대로 사용 (true→1, false→0 권장) |
Long |
id.hashCode()(JVM: (id xor (id ushr 32)).toInt()) |
Float |
value.toBits() 사용(예: 31*.. + value.toBits()) |
Double |
value.toBits().hashCode() |
String, List, Set, Map |
각 타입의 hashCode()는 내용 기반이라 그대로 사용 OK |
| 배열(Array, IntArray 등) | contentHashCode() / contentEquals() 필수 (기본 hashCode()는 참조 기반) |
Enum |
기본 hashCode() 사용 |
Nullable |
x?.hashCode() ?: 0 |
| 복합 객체 | 그 객체의 동등성/해시 구현에 신뢰 기반 |
override fun hashCode(): Int {
var r = 1
r = 31 * r + items.contentHashCode() // ★ 배열은 contentHashCode
r = 31 * r + (tag?.hashCode() ?: 0)
return r
}
// 부동소수
val fBits = price.toBits()
result = 31 * result + fBits
6. 자바 유틸을 써도 되나? (Objects.hash)
JVM 한정으로 java.util.Objects.hash(a, b, c)를 써서 간단히 만들 수 있지만,
- varargs로 배열 생성 비용(GC)이 있고,
- 배열의 내용 해시가 필요한 경우엔 또 별도 처리해야 합니다.
- 성능/명확성 관점에서 직접 31 패턴으로 합성하는 걸 권장합니다.
7. 흔한 실수 체크리스트
- equals만 오버라이드하고 hashCode를 빼먹음 → HashMap/HashSet 오동작
- equals와 hashCode에 서로 다른 필드 집합 사용
- 배열을 contentHashCode() 대신 기본 hashCode()로 사용
- Double/Float를 직접 캐스팅 → toBits()로 일관된 규칙 사용
- 키로 쓰는 객체를 컬렉션에 넣은 뒤 필드를 변경
- 가변 객체에 해시 캐싱 도입
8. 확장 유틸
/** null-safe hash */
inline fun <T> T?.h(): Int = this?.hashCode() ?: 0
/** 31 곱셈 합성 */
fun combineHash(vararg parts: Int): Int {
var r = 1
for (p in parts) r = 31 * r + p
return r
}
// 사용 예
override fun hashCode(): Int = combineHash(
id.hashCode(),
email.h(),
name.hashCode(),
flags
)