상위 클래스의 메서드를 하위 클래스에서 재정의하는 것(overriding). 다형성의 실제 동작 원리다.
class Animal { void speak() { System.out.println("..."); } }
class Dog extends Animal {
@Override void speak() { System.out.println("멍멍"); }
}
Animal a = new Dog();
a.speak(); // "멍멍" ← 변수 타입은 Animal이지만 실제 객체가 Dog
동적 바인딩 — 어떻게 실제 타입을 찾는가
컴파일 시점에는 어느 메서드를 부를지 정해지지 않는다. 런타임에 객체의 실제 클래스를 보고 결정한다.
객체 헤더 → 클래스 정보 → 가상 메서드 테이블(vtable)
- speak() → Dog.speak 주소
각 클래스는 자기 메서드 주소 표를 갖고 있고, 호출 시 이 표를 조회한다. 하위 클래스가 재정의했으면 그 표에는 새 주소가 들어 있다.
이 한 겹의 간접 참조 덕분에 다형성이 성립한다.
규칙 세 가지
① 시그니처가 같아야 한다 (이름·매개변수)
② 반환 타입은 같거나 하위 타입 (공변 반환)
③ 접근 제어자를 좁힐 수 없다 (public → protected ✗)
④ 더 넓은 checked exception 을 던질 수 없다
③④가 왜 그런가 — 리스코프 치환 원칙 때문이다. 상위 타입으로 쓰던 코드가 하위 타입으로 바꿔도 문제없어야 하는데, 접근 범위를 좁히거나 새 예외를 던지면 기존 코드가 깨진다.
재정의할 수 없는 것
final 메서드 → 재정의 금지
static 메서드 → 재정의가 아니라 "숨김"(hiding). 정적 바인딩된다
private 메서드 → 애초에 하위에서 보이지 않는다
생성자 → 상속되지 않는다
static 메서드 숨김의 함정
class A { static void f() { print("A"); } }
class B extends A { static void f() { print("B"); } }
A a = new B();
a.f(); // "A" 출력! ← static은 변수의 선언 타입으로 결정된다
static 메서드에 @Override를 붙이면 컴파일 에러가 난다.
@Override를 반드시 붙인다
class Parent { void process(String s) { } }
class Child extends Parent {
void proccess(String s) { } // 오타! 새 메서드를 만든 것
}
@Override가 없으면 컴파일이 통과하고, 런타임에 부모 메서드가 불려
디버깅하기 어려운 버그가 된다. 붙이면 컴파일 시점에 잡힌다.
매개변수 타입을 잘못 쓴 경우도 마찬가지다 —
equals(MyClass o) 는 equals(Object o)의 재정의가 아니라 오버로딩이다.
이것이 equals 구현의 가장 흔한 실수다.
생성자에서 재정의 가능한 메서드를 부르면 안 된다
class Parent {
Parent() { init(); } // 하위가 재정의할 수 있는 메서드 호출
void init() { }
}
class Child extends Parent {
private String name = "기본값";
@Override void init() { System.out.println(name); } // null 출력!
}
초기화 순서 — 부모 생성자가 먼저 실행되고, 그때는 자식의 필드가
아직 초기화되지 않았다. name은 null이다.
Effective Java Item 19가 지적하는 문제로, 실제 프레임워크에서도 종종 발생한다.
상속보다 컴포지션
오버라이딩은 강력하지만 상위 클래스 구현에 강하게 의존하게 만든다. 상위 클래스가 내부적으로 자기 메서드를 부르는 방식이 바뀌면 하위가 깨진다.
❌ class MyList extends ArrayList { … } // addAll이 add를 부르는지에 의존
✅ class MyList { private List delegate; } // 위임. 내부 구현과 무관