상속은 강력하지만 오용하기 쉬운 도구다. 마지막 장은 두 부류의 기법을 다룬다. 하나는 계층 안에서 메서드와 필드를 위아래로 옮기는 비교적 안전한 작업이고, 다른 하나는 상속 자체를 위임(composition)으로 갈아타는 큰 수술이다. 파울러의 태도는 실용주의다 — 상속은 싸고 명료하니 먼저 쓰되, 안 맞아지면 갈아탈 절차가 있으니 겁내지 말라는 것.
메서드 올리기 / 필드 올리기 / 내리기
방향의 기준은 언제나 단순하다. 코드는 그것을 쓰는 클래스에 가장 가까이 있어야 한다. 여러 서브클래스에 중복된 요소는 슈퍼클래스로 올리고(pull up), 일부 서브클래스만 쓰는 요소는 그 서브클래스로 내린다(push down).
// before — 두 서브클래스에 같은 메서드가 중복
class Employee {}
class Salesman extends Employee {
get name() {
return this._name
}
}
class Engineer extends Employee {
get name() {
return this._name
} // 동일
}// after — 슈퍼클래스로 올린다
class Employee {
get name() {
return this._name
}
}
class Salesman extends Employee {}
class Engineer extends Employee {}함정: 올리려는 두 메서드가 “겉보기엔 같지만” 참조하는 필드가 서브클래스마다 다르면 그냥 못 올린다. 먼저 필드를 올리거나 시그니처를 맞춘 뒤 올린다. 내리기는 반대로, 슈퍼클래스에 있지만 실제로는 한 서브클래스만 쓰는 메서드를 그 클래스로 밀어내는 것이다.
생성자 본문 올리기
서브클래스 생성자들이 똑같은 초기화 코드를 갖고 있으면, 그 공통부를 슈퍼클래스 생성자로 올리고 서브클래스에서는 super()로 부른다.
// before
class Party {}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super()
this._id = id
this._name = name // 다른 서브클래스와 중복될 초기화
this._monthlyCost = monthlyCost
}
}// after — 공통 초기화를 슈퍼클래스로
class Party {
constructor(name) {
this._name = name
}
}
class Employee extends Party {
constructor(name, id, monthlyCost) {
super(name) // 공통부는 super가 처리
this._id = id
this._monthlyCost = monthlyCost
}
}타입 코드를 서브클래스로 바꾸기
타입 필드 값에 따라 동작이 갈리기 시작하면 서브클래스를 도입할 시점이다. 10장의 “조건부 로직을 다형성으로 바꾸기”와 이어지는 구조 준비 작업이다.
Next를 누르며 세 스냅샷을 따라가 보자. type 문자열 분기가 먼저 서브클래스로, 다시 별도 타입 객체로 옮겨 간다.
class Employee { constructor(name, type) { this._name = name this._type = type } get type() { return this._type } get capitalizedType() { /* type에 따라 분기 */ }}
두 번째 스텝의 직접 상속은 타입마다 서브클래스를 두고 createEmployee 팩토리로 알맞은 인스턴스를 만든다. 문제는 직접 상속이 곤란한 경우다. Employee가 이미 다른 이유로 상속 계층에 묶여 있거나, 한 직원의 타입이 런타임에 바뀌어야 한다면 객체의 클래스를 갈아끼울 수 없다. 이때가 세 번째 스텝이다. 타입 코드를 별도 객체로 뽑고 거기에 간접 상속을 건다.
이제 emp.type = "salesman"처럼 런타임에 타입을 바꿀 수 있다. Employee 자신의 클래스는 그대로 두면서.
서브클래스 제거하기
서브클래스가 더 이상 제 값을 못 한다면 — 하는 일이 거의 없거나, 앞으로 안 쓸 예정이면 — 슈퍼클래스의 필드로 흡수해 없앤다. 타입 코드를 서브클래스로 바꾸기의 역방향이다.
// before
class Person {
get genderCode() {
return "X"
}
}
class Male extends Person {
get genderCode() {
return "M"
}
}
class Female extends Person {
get genderCode() {
return "F"
}
}
// after — 서브클래스를 걷어내고 필드로
class Person {
constructor(genderCode) {
this._genderCode = genderCode || "X"
}
get genderCode() {
return this._genderCode
}
}슈퍼클래스 추출하기
두 클래스가 비슷한 일을 하고 있으면, 공통부를 새 슈퍼클래스로 뽑아 올린다.
// before — Employee와 Department가 각자 이름·연간비용을 다룬다
class Employee {
get annualCost() {
return this._monthlyCost * 12
}
get name() {
return this._name
}
}
class Department {
get totalAnnualCost() {
return this._staff.reduce((s, e) => s + e.annualCost, 0)
}
get name() {
return this._name
}
}// after — 공통 슈퍼클래스로 추출
class Party {
constructor(name) {
this._name = name
}
get name() {
return this._name
}
get annualCost() {
return this.monthlyCost * 12
} // 훅 메서드
}
class Employee extends Party {
get monthlyCost() {
return this._monthlyCost
}
}
class Department extends Party {
get monthlyCost() {
return this._staff.reduce((s, e) => s + e.monthlyCost, 0)
}
}상속이냐 위임이냐 고민되면, 파울러는 일단 상속으로 빠르게 만들고 필요해지면 나중에 위임으로 바꾸라고 한다. 슈퍼클래스 추출이 위임보다 단순하기 때문이다.
계층 합치기
슈퍼클래스와 서브클래스가 사실상 같아져서 구분할 이유가 없어지면, 둘을 하나로 합친다. 슈퍼클래스 추출의 역방향이자 계층 정리의 마무리다.
서브클래스를 위임으로 바꾸기
상속에는 두 가지 근본 제약이 있다. 첫째, 축이 하나뿐이다. 사람을 나이대로 서브클래싱하면, 소득 수준 같은 다른 축으로는 동시에 나눌 수 없다. 둘째, 결합이 세다. 슈퍼클래스를 고치면 서브클래스가 쉽게 깨진다. 이 신호가 오면 서브클래스를 위임 객체로 바꾼다.
// before — 예약이 프리미엄 여부로 상속
class Booking {
constructor(show, date) {
this._show = show
this._date = date
}
get hasTalkback() {
return this._show.hasOwnProperty("talkback") && !this.isPeakDay
}
}
class PremiumBooking extends Booking {
constructor(show, date, extras) {
super(show, date)
this._extras = extras
}
get hasTalkback() {
return this._show.hasOwnProperty("talkback")
}
}// after — 프리미엄 여부를 위임 객체로
class Booking {
constructor(show, date) {
this._show = show
this._date = date
}
get hasTalkback() {
return this._premiumDelegate
? this._premiumDelegate.hasTalkback
: this._show.hasOwnProperty("talkback") && !this.isPeakDay
}
_bePremium(extras) {
this._premiumDelegate = new PremiumBookingDelegate(this, extras)
}
}
class PremiumBookingDelegate {
constructor(hostBooking, extras) {
this._host = hostBooking
this._extras = extras
}
get hasTalkback() {
return this._host._show.hasOwnProperty("talkback")
}
}
function createBooking(show, date) {
return new Booking(show, date)
}
function createPremiumBooking(show, date, extras) {
const result = new Booking(show, date)
result._bePremium(extras) // 런타임에 프리미엄으로 승격 가능
return result
}위임으로 바꾸면 프리미엄 여부를 런타임에 붙이거나 뗄 수 있고(축 자유), Booking과 PremiumBookingDelegate의 결합이 느슨해진다. 이것이 “상속보다 컴포지션”이라는 격언의 절차화다. 다만 파울러는 그 격언을 “상속을 쓰지 말라”가 아니라 “상속이 안 맞아지면 갈아탈 수 있다”로 읽는다. 위임은 위임대로 위임 대상 필드와 전달 메서드라는 비용이 있다.
슈퍼클래스를 위임으로 바꾸기
서브클래스가 슈퍼클래스 인터페이스의 일부만 유효하게 쓰고 나머지는 말이 안 된다면, 상속이 거짓말을 하고 있는 것이다. 고전적 반례가 Stack extends List다. 스택으로 만들었는데 리스트의 get(3)이나 중간 삽입이 전부 노출돼, “스택은 리스트다(is-a)“라는 상속의 약속이 깨진다.
상속(is-a)일 때와 위임(has-a)으로 바꾼 뒤의 구조를 나란히 보면 차이가 분명하다.
classDiagram class List { +add(item) +remove(i) +get(i) +insert(i, item) } class StackInherit { +push(item) +pop() } class StackStorage { +push(item) +pop() +isEmpty() } List <|-- StackInherit : 상속 is-a StackStorage o-- List : 위임 has-a note for StackInherit "상속: get·insert까지 노출돼 거짓 is-a" note for StackStorage "위임: 스택에 맞는 연산만 전달"
상속에서는 리스트 연산이 그대로 새어 나오지만, 위임에서는 필요한 연산만 골라 전달해 API가 정직해진다.
// before — 스택인데 리스트의 모든 연산이 새어 나온다
class Stack extends List {
push(item) {
this.add(item)
}
pop() {
return this.remove(this.length - 1)
}
// 하지만 stack.get(2), stack.insert(...) 도 다 열려 있다
}// after — 슈퍼클래스를 필드로 강등, 필요한 것만 전달
class Stack {
constructor() {
this._storage = new List()
} // is-a → has-a
push(item) {
this._storage.add(item)
}
pop() {
return this._storage.remove(this._storage.length - 1)
}
get isEmpty() {
return this._storage.length === 0
}
// get/insert 같은 리스트 연산은 노출되지 않는다
}리스트를 필드로 내리고 스택에 어울리는 연산만 전달하니, API가 정직해진다. 함정: 슈퍼클래스의 많은 메서드를 실제로 다 쓰고 있다면 위임 전환 비용(전달 메서드 다수)이 크다. 인터페이스 대부분이 유효하면 상속을 유지하는 게 낫다.
정리
- 올리기/내리기의 기준은 단순하다. 코드는 그것을 쓰는 클래스에 가장 가까이 있어야 한다.
- 타입 코드가 동작을 가르기 시작하면 서브클래스로, 직접 상속이 곤란하면 타입 객체에 간접 상속을 건다.
- 상속 먼저, 위임은 탈출구 — 이 순서가 실용적이다. 처음부터 위임으로 방어 설계할 필요는 없다.
- 서브클래스를 위임으로(축·결합 문제), 슈퍼클래스를 위임으로(
Stack extends List식 거짓 is-a) — 상속이 거짓말할 때 갈아탄다. - 책 전체의 결론과 같다. 어떤 구조 선택도 최종이 아니다. 갈아탈 절차가 있는 한 지금 가장 단순한 선택을 하면 된다.