equals를 override하면 hashCode도 해야 하는 이유 — 상속과 다형성의 계약
해시맵에 객체를 넣었다. 같은 키로 조회했는데 null이 돌아온다. equals()로 비교하면 true인데. 원인은 hashCode()를 함께 override하지 않았기 때문이다.
쉽게 말하면: HashMap은 객체를 넣을 때 hashCode()로 "몇 번 서랍에 넣을지"를 결정한다. 그런데 equals()는 "같은 사람이야"라고 하는데, hashCode()는 "다른 서랍 번호"를 돌려준다 — 서랍이 안 맞으니 찾을 수 없다. 이것이 equals와 hashCode의 계약 위반이다.
이 현상은 다형성(polymorphism)과 동적 디스패치(dynamic dispatch)의 결과다. 이 글은 상속, override, Object 클래스의 계약을 하나씩 풀어간다.
상속 — extends와 단일 상속
Java는 클래스 상속에 단일 상속(single inheritance)만 허용한다. (JLS §8.1.4) extends 뒤에 클래스는 하나만 올 수 있다.
// Java 25
class Animal {
String name;
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + "이(가) 먹습니다.");
}
}
class Dog extends Animal {
public Dog(String name) {
super(name); // 부모 생성자 호출 (반드시 첫 줄)
}
public void bark() {
System.out.println(name + ": 멍멍!");
}
}
// Java 25
Dog dog = new Dog("레온");
dog.eat(); // 상속받은 메서드
dog.bark(); // Dog의 메서드
레온이(가) 먹습니다.
레온: 멍멍!
단일 상속을 선택한 이유는 C++의 다중 상속이 만든 문제(다이아몬드 문제, 복잡한 초기화 순서)를 피하기 위해서다. 다중 타입 상속이 필요하면 interface를 쓴다 (장 07).
다형성(polymorphism) — 부모 타입으로 자식 객체 참조
다형성은 부모 타입의 변수가 자식 객체를 참조할 수 있는 능력이다. (JLS §4.8)
// Java 25
Animal a = new Dog("레온"); // 업캐스팅 (implicit)
a.eat(); // OK: Animal의 eat()
// a.bark(); // 컴파일 에러: Animal 타입에는 bark()가 없음
변수 타입이 Animal이므로 컴파일러는 Animal에 정의된 메서드만 호출을 허용한다. 하지만 런타임에 eat()가 실행될 때, 실제 객체가 Dog이고 Dog가 eat()를 override했다면 — Dog의 eat()가 실행된다.
동적 디스패치(dynamic dispatch) — 컴파일 타임 타입 vs 런타임 타입
flowchart TD
V["변수 a<br/>컴파일 타임 타입: Animal"] -->|"참조"| O["객체 Dog<br/>런타임 타입: Dog"]
O -->|"eat() 호출 시<br/>런타임에 Dog.eat() 선택"| D["동적 디스패치<br/>실제 객체의 메서드 실행"]
// Java 25
class Animal {
public void describe() {
System.out.println("동물입니다.");
}
}
class Dog extends Animal {
@Override
public void describe() {
System.out.println("강아지입니다.");
}
}
Animal a = new Dog();
a.describe(); // "강아지입니다." — Dog의 describe() 실행 (동적 디스패치)
컴파일 타임에는 a.describe()가 Animal.describe()를 가리키는 것처럼 보이지만, 런타임에 JVM은 실제 객체(Dog)의 describe()를 실행한다. 이것이 동적 디스패치다.
동적 디스패치는
invokevirtual바이트코드로 구현된다. JVM이 객체의 실제 클래스를 확인하고 해당 클래스의 메서드 테이블(vtable)에서 메서드를 찾는다. 반면static메서드와private메서드는 컴파일 타임에 결정된다(invokestatic,invokespecial). (JVM Spec §6.5)
override vs overload — 이름이 비슷하지만 완전히 다르다
override(재정의)는 부모 클래스의 메서드를 하위 클래스에서 같은 시그니처(이름 + 매개변수 목록)로 다시 정의하는 것이다. 동적 디스패치가 적용된다.
overload(중복 정의)는 같은 이름의 메서드를 다른 매개변수 목록으로 여러 개 정의하는 것이다. 컴파일 타임에 호출할 메서드가 결정된다.
// Java 25
class Calculator {
// overload: 같은 이름, 다른 매개변수
public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
// 이것도 overload (매개변수 수가 다름)
public int add(int a, int b, int c) { return a + b + c; }
}
class SuperCalculator extends Calculator {
// override: 부모의 add(int, int)를 재정의
@Override
public int add(int a, int b) {
return super.add(a, b) + 1; // super로 부모 메서드 호출
}
}
| 구분 | override | overload |
|---|---|---|
| 관계 | 부모↔자식 (상속) | 같은 클래스 내 |
| 시그니처 | 동일 (매개변수 같음) | 다름 (매개변수 다름) |
| 반환 타입 | 같거나 하위 타입(covariant return) | 상관 없음 |
| 디스패치 | 런타임 (동적) | 컴파일 타임 (정적) |
@Override 어노테이션 |
권장 (컴파일러 검증) | 해당 없음 |
@Override어노테이션을 항상 붙인다 — 오타로 인해 override가 안 되고 새 메서드가 만들어지는 버그를 컴파일 타임에 잡을 수 있다. (JLS §9.6.4.4)
Object 클래스 — 모든 클래스의 조상
Java의 모든 클래스는 명시적으로 extends를 하지 않아도 암묵적으로 Object를 상속한다. (JLS §4.3.2) Object는 모든 객체가 가져야 할 핵심 메서드를 정의한다.
| 메서드 | 기본 동작 | override 시점 |
|---|---|---|
equals(Object) |
참조 비교 (==) |
값 비교로 override 필수 |
hashCode() |
객체 주소 기반 정수 | equals override 시 반드시 함께 |
toString() |
ClassName@hashCode |
디버깅/로깅을 위해 override 권장 |
getClass() |
런타임 클래스 정보 | override 불가 (final) |
equals와 hashCode의 계약 — 이 글의 출발점
equals()를 override하면 반드시 hashCode()도 override해야 한다. (Object.hashCode API 계약)
계약:
a.equals(b)가true이면a.hashCode() == b.hashCode()여야 한다.
이 계약을 어기면 HashMap, HashSet, ConcurrentHashMap 등 해시 기반 컬렉션이 깨진다.
// Java 25 — equals만 override (hashCode 누락) → 버그
class Person {
private final String name;
public Person(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person p)) return false;
return name.equals(p.name);
}
// hashCode() override 누락! → 기본 hashCode는 주소 기반
public static void main(String[] args) {
var set = new java.util.HashSet<Person>();
set.add(new Person("Alice"));
System.out.println(set.contains(new Person("Alice"))); // false!
// equals는 true지만 hashCode가 달라서 다른 버킷을 찾음
}
}
올바른 구현:
// Java 25 — equals + hashCode 함께 override
class Person {
private final String name;
public Person(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person p)) return false;
return name.equals(p.name);
}
@Override
public int hashCode() {
return name.hashCode(); // equals에서 사용하는 필드로 hashCode 계산
}
}
Java 16+에서는
record를 쓰면equals/hashCode를 컴파일러가 자동 생성한다. 값 기반 비교가 필요한 데이터 객체는 record를 쓰는 것이 가장 안전하다.
instanceof — 타입 확인과 패턴 매칭
// Java 25
Object obj = "Hello";
if (obj instanceof String) {
String s = (String) obj; // 명시적 캐스트
System.out.println(s.length());
}
// pattern matching (Java 16+, JEP-394)
if (obj instanceof String s) {
System.out.println(s.length()); // 캐스트 없이 바로 사용
}
instanceof 패턴 매칭은 타입 확인과 캐스트를 한 번에 수행한다. (JEP-394)
super — 부모 참조
// Java 25
class Parent {
void greet() { System.out.println("Parent.greet()"); }
}
class Child extends Parent {
@Override
void greet() {
super.greet(); // 부모의 greet() 호출
System.out.println("Child.greet()");
}
}
new Child().greet();
Parent.greet()
Child.greet()
super.method()는 부모 클래스의 메서드를 호출한다. 동적 디스패치를 우회하여 컴파일 타임에 부모 메서드를 지정한다(invokespecial).
왜 단일 상속인가 — 다이아몬드 문제
C++의 다중 상속은 다이아몬드 문제(diamond problem)를 일으킨다:
A
/ \
B C
\ /
DB와 C가 모두 A의 메서드를 override하면, D는 어느 것을 상속받는가? Java는 이 문제를 원천 차단하기 위해 클래스 상속을 하나로 제한했다. 다중 타입 상속이 필요하면 interface의 default method를 사용한다(장 07).
interface의 default method가 여러 개 충돌할 때도 Java는 규칙을 가지고 있다: "가장 구체적인(most specific) 인터페이스의 구현이 이긴다." 충돌을 해결할 수 없으면 컴파일 에러. (JLS §9.4.1)
상속보다 조합(composition)을 우선하라
상속은 강력하지만 남용하면 결합도가 높아진다. "is-a" 관계가 명확할 때만 상속을 쓰고, "has-a" 관계라면 조합(composition)을 쓴다.
// Java 25 — 상속의 남용 (잘못된 예)
class Stack<E> extends ArrayList<E> {
public void push(E item) { add(item); }
public E pop() { return remove(size() - 1); }
}
// 문제: Stack이 ArrayList의 모든 메서드(add(index), removeRange 등)를 노출
// 사용자가 스택의 LIFO 규칙을 우회할 수 있음
// 조합 (올바른 예)
class Stack<E> {
private final List<E> items = new ArrayList<>(); // 내부 구현으로 숨김
public void push(E item) { items.add(item); }
public E pop() {
if (items.isEmpty()) throw new IllegalStateException("빈 스택");
return items.remove(items.size() - 1);
}
public int size() { return items.size(); }
}
// 외부에 push/pop/size만 노출. ArrayList의 불필요한 메서드는 숨겨짐
Effective Java(Item 18)의 원칙: "상속보다 조합을 우선하라." 상속은 부모 클래스의 구현 세부사항에 의존하게 만든다. 부모가 변경되면 자식이 깨진다. 조합은 캡슐화를 유지하면서 기능을 재사용한다.
요약 — 이 글의 결론
- 동적 디스패치가 다형성의 핵심이다. 부모 타입 변수로 자식 객체를 참조할 때, 메서드 호출은 런타임에 실제 객체의 메서드로 연결된다. 이것이 "코드는 변경하지 않고 새 하위 클래스만 추가하면 동작이 바뀐다"는 OCP(Open-Closed Principle)의 기반이다.
- override와 overload는 완전히 다르다. override는 런타임 디스패치, overload는 컴파일 타임 결정.
@Override어노테이션을 항상 붙여 실수를 방지한다. equals를 override하면hashCode도 반드시 함께. 이 계약을 어기면HashMap,HashSet이 조용히 실패한다 — 컴파일 에러도 예외도 없이 잘못된 결과를 반환한다.Object는 모든 클래스의 조상이다.equals,hashCode,toString은 용도에 맞게 override한다. 단,getClass()는final이라 override 불가.- Java의 단일 상속은 다이아몬드 문제를 원천 차단한다. 다중 타입 필요성은 interface로 해결한다.
생각해 볼 문제
Animal a = new Dog();에서a.getClass().getName()의 결과는 무엇인가? 왜 그런가?equals의 대칭성 계약:a.equals(b)가true이면b.equals(a)도true여야 한다. 이것을 위반하는 코드를 작성할 수 있는가? (예:Person이Employee를 상속할 때)record Person(String name)은equals/hashCode를 자동 생성한다. record의hashCode가 어떤 필드를 사용하는지 API 문서에서 확인해 보자.- 부모 클래스의
static메서드를 자식에서 같은 이름으로 정의하면 override인가 hiding인가? 동적 디스패치가 적용되는가? protected메서드를 다른 패키지의 하위 클래스에서 호출할 때의 제약을 설명하라. (같은 인스턴스여야 하는가?)
참고
- JLS §8.4.8 - Inheritance, Overriding, Hiding - 접근 2026-07-10
- JLS §4.3.2 - The Class Object - 접근 2026-07-10
- Object.hashCode API (Java 25) - 접근 2026-07-10
- Object.equals API (Java 25) - 접근 2026-07-10
- JEP-394: Pattern Matching for instanceof - 접근 2026-07-10
- JVM Spec §6.5 - invokevirtual - 접근 2026-07-10
'Develop Artifacts > Java' 카테고리의 다른 글
| Java - 08. record sealed enum (0) | 2026.07.12 |
|---|---|
| Java - 07. interface and abstract (0) | 2026.07.12 |
| Java - 05. class and object (0) | 2026.07.12 |
| Java - 04. array and string (0) | 2026.07.12 |
| Java - 03. operator control (0) | 2026.07.12 |
