11장에서 PhoneRatePolicy에게 calculateFee를 보내면 실제로는 감싼 구현이 실행됐다. 이걸 가능하게 한 메커니즘이 다형성 — 하나의 메시지가 수신자의 실제 타입에 따라 서로 다른 메서드로 이어지는 것이다. 이 장은 그 이음이 코드 수준에서 어떻게 일어나는지를 연다. 예제는 성적을 매기는 Lecture와, 등급까지 추가하는 GradeLecture다.

업캐스팅과 동적 바인딩

부모 타입 변수에 자식 인스턴스를 담는 것이 업캐스팅이다. 컴파일러는 변수의 타입(Lecture)만 보고 허용 여부를 판단하지만, 실행 시점에 실제로 실행되는 메서드는 담긴 객체의 타입(GradeLecture)이 정한다. 이 실행 시점 결정이 동적 바인딩이다.

Lecture lecture = new GradeLecture(...); // 업캐스팅 — 컴파일러는 Lecture 로 본다
lecture.evaluate();                      // 동적 바인딩 — 실제론 GradeLecture.evaluate 실행

컴파일 시점 타입과 실행 시점 타입이 다를 수 있다는 것, 그리고 메서드를 고르는 쪽은 후자라는 것 — 이 둘이 다형성의 전부다. 함정: 정적 메서드나 필드는 동적 바인딩되지 않고 컴파일 시점 타입으로 묶인다. 다형성은 인스턴스 메서드에서만 일어난다.

메서드 탐색은 self 에서 시작한다

메시지를 받으면 런타임은 메서드를 어디서 찾을까. 규칙은 하나다. 언제나 메시지를 받은 객체(self)의 실제 클래스에서 시작해, 없으면 부모로 올라간다. super는 이 시작점을 부모로 옮기는 예외다.

public class Lecture {
    public String evaluate() {
        return "Pass:" + passCount() + " Fail:" + failCount();
    }
    // passCount(), failCount() ...
}
 
public class GradeLecture extends Lecture {
    @Override
    public String evaluate() {
        return super.evaluate() + ", " + gradesStatistics(); // 부모 결과에 등급 통계를 더한다
    }
    // gradesStatistics() ...
}
flowchart TD
    M["lecture.evaluate() 수신<br/>self = GradeLecture 인스턴스"]
    M --> G["GradeLecture 에서 evaluate 찾음 → 실행"]
    G --> S["super.evaluate() → 부모 Lecture.evaluate 실행"]
    S --> P["passCount() 호출 — 다시 self(GradeLecture)부터 탐색"]
    P --> R["GradeLecture 에 없으면 Lecture 의 passCount 실행"]

핵심은 super.evaluate() 안에서 passCount()를 부를 때다. 이 호출은 Lecture에서 시작하지 않는다 — 여전히 self(GradeLecture)부터 다시 탐색한다. 그래서 만약 GradeLecturepassCount()를 오버라이드했다면, 부모의 evaluate 안에서 불렸더라도 자식의 passCount()가 실행된다. 이것이 self 참조의 힘이자, 앞 장에서 본 템플릿 메서드가 작동하는 원리다. 부모의 골격이 자식이 채운 메서드를 호출할 수 있는 건 탐색이 늘 self에서 시작하기 때문이다. 판단 기준: 부모 메서드 안의 this 호출은 컴파일러가 가리키는 부모가 아니라 실행 중인 실제 객체를 가리킨다. 이 사실을 놓치면 “왜 부모 코드가 자식 메서드를 부르지?”에서 길을 잃는다.

상속의 본질은 타입 계층이다

10장은 상속을 코드 재사용의 도구로 썼고, 그 결합의 함정을 봤다. 이 장은 상속의 다른 얼굴을 보여 준다. 다형성이 성립하려면 GradeLectureLecture로 취급될 수 있어야 한다 — 즉 타입 계층이 필요하다.

List<Lecture> lectures = List.of(new Lecture(...), new GradeLecture(...));
for (Lecture each : lectures) {
    System.out.println(each.evaluate()); // 원소마다 실제 타입의 evaluate 가 실행된다
}

이 반복문은 각 원소가 무슨 서브클래스인지 묻지 않는다. 모두 Lecture라는 타입으로 다루고, 실행할 메서드는 동적 바인딩이 고른다. 여기서 재사용된 것은 부모의 코드가 아니라 부모라는 타입이다. 판단 기준: 상속을 쓸 때 물어야 할 진짜 질문은 “코드를 재사용하고 싶은가”가 아니라 “자식을 부모 타입으로 다뤄도 되는가(치환 가능한가)“다. 전자만 노린 상속은 10장의 취약함으로 이어지고, 후자를 만족하는 상속만이 다형성이라는 열매를 맺는다. 이 치환 가능성의 조건 — 서브클래싱과 서브타이핑의 갈림길이 바로 다음 장의 주제다.

정리

  • 다형성 = 업캐스팅(부모 타입으로 담기) + 동적 바인딩(실제 타입이 메서드를 고르기).
  • 메서드 탐색은 언제나 self(실제 객체)에서 시작해 부모로 올라간다. super만 시작점을 부모로 옮긴다.
  • 부모 메서드 안의 this/암묵적 호출도 self부터 다시 탐색한다 — 템플릿 메서드가 작동하는 원리.
  • 상속의 본질은 코드 재사용이 아니라 타입 계층이다. 진짜 질문은 “자식을 부모로 치환해도 되는가”이며, 그 답이 13장으로 이어진다.

다음장으로 13장