class inheritance & composition
객체 지향 프로그래밍은 코드의 재사용성과 유지보수성을 높이기 위해 다양한 설계 원칙을 제공한다. 그중 클래스 상속(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; // 펭귄은 날지 않으므로 false 반환
}
}
const eagle = new Bird('Eagle');
console.log(eagle.canFly()); // true
const penguin = new Penguin('Penguin');
console.log(penguin.canFly()); // false
problem of inheritance
앞서 작성한 Penguin 클래스에는 bark 메소드가 따로 존재하지 않지만, 부모 클래스에 bark 메소드가 존재하기에 자동으로 이를 상속받는다. 이는 곧 부모 클래스에 새로운 메서드가 추가될 때마다 자동으로 모든 자식 클래스에 전파된다는 뜻이다. 따라서 부모 클래스에 새로운 기능을 개발하는 데 있어 자식 클래스의 동작을 다형성적인 측면에서 예상하기 어려워진다는 문제가 발생한다.
const peng = new Penguin("peng");
peng.bark(); // "peng"
가령 이런 경우를 상상해보자. 일반적으로 새는 수영할 수 없다. 따라서 우리는 Bird 클래스에 canSwim 메서드를 만들고, 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 클래스를 구성하면 그 코드는 아래와 같을 것이다. 이렇게 코드만 놓고 보면 별반 차이가 없는 것 같고, super 없이 구현하려다보니 더 번거로워진 것처럼 느껴질 수도 있다.
class Penguin {
bird;
constructor(private _name: string) {
this.bird = new Bird(this._name);
}
bark() {
this.bird.bark();
}
canFly() {
return false;
}
}
하지만 컴포지션의 장점은 부모 클래스가 변경되어도 그 내용을 자식 클래스가 자동으로 상속하지 않는다는 점이다. 따라서 Bird 클래스에 canSwim을 추가하여도 Penguin 클래스에는 이 메소드가 존재하지 않는다. 그리하여 이후 다른 개발자가 Penguin 클래스로 인스턴스를 만들어 테스트를 돌려보면 아래와 같은 상황과 마주하게 되는 것이다.
우리의 뉴비 개발자는 타입 에러를 통해 Penguin 클래스에 canSwim 메소드가 들어있지 않다는 사실을 알게 된다. 그럼 클래스가 선언된 곳으로 가서 canSwim() { return false }를 추가해주기만 하면 되는 것이다. 이처럼 컴포지션을 사용하면 타입스크립트의 도움을 받을 수있기에 상속을 사용할 때보다 문제 코드를 더 빨리 찾아낼 수 있다.
이러한 타입스크립트의 도움을 극대화하고 코드 재사용성을 늘리고자 한다면 Penguin 클래스를 Bird 클래스로 상속하거나 구현하는 것이 아니라, 인터페이스를 사용하는 것이 좋다.
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 원칙 중 하나인 의존성 역전 원칙을 따라 클래스와 함수 둘 다 추상화에 의존하게 될 경우 duck-typing을 통해 앞서 언급된 문제들을 대부분 해결할 수 있다.
function swimableFn(swimable: Swimable) {
swimable.swim()
}
const magpie = new Magpie();
swimableFn(magpie) // 'swim' 속성이 'Magpie' 형식에 없지만 'Swimable' 형식에서 필수입니다
이처럼 클래스 합성과 구현을 조합하면 대규모 프로젝트에서 휴먼 에러를 효과적으로 최소화할 수 있다. 그런데 모든 경우에서 클래스 컴포지션이 더 나은 방식이냐고 묻는다면 또 그건 아니다.
앞서 잠깐 언급하기는 했지만, 클래스 컴포지션은 super 없이 구현하려다보니 상속에 비해 코드가 좀 더 번거로워지는 경향이 있다. 또, Bird와 Penguin의 관계가 아닌 완벽한 부모자식 관계, 예를 들면 User와 PremiumUser처럼 자식 클래스가 부모의 모든 것을 반드시 가지고 있어야 할 경우라면 상속이 압도적으로 좋다.
따라서 개발자는 클래스를 작성할 때, 상속과 컴포지션 사이에서 더 나은 방식을 고민해야 늘 고민해야 한다고 생각한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden