thenullpage.com

재생 목록을 다루는 클래스를 하나 만든다고 하자. 배열이 이미 갖고 있는 push와 length를 그대로 쓰고 싶어 extends Array를 붙인다. 코드 몇 줄이 줄고 그날은 잘 돌아간다. 이 선택이 문제가 되는 시점은 이 클래스에 지켜야 할 규칙이 하나 생길 때다.


class Playlist extends Array<string> {
addTrack(title: string) {
if (this.length >= 3) throw new Error("무료 계정은 3곡까지");
this.push(title);
}
}

const list = new Playlist();
list.addTrack("첫 곡");
list.push("몰래 넣은 곡");
console.log(list.length); // 2

물려받은 문이 전부 열려 있다

addTrack은 3곡 제한을 확인하지만, push는 그 확인을 거치지 않고 곡을 밀어 넣는다. pop, splice, sort, unshift까지 배열이 가진 메서드가 전부 Playlist의 메서드가 됐기 때문이다.


extends는 부모의 공개 메서드를 자식의 공개 메서드로 그대로 옮겨 놓는다. 재사용하려고 붙였는데 실제로 일어난 일은 부모가 가진 기능 전부를 바깥에 공개한 것이다. 규칙을 객체 안에 가두는 일이 캡슐화인데, 상속으로 시작하면 그 규칙을 지나칠 통로가 처음부터 여럿 열려 있다.

필요한 것만 빌리는 합성

해법은 물려받는 대신 갖는 것이다. 배열을 필드로 두고, 바깥에 열어 줄 동작만 메서드로 만든다. 이렇게 다른 객체를 안에 두고 쓰는 방식을 합성(composition)이라 부르고, 받은 요청을 그 객체에게 넘기는 것을 위임(delegation)이라 한다.


class Playlist {
#tracks: string[] = [];
addTrack(title: string) {
if (this.#tracks.length >= 3) throw new Error("무료 계정은 3곡까지");
this.#tracks.push(title);
}
get size() { return this.#tracks.length; }
at(index: number) { return this.#tracks[index]; }
}

const list = new Playlist();
list.addTrack("첫 곡");
// list.push는 존재하지 않는다
console.log(list.size); // 1


줄 수는 오히려 늘었지만 열린 문은 셋뿐이다. 곡을 넣는 경로가 addTrack 하나로 좁혀졌으니 3곡 제한은 반드시 지켜진다.

부모가 바뀌면 자식이 깨진다

상속의 두 번째 대가는 겉으로 잘 드러나지 않는다. 목록에 넣은 개수를 세는 기능을 상속으로 얹은 코드를 보자.


class Basket {
protected items: string[] = [];
add(item: string) { this.items.push(item); }
addAll(list: string[]) { for (const i of list) this.add(i); }
}

class CountingBasket extends Basket {
added = 0;
add(item: string) { this.added += 1; super.add(item); }
addAll(list: string[]) { this.added += list.length; super.addAll(list); }
}

const basket = new CountingBasket();
basket.addAll(["사과", "배"]);
console.log(basket.added); // 4


두 개를 넣었는데 4가 찍힌다. addAll에서 2를 더한 뒤 super.addAll이 안에서 add를 두 번 부르고, 그 add는 자식이 재정의(override, 부모의 메서드를 자식이 같은 이름으로 다시 정의하는 것)한 쪽이라 다시 2가 더해진다. addAll을 재정의하지 않으면 답은 맞는다. 자식을 어떻게 짜야 맞는지가 부모 메서드의 내부 구현에 달려 있다는 뜻이다.


이 관계가 위험한 이유는 부모가 언제든 바뀔 수 있다는 데 있다. 누군가 성능을 이유로 addAll을 this.items.push(...list)로 바꾸면, 부모의 겉모습은 그대로인데 자식의 카운트만 조용히 틀어진다. 이렇게 부모 구현이 바뀔 때 자식이 깨지는 현상을 취약한 기반 클래스(fragile base class)라 부른다.


합성으로 바꾸면 이 연결이 끊어진다.


class CountingBasket {
#inner = new Basket();
added = 0;
add(item: string) { this.added += 1; this.#inner.add(item); }
addAll(list: string[]) { this.added += list.length; this.#inner.addAll(list); }
}

const basket = new CountingBasket();
basket.addAll(["사과", "배"]);
console.log(basket.added); // 2


Basket이 안에서 무슨 순서로 무엇을 부르든 added가 올라가는 자리는 이 클래스 안 두 곳뿐이다.

is-a를 확인하는 질문

상속을 판단할 때 흔히 쓰는 기준이 is-a, 자식이 부모의 한 종류인가를 묻는 것이다. 문제는 이 질문을 우리말 문장으로만 검사한다는 데 있다. 정사각형은 직사각형이다, 라는 문장은 흠잡을 데가 없어 보인다.


class Rectangle {
constructor(protected w: number, protected h: number) {}
setWidth(w: number) { this.w = w; }
setHeight(h: number) { this.h = h; }
area() { return this.w * this.h; }
}

class Square extends Rectangle {
setWidth(w: number) { this.w = w; this.h = w; }
setHeight(h: number) { this.w = h; this.h = h; }
}

function resize(r: Rectangle) {
r.setWidth(4);
r.setHeight(5);
return r.area();
}

console.log(resize(new Rectangle(1, 1))); // 20
console.log(resize(new Square(1, 1))); // 25


resize는 가로 4에 세로 5를 넣었으니 20을 기대하고 짠 코드다. Square를 넘기면 25가 나온다. Square가 틀린 것도, resize가 틀린 것도 아니다. 정사각형은 가로와 세로를 따로 정할 수 없다는 성질 때문에 직사각형이 하던 약속을 지킬 수 없을 뿐이다.


그래서 실제로 물어야 할 질문은 낱말이 아니라 동작에 관한 것이다. 부모를 기대하고 짠 코드에 자식을 대신 넣어도 그 코드가 여전히 맞게 도는가. 여기에 조건이 붙거나, 넘기기 전에 타입을 확인해야 한다면 상속 관계가 아니다.

상속을 써도 되는 자리

상속이 언제나 나쁜 것은 아니다. 부모가 처음부터 확장을 염두에 두고 설계돼, 어느 메서드를 재정의하라고 정해 둔 경우가 대표적이다. 프레임워크가 특정 클래스를 물려받으라고 요구하는 자리도 마찬가지다. 부모 저자가 확장 지점을 약속으로 명시해 둔 경우라 앞의 취약함이 생기지 않는다.


반대로 계층이 세 단계를 넘어가면 메서드 하나를 따라가려고 조상 클래스를 전부 열어 보게 된다. 이 상태를 요요 문제(yo-yo problem)라 한다. 깊은 계층은 대체로 재사용을 상속으로 해결한 흔적이다.


판단은 한 문장으로 끝난다. 이걸 왜 상속하는가. 답이 저기 있는 메서드를 쓰고 싶어서라면 필드로 갖는 편이 맞다. 답이 이 자리에 자식을 넣어도 쓰는 쪽이 아무것도 몰라도 되기 때문이라면 그때가 상속이다. 재사용은 상속의 이유가 아니라 합성으로 얻는 결과다.