PUBLISHED
inheritance v composition
작성일: 2025.01.30

객체 지향 프로그래밍은 코드의 재사용성과 유지보수성을 높이기 위해 다양한 설계 원칙을 제시한다. 그중에서도 클래스 상속(Class Inheritance)과 클래스 합성(Class Composition)은 객체 간의 관계를 정의하는 대표적인 방법이며, 시스템의 유연성과 확장성에 직접적인 영향을 미치는 핵심 개념이다. 두 접근법은 모두 코드 중복을 줄이고 공통된 행위를 재사용하려는 목적을 가지고 있지만, 문제를 해결하는 방식과 그에 따른 트레이드오프는 분명히 다르다.
클래스 상속은 부모 클래스의 속성과 메서드를 자식 클래스가 그대로 물려받는 구조를 만든다. 이 방식은 계층적인 관계를 표현하기에 직관적이고, 공통 로직을 한 곳에 모을 수 있다는 장점이 있다. 반면 클래스 합성은 하나의 객체가 다른 객체를 내부에 포함하여 기능을 조합하는 방식이다. 기능을 “물려받는” 대신 “위임”하기 때문에, 구조적으로 더 느슨한 결합을 만들 수 있다.
최근에는 상속 대신 합성을 선호하자는 흐름이 점점 강해지고 있다. 심지어 상속은 코드 품질을 해치는 악습이며, 절대 사용해서는 안 된다는 다소 과격한 주장까지 종종 등장한다. 하지만 정말로 상속은 언제나 나쁜 선택일까? 이를 판단하기 위해서는 상속이 실제로 어떤 문제를 만들어내는지를 구체적인 코드 수준에서 살펴볼 필요가 있다.
inheritance
자바스크립트에서 클래스는 extends 키워드를 통해 다른 클래스를 상속할 수 있다. 다중 상속은 허용되지 않는데, 이는 메서드 충돌이나 복잡한 상속 계층으로 인한 혼란을 방지한다는 점에서 오히려 합리적인 선택처럼 느껴지기도 한다. 자식 클래스에서는 super를 통해 부모 클래스의 생성자와 메서드에 접근할 수 있다.
class Bird {
constructor(private _name: string) {}
bark() {
console.log(`${this._name} barks.`);
}
canFly() {
return true;
}
}
class Penguin extends Bird {
constructor(private name: string) {
super(name);
}
canFly() {
return false;
}
}이 코드에서 Penguin은 Bird를 상속받아 canFly 메서드를 오버라이딩한다. 다형성의 관점에서 보면 매우 자연스러운 예제다. 실제로 Bird 타입을 기대하는 곳에 Penguin을 넘겨도 문제없이 동작한다.
const eagle = new Bird("Eagle");
console.log(eagle.canFly()); // true
const penguin = new Penguin("Penguin");
console.log(penguin.canFly()); // false문제는 여기서부터 시작된다. Penguin 클래스에는 bark 메서드를 직접 정의한 적이 없지만, 부모 클래스에 존재하기 때문에 자동으로 상속된다. 이는 곧 부모 클래스에 새로운 메서드가 추가될 때마다, 그 메서드가 모든 자식 클래스에 아무런 경고 없이 전파된다는 뜻이다.
const peng = new Penguin("peng");
peng.bark(); // "peng barks."이제 조금 더 현실적인 상황을 가정해보자. 일반적으로 “새는 수영하지 못한다”는 전제를 가지고 Bird 클래스에 canSwim 메서드를 추가했다고 하자.
class Bird {
canSwim() {
return false;
}
}문제는 프로젝트가 커지면서, 누군가는 Penguin이 Bird를 상속하고 있다는 사실을 잊어버렸다는 점이다. 이후 아래와 같은 테스트 코드가 작성된다.
function testBirdSwim(bird: Bird) {
if (bird.canSwim()) {
return "A";
} else {
return "B";
}
}
const peng = new Penguin("peng");
testBirdSwim(peng); // "B"펭귄은 수영할 수 있으니 "A"가 나올 것이라 기대했지만, 실제 결과는 "B"다. 이 코드는 문법적으로도, 타입적으로도 아무런 문제가 없다. 컴파일도 정상적으로 되고, 런타임 에러도 발생하지 않는다. 그럼에도 불구하고 결과는 명백히 잘못되었다.
composition
내가 생각하기에 상속의 가장 큰 문제는 바로 여기에 있다. 부모 클래스의 변경이 자식 클래스의 의미를 조용히 바꿔버릴 수 있고, 이 사실을 실행해보기 전까지는 누구도 알아차리기 어렵다는 점이다. 타입 시스템조차 이 문제를 감지하지 못한다.
이제 같은 예제를 합성으로 풀어보자. Penguin이 Bird를 상속하는 대신, 내부에 Bird 인스턴스를 포함하도록 구성하면 코드는 다음과 같다.
class Penguin {
bird;
constructor(private _name: string) {
this.bird = new Bird(this._name);
}
bark() {
this.bird.bark();
}
canFly() {
return false;
}
}겉보기에는 오히려 코드가 더 번거로워진 것처럼 보일 수도 있다. super를 사용할 수 없기 때문에, 필요한 메서드를 직접 위임해야 한다. 하지만 이 구조의 진짜 장점은 “자동 상속이 일어나지 않는다”는 데 있다.
이제 Bird 클래스에 canSwim 메서드를 추가하더라도, Penguin에는 아무런 변화가 없다. 그리고 누군가 Penguin 인스턴스를 Bird처럼 다루려고 시도하는 순간, 타입스크립트는 즉시 문제를 알려준다.
“Penguin에는 canSwim 메서드가 없다.”
이 피드백은 런타임이 아니라, 코드 작성 시점에 발생한다. 개발자는 즉시 클래스 정의로 이동해 canSwim을 명시적으로 추가하거나, 해당 설계가 잘못되었음을 깨닫게 된다. 이처럼 합성은 타입스크립트의 정적 타입 검사를 훨씬 효과적으로 활용할 수 있게 만든다.
이 장점을 극대화하려면, 구체적인 클래스에 의존하기보다는 인터페이스를 중심으로 설계하는 것이 좋다. 기능을 “능력” 단위로 분리하고, 클래스는 이를 조합하는 역할만 맡는다.
interface Swimable {
swim(): void;
}
interface Flyable {
fly(): void;
}
class SwimAbility implements Swimable {
swim() {}
}
class FlyAbility implements Flyable {
fly() {}
}
class Penguin implements Swimable {
private swimAbility = new SwimAbility();
swim() {
this.swimAbility.swim();
}
}
class Magpie implements Flyable {
private flyAbility = new FlyAbility();
fly() {
this.flyAbility.fly();
}
}여기에 SOLID 원칙 중 하나인 의존성 역전 원칙을 적용하면, 함수와 클래스 모두 구체적인 구현이 아니라 추상화에 의존하게 된다.
function swimableFn(swimable: Swimable) {
swimable.swim();
}
const magpie = new Magpie();
swimableFn(magpie);
// 타입 에러: "Magpie 타입에는 swim 속성(메서드)이 없는데, Swimable 타입에서는 swim이 필수(required)입니다."이 에러는 런타임 버그를 미리 제거해준다. “수영할 수 없는 객체를 수영 함수에 넘기고 있다”는 사실이 코드 작성 단계에서 명확히 드러난다. 이는 상속 구조에서는 쉽게 놓치기 쉬운 문제다. 이처럼 클래스 합성과 인터페이스를 조합하면, 대규모 프로젝트에서 발생하는 휴먼 에러를 상당 부분 줄일 수 있다. 하지만 그렇다고 해서 모든 경우에 합성이 상속보다 무조건 우월하다고 말할 수는 없다.
합성은 구조적으로 더 안전한 대신, 코드가 다소 장황해지는 경향이 있다. 또한 User와 PremiumUser처럼, 자식 클래스가 부모 클래스의 모든 속성과 행위를 반드시 포함해야 하는 명확한 “is-a 관계”라면 상속이 훨씬 자연스럽고 표현력이 좋다.
결국 중요한 것은 “상속이냐 합성이냐”라는 이분법적인 선택이 아니다. 클래스 간의 관계가 정말로 부모-자식 관계인지, 아니면 단순한 기능 조합인지 끊임없이 고민하는 태도다. 상속과 합성은 경쟁 관계가 아니라, 상황에 따라 선택해야 할 서로 다른 도구다. 좋은 설계는 언제나 그 맥락을 정확히 판단하는 데서 시작한다.