`hashCode()`와 `equals()`는 Object 클래스에 선언된 메서드며, 모든 객체가 사용할 수 있다.
두 메서드는 같이 오버라이딩해야 하는 이유가 있다. 그 이유를 알아보자.
일단 `hashCode()`와 `equals()`부터 알아보자.
JDK 21 기준
public class Object {
@IntrinsicCandidate
public native int hashCode();
public boolean equals(Object obj) {
return (this == obj);
}
}
hashCode()
객체의 해시 값을 반환한다.
메모리 주소와 무관한 난수 값을 사용해서 해시를 생성하는 해시 함수다.
해시가 뭘까? 해시 함수부터 알아보자.
해시 함수
임의의 길이의 데이터를 입력으로 받아, 고정된 길이의 해시 값(해시 코드)을 출력하는 함수
public int testHashCode() {
...
}
여기서 고정된 길이는 저장 공간의 크기를 뜻한다.
- 아래 메서드의 반환 값처럼 4byte(`int`)를 차지하는 고정된 길이
좋은 해시 함수일수록 해시 충돌 확률이 낮아진다.
주요 특징
- 일관성 : 동일한 입력에 대해서는 항상 동일한 결과가 나온다.
- 단방향성 : 해시 값으로부터 원본 데이터를 유추하기 어렵다. (사실상 복호화 불가능)
주요 용도
- 데이터 무결성 검사 : 원본 데이터의 해시 값을 미리 저장해두고, 데이터가 변경되었을 때 해시 값을 비교해 데이터의 변조 여부를 확인한다.
- 자료구조 : 해시 테이블과 같은 해시 기반 자료구조에서 데이터를 효율적으로 저장하고 검색하기 위해 사용된다.
- 암호화 : 비밀번호 등 중요한 정보를 안전하게 저장하기 위해 사용된다. 원본 비밀번호 대신 해시 값을 저장하고, 로그인 시 입력된 비밀번호의 해시 값과 저장된 해시 값을 비교한다.
해시는 해시 함수를 통해 반환되는 결과이다.
equals()
문자열 비교에 자주 사용해서 문자열을 비교하는 메서드라고 알고 있을 수도 있겠지만,
엄밀히 말하면 객체 비교에 사용하는 메서드다.
객체 비교는 두 가지 방식이 있다.
- 동일성(Identity) : 두 객체가 동일한지 비교한다. (두 객체가 동일한 메모리 주소를 참조하는지 비교)
- 비교 연산 : `==`
- 동등성(Equality) : 두 객체가 논리적으로 동일한 값(내용)을 가지는지 비교한다.
- 비교 연산 : `equals()`
public static void main(String[] args) {
Member member1 = new Member("test");
Member member2 = member1;
Member member3 = new Member("test");
// 동일성 비교
boolean identity = member1 == member2;
System.out.println(identity); // true
// 동등성 비교
boolean equals = member1.equals(member3);
System.out.println(equals); // true
}
static class Member {
private String name;
public Member(String name) {
this.name = name;
}
@Override
public boolean equals(Object object) {
Member member = (Member) object;
return name.equals(member.name);
}
}
동일성 비교
member1과 member2가 동일한 인스턴스인지 비교한다.
member2에 member1의 인스턴스를 대입했으니 `true`가 된다.
동등성 비교
member1과 member3이 논리적으로 동일한지 비교한다.
Member 클래스는 `equals()`를 오버라이딩하여 name이 같으면 같은 객체라고 판단한다.
member1과 member3의 name이 같으므로 `true`가 된다.
String의 equals()
문자열 비교에 흔히 사용하는 메서드다.
Object 클래스의 equals()를 오버라이딩했다.
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
return (anObject instanceof String aString)
&& (!COMPACT_STRINGS || this.coder == aString.coder)
&& StringLatin1.equals(value, aString.value);
}
동일성 비교
`if (this == anObject)`
두 문자열이 같은 인스턴스인지 비교한다.
동등성 비교
`StringLatin1.equals(value, aString.value)`
두 객체의 값(문자열)을 비교한다.
// 문자열 비교
String str1 = "hello";
String str2 = "hello";
String str3 = new String("hello");
System.out.println(str1 == str2); // true
System.out.println(str1 == str3); // false
System.out.println(str1.equals(str3)); // true
자바에서는 일반적으로 객체는 `new` 키워드를 사용해 인스턴스를 생성한다.
하지만 String 클래스는 조금 다른데, 2가지의 객체 생성 방식이 있다.
- new 키워드 사용 : `String str3 = new String(”hello”);`
- 문자열 리터럴 : `String str1 = "hello";`
`new` 키워드를 사용하는 방식은 일반적으로 사용되지 않고, 대부분 문자열 리터럴(literal) 방식을 사용한다.
리터럴 방식으로 생성한 `hello` 문자열은 String Constant pool(문자열 상수 풀)이라는 특별한 메모리 영역에 저장되어 JVM 종료까지 하나의 `hello` 문자열 인스턴스만 존재한다.
때문에 같은 `hello` 문자열을 리터럴 방식으로 생성한 `str1`와 `str2`는 같은 인스턴스를 참조하게 되고, 동일성 비교했을 때 true인 것이다.
하지만 `new` 키워드를 사용하면 String Constant pool에 저장되지 않고, 다른 인스턴스처럼 Heap 영역에 저장된다.
따라서 `str1`와 `str3`은 같은 `hello`문자열이더라도 다른 인스턴스를 참조하고 있기 때문에 false이다.
그래서 hashCode()와 equals()를 같이 오버라이딩해야 하는 이유는 뭘까?
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
public V get(Object key) {
Node<K,V> e;
return (e = getNode(key)) == null ? null : e.value;
}
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & (hash = hash(key))]) != null) {
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
}
return (e = getNode(key)) == null ? null : e.value;
전달 받은 key를 `getNode()`로 전달한다.
final Node<K,V> getNode(Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n, hash; K k;
if (... && (hash = hash(key))) != null) {
...
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
...
}
return null;
}
hash = hash(key)
`hash()`로 해시를 생성하여 hash 변수에 대입한다.
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
해시 값을 생성하는 `hash()`에서 `key.hashCode()`가 사용된다.
- 해시 테이블의 해시 인덱스를 생성하는 용도
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
객체 간 비교한다.
`key` : 클라이언트에서 값을 찾으려고 전달한 키
`hash` : `key`로 생성한 해시
`e` : 버킷에 담겨 있는 노드
`k` : 버킷에 담겨 있는 노드의 키
1. 버킷에 담겨 있는 노드(`e`)와 `key`로 생성한 해시(`hash`)를 비교
`e.hash == hash`
2. 버킷에 담겨 있는 노드의 키(`k`)와 `key`를 동일성 비교
`(k = e.key) == key`
3. `key`가 null이 아니라면, `key`와 `k`를 동등성 비교
`key != null && key.equals(k)`
여기서 동등성 비교인 `equals()`가 사용된다. 여기까지 진행됐다는 것은 두 객체의 해시가 같다는 뜻이다. 그 다음 객체 간 비교할 수 있는 수단이 필요한데, 그 수단이 `equals()`인 것이다.
결론
`hashCode()` : 해시 기반 자료구조에서 객체를 찾기 위한 힌트 (버킷 입장권 느낌)
`equals()` : 두 객체가 내용적으로 같은지 비교 (논리적으로 동등한지)
해시 충돌이 적을 수록 좋은 해시 함수지만 해시의 범위에는 한계가 있고, 객체의 수가 많아질수록 해시 충돌 가능성이 높아져 해시 값이 같은 두 객체가 나타날 수 있다.
그때 해시 값이 같은 두 객체 간 비교에 `equals()`로 논리적으로 동등한지 판단해야 한다.
따라서 `hashCode()`와 `equals()`를 같이 오버라이딩해야 해시 기반 자료구조(ex. 컬렉션 프레임워크)에서 객체 간 논리적 동등성이 일치한다.
`hashCode()`를 항상 오버라이딩해야 하는 것은 아니다. 해시 기반 자료 구조를 사용할 경우에만 `hashCode()`와 `equals()`를 같이 오버라이딩하면 될 것 같다.
직접 오버라이딩하는 것은 쉽지 않기 때문에 IDE의 도움을 받아도 좋을 것 같다.

참고
https://jiwondev.tistory.com/113
'Java' 카테고리의 다른 글
| ConcurrentHashMap의 동작원리 (0) | 2025.10.28 |
|---|---|
| 제네릭 타입 소거 (Type Erasure) (1) | 2025.07.03 |
| ArrayList는 왜 제네릭 타입 E[]가 아닌 Object[]으로 요소를 관리할까? (1) | 2025.06.12 |