11장에서 Phone이 RatePolicy에게 calculateFee를 보내면 실제로는 감싼 구현이 실행됐다. 이걸 가능하게 한 메커니즘이 다형성 — 하나의 메시지가 수신자의 실제 타입에 따라 서로 다른 메서드로 이어지는 것이다. 이 장은 그 이음이 코드 수준에서 어떻게 일어나는지를 연다. 예제는 성적을 매기는 Lecture와, 등급까지 추가하는 GradeLecture다.
다형성의 분류 체계
우리가 다루는 다형성은 여러 종류 중 하나다. 먼저 지도를 펼쳐 두면 이후 논의의 좌표가 잡힌다. 다형성은 크게 유니버설 다형성과 임시 다형성(ad hoc) 으로 갈리고, 각각 다시 둘로 나뉜다.
flowchart TD P["다형성 (Polymorphism)"] P --> U["유니버설 다형성<br/>(하나의 코드가 여러 타입에)"] P --> A["임시(ad hoc) 다형성<br/>(타입마다 다른 코드)"] U --> Par["매개변수 다형성<br/>(제네릭 List<T>)"] U --> Inc["포함 다형성<br/>(서브타입 — 이 장의 주제)"] A --> Over["오버로딩<br/>(같은 이름, 다른 시그니처)"] A --> Coe["강제 다형성<br/>(자동 타입 변환·형변환)"]
- 매개변수 다형성:
List<T>처럼 타입을 매개변수로 받아 하나의 코드가 여러 타입에 동작한다. 자바 제네릭이 이것이다. - 포함 다형성(서브타입 다형성): 자식 타입을 부모 타입으로 취급해 하나의 메시지가 여러 구현으로 이어진다. 이 장이 다루는, 흔히 그냥 “다형성”이라 부르는 것이다.
- 오버로딩:
plus(int)와plus(Money)처럼 같은 이름의 메서드를 시그니처만 다르게 여러 개 두는 것. - 강제 다형성:
1 + 2.0에서1이double로 변환되듯, 자동 형변환으로 하나의 연산자를 여러 타입에 쓰는 것.
판단 기준: 오버로딩과 강제는 컴파일 시점에 어떤 코드가 실행될지 결정된다(정적) — “임시”라 불리는 이유다. 반면 포함 다형성만이 실행 시점에 수신자의 타입으로 메서드가 결정된다(동적). 객체지향 설계에서 OCP와 유연성을 만드는 것은 오직 이 포함 다형성이다. 함정: 오버로딩을 다형성의 핵심으로 착각하면 안 된다 — 오버로딩은 컴파일러가 인자 타입으로 미리 고른 것이라, 실행 중에 동작이 바뀌는 다형성의 힘이 없다.
상속의 두 얼굴 — 데이터 관점과 행동 관점
포함 다형성은 상속 위에서 성립하므로, 상속을 두 관점으로 나눠 보면 이해가 선명해진다.
데이터 관점의 상속은 자식 인스턴스가 부모의 인스턴스 변수를 포함한다는 사실이다. GradeLecture 객체 안에는 Lecture의 필드가 그대로 들어 있다 — 자식 객체는 부모 객체를 품은 채 만들어진다.
행동 관점의 상속은 자식이 부모의 메서드를 공유한다는 사실이다. 자식이 오버라이드하지 않은 메서드는 부모의 것이 실행된다 — 이때 메서드가 실제로 자식의 데이터 위에서 동작하도록 잇는 것이 다음 절의 self 참조다.
public class Lecture {
private int pass; // 데이터 관점: 이 필드가 자식 객체에도 들어간다
public String evaluate() { ... } // 행동 관점: 자식이 이 메서드를 공유한다
}
public class GradeLecture extends Lecture {
private List<Grade> grades; // 자식 고유 데이터 + 부모의 pass 를 함께 가진다
}판단 기준: 데이터 관점은 “자식 객체가 무엇으로 이루어지는가”를, 행동 관점은 “어떤 메시지에 어떤 코드가 응답하는가”를 설명한다. 다형성은 이 둘의 결합 — 부모의 코드가 자식의 데이터 위에서 실행될 수 있어야 성립한다. 함정: 상속을 “부모 코드 복사”로 오해하면 안 된다. 자식은 부모 코드의 사본을 갖는 게 아니라 실행 시점에 부모의 메서드를 찾아가 공유한다. 그래서 부모를 고치면 자식의 행동도 즉시 바뀐다.
업캐스팅과 동적 바인딩
부모 타입 변수에 자식 인스턴스를 담는 것이 업캐스팅이다. 컴파일러는 변수의 타입(Lecture)만 보고 허용 여부를 판단하지만, 실행 시점에 실제로 실행되는 메서드는 담긴 객체의 타입(GradeLecture)이 정한다. 이 실행 시점 결정이 동적 바인딩이다.
Lecture lecture = new GradeLecture(...); // 업캐스팅 — 컴파일러는 Lecture 로 본다
lecture.evaluate(); // 동적 바인딩 — 실제론 GradeLecture.evaluate 실행컴파일 시점 타입과 실행 시점 타입이 다를 수 있다는 것, 그리고 메서드를 고르는 쪽은 후자라는 것 — 이 둘이 다형성의 전부다. 함정: 정적 메서드나 필드는 동적 바인딩되지 않고 컴파일 시점 타입으로 묶인다. 다형성은 인스턴스 메서드에서만 일어난다.
이 두 메커니즘이 9장의 개방-폐쇄 원칙을 어떻게 떠받치는지 보자. 업캐스팅 덕분에 코드는 부모 타입 하나만 알면 되고(컴파일 의존성 고정 → 수정에 닫힘), 동적 바인딩 덕분에 실행 시점에 어떤 자식이 담기든 그것의 메서드가 실행된다(런타임 의존성 변경 → 확장에 열림).
public class Screening {
public Money calculateFee(Movie movie) { // Movie 타입만 안다 — 컴파일 의존성 고정
return movie.calculateMovieFee(this); // 실제로는 담긴 자식의 메서드가 실행 — 확장에 열림
}
}Screening은 Movie의 새 서브클래스가 몇 개 생기든 한 줄도 바뀌지 않는다. 새 타입을 부모 타입 변수에 담는 것(업캐스팅)과 그 타입의 메서드가 실행되는 것(동적 바인딩), 이 둘이 없으면 OCP는 코드로 존재할 수 없다. 판단 기준: “새 기능을 기존 코드 수정 없이 추가”라는 OCP의 약속은 추상적 원칙이 아니라 업캐스팅과 동적 바인딩이라는 언어 메커니즘의 직접적 결과다.
메서드 탐색은 self 에서 시작한다
메시지를 받으면 런타임은 메서드를 어디서 찾을까. 동적 메서드 탐색은 세 가지 규칙으로 굴러간다.
- 자동적인 메시지 위임: 객체는 자신이 이해할 수 없는 메시지를 받으면 부모 클래스로 자동으로 위임한다 — 프로그래머가 명시하지 않아도 언어가 자동으로 계층을 타고 올라간다.
- 탐색은 self 참조에서 시작한다: 어디서 메시지가 보내졌든, 탐색의 출발점은 언제나 메시지를 받은 객체(self)의 실제 클래스다.
- 이해할 수 없으면 부모로: self 클래스에 메서드가 없으면 부모로, 또 없으면 그 부모로 올라간다. 최상위(
Object)까지 갔는데도 없으면 예외다 — 스몰토크에서는doesNotUnderstand메시지가 되고, 자바에서는 애초에 컴파일 단계에서 걸러진다.
정리하면 규칙은 하나로 요약된다. 언제나 메시지를 받은 객체(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)부터 다시 탐색한다. 그래서 만약 GradeLecture가 passCount()를 오버라이드했다면, 부모의 evaluate 안에서 불렸더라도 자식의 passCount()가 실행된다. 이것이 self 참조의 힘이자, 앞 장에서 본 템플릿 메서드가 작동하는 원리다. 부모의 골격이 자식이 채운 메서드를 호출할 수 있는 건 탐색이 늘 self에서 시작하기 때문이다. 판단 기준: 부모 메서드 안의 this 호출은 컴파일러가 가리키는 부모가 아니라 실행 중인 실제 객체를 가리킨다. 이 사실을 놓치면 “왜 부모 코드가 자식 메서드를 부르지?”에서 길을 잃는다.
상속의 본질은 타입 계층이다
10장은 상속을 코드 재사용의 도구로 썼고, 그 결합의 함정을 봤다. 이 장은 상속의 다른 얼굴을 보여 준다. 다형성이 성립하려면 GradeLecture가 Lecture로 취급될 수 있어야 한다 — 즉 타입 계층이 필요하다.
List<Lecture> lectures = List.of(new Lecture(...), new GradeLecture(...));
for (Lecture each : lectures) {
System.out.println(each.evaluate()); // 원소마다 실제 타입의 evaluate 가 실행된다
}이 반복문은 각 원소가 무슨 서브클래스인지 묻지 않는다. 모두 Lecture라는 타입으로 다루고, 실행할 메서드는 동적 바인딩이 고른다. 여기서 재사용된 것은 부모의 코드가 아니라 부모라는 타입이다. 판단 기준: 상속을 쓸 때 물어야 할 진짜 질문은 “코드를 재사용하고 싶은가”가 아니라 “자식을 부모 타입으로 다뤄도 되는가(치환 가능한가)“다. 전자만 노린 상속은 10장의 취약함으로 이어지고, 후자를 만족하는 상속만이 다형성이라는 열매를 맺는다. 이 치환 가능성의 조건 — 서브클래싱과 서브타이핑의 갈림길이 바로 다음 장의 주제다.
self 전송과 super 전송은 다르다
super를 “부모의 메서드를 호출한다”로 이해하면 3단 상속에서 반드시 틀린다. super의 정확한 뜻은 “지금 이 코드가 속한 클래스의 부모부터 탐색을 시작하라” 이지, “부모의 메서드를 부른다”가 아니다. 둘의 차이는 클래스가 셋 이상 쌓일 때 드러난다. Lecture → GradeLecture → GradeExtraLecture로 3단을 만들어 보자.
public class Lecture {
public String evaluate() { return "기본 평가"; }
}
public class GradeLecture extends Lecture {
@Override
public String evaluate() {
return super.evaluate() + " + 등급"; // "GradeLecture 의 부모(Lecture)부터 탐색"
}
}
public class GradeExtraLecture extends GradeLecture {
@Override
public String evaluate() {
return super.evaluate() + " + 가산점"; // "GradeExtraLecture 의 부모(GradeLecture)부터 탐색"
}
}new GradeExtraLecture().evaluate()를 부르면 결과는 "기본 평가 + 등급 + 가산점"이다. 흐름을 따라가 보자. GradeExtraLecture.evaluate의 super.evaluate()는 “이 코드가 있는 클래스(GradeExtraLecture)의 부모, 즉 GradeLecture부터” 탐색한다 → GradeLecture.evaluate 실행. 그 안의 super.evaluate()는 다시 “이 코드가 있는 클래스(GradeLecture)의 부모, 즉 Lecture부터” 탐색한다 → Lecture.evaluate 실행.
flowchart TD A["GradeExtraLecture.evaluate<br/>super = 'GradeExtraLecture 의 부모부터'"] A --> B["GradeLecture.evaluate 실행<br/>super = 'GradeLecture 의 부모부터'"] B --> C["Lecture.evaluate 실행 → '기본 평가'"] C --> B2["+ ' + 등급'"] B2 --> A2["+ ' + 가산점'"]
여기서 핵심은 각 super의 시작점이 런타임 객체의 부모가 아니라, 그 super가 적힌 클래스의 부모로 고정된다는 것이다. 만약 super가 “self의 부모부터”였다면 GradeExtraLecture의 부모는 언제나 GradeLecture이므로 무한 루프에 빠졌을 것이다. self 전송(this.method() 또는 암묵적 호출)은 언제나 실제 객체의 클래스부터 다시 탐색하지만, super 전송은 정적으로 결정된 “그 클래스의 부모”라는 고정점부터 탐색한다. 판단 기준: super는 상향식으로 “한 단계 위부터”를 뜻하고, self/this는 하향식으로 “맨 아래(실제 객체)부터”를 뜻한다. 이 방향의 차이가 템플릿 메서드(self 전송으로 자식을 부름)와 부모 확장(super 전송으로 부모 골격에 얹음)을 갈라 놓는다.
상속 대신 위임
동적 메서드 탐색이 self에서 시작하는 것은 상속이라는 언어 기능이 self 참조를 자동으로 넘겨주기 때문이다. 그렇다면 상속 없이, 그 self 참조를 직접 넘겨주면 같은 효과를 손으로 만들 수 있다 — 이것이 위임(delegation) 이다.
// 상속 없이 위임으로 다형성을 흉내 낸다 — self 를 명시적으로 전달한다
public class GradeLecture {
private Lecture lecture; // 부모를 '가진다'
public String evaluate() {
return lecture.evaluateWith(this) + " + 등급"; // self(this)를 넘긴다
}
}일반적인 포워딩(11장의 합성)은 감싼 객체에 일을 넘기되 자기 자신(self)은 넘기지 않는다 — 감싼 객체는 자신의 self만 안다. 반면 위임은 self 참조까지 함께 넘겨, 감싼 객체가 원래 수신자를 되짚어 부를 수 있게 한다. 이 self 전달이 상속의 자동 위임을 손으로 재현하는 지점이다. 자바스크립트 같은 프로토타입 기반 언어는 아예 클래스 없이 이 위임을 언어의 기본 메커니즘으로 삼는다 — 객체가 다른 객체(프로토타입)에게 이해 못 하는 메시지를 위임하고, 그 탐색은 여전히 원래 객체(self)에서 시작한다. 판단 기준: 클래스 기반 상속의 self 참조 자동 전달을 “프로토타입 객체 사이의 명시적 위임”으로 바꾼 것이 프로토타입 언어다. 상속과 위임은 다른 것이 아니라, self를 자동으로 넘기느냐 손으로 넘기느냐의 차이다.
오버라이딩과 오버로딩을 혼동하지 마라
이름이 비슷해 자주 섞이지만, 오버라이딩과 오버로딩은 다형성의 축이 완전히 다르다.
public class Lecture {
public String evaluate() { ... } // (A)
public String evaluate(String prefix) { ... } // (B) 오버로딩 — 시그니처가 다르다
}
public class GradeLecture extends Lecture {
@Override
public String evaluate() { ... } // (C) 오버라이딩 — (A)와 시그니처가 같다
}오버라이딩은 자식이 부모와 같은 시그니처의 메서드를 재정의해, 부모의 것을 가리는(대체하는) 것이다. (A)와 (C)는 시그니처가 같고, 실제 객체가 GradeLecture면 (C)가 (A)를 가려 실행된다 — 실행 시점에 결정되는 동적 다형성이다. 오버로딩은 다른 시그니처의 메서드를 같은 이름으로 여러 개 두는 것이다. (A)와 (B)는 공존하며 서로를 가리지 않고, 컴파일러가 인자 타입을 보고 미리 하나를 고른다 — 컴파일 시점에 결정되는 정적 다형성이다.
판단 기준: “부모의 것을 대체하는가(오버라이딩), 아니면 나란히 늘어놓는가(오버로딩)“로 가른다. 오버라이딩만이 이 장에서 말하는 동적 바인딩·포함 다형성의 대상이고, OCP와 유연성을 만든다. 함정: 오버라이딩하려다 시그니처를 미묘하게 틀리면(인자 타입 하나가 다르면) 오버라이딩이 아니라 오버로딩이 되어 부모 메서드를 가리지 못한다 — 부모의 것이 그대로 실행돼 조용히 오작동한다. @Override 애너테이션은 이 실수를 컴파일 에러로 잡아 주므로 반드시 붙인다.
정리
- 다형성의 분류: 유니버설(매개변수/포함) vs 임시(오버로딩/강제). 이 장의 주제는 실행 시점에 결정되는 포함(서브타입) 다형성 — OCP를 만드는 유일한 종류다.
- 상속의 두 얼굴: 데이터 관점(자식이 부모 필드를 포함)과 행동 관점(자식이 부모 메서드를 공유). 다형성은 둘의 결합이다.
- 다형성 = 업캐스팅(부모 타입으로 담기) + 동적 바인딩(실제 타입이 메서드를 고르기). 이 둘이 OCP를 언어 메커니즘으로 실현한다.
- 동적 메서드 탐색 3규칙: 자동 위임 / self에서 시작 / 없으면 부모로(끝까지 없으면 doesNotUnderstand).
super만 시작점을 “그 코드가 적힌 클래스의 부모”로 옮긴다. - self 전송(this)은 실제 객체부터 다시 탐색, super 전송은 “그 클래스의 부모”라는 고정점부터 탐색 —
GradeExtraLecture3단이 그 차이를 드러낸다. - 상속은 self 참조를 자동으로 넘기는 위임이다. self를 손으로 넘기면 위임이 되고, 프로토타입 언어(JS)는 이를 기본 메커니즘으로 삼는다.
- 상속의 본질은 코드 재사용이 아니라 타입 계층이다. 진짜 질문은 “자식을 부모로 치환해도 되는가”이며, 그 답이 13장으로 이어진다.
다음장으로 13장