상속과 구현
extends로 부모 클래스의 상태와 구현을 물려받아 재정의하는 상속과, implements로 클래스가 인터페이스의 모양을 지키는지 검사하는 구현을 비교하고, 구현을 물려받지 않고 다른 객체에 맡기는 합성까지 클래스 관계를 고르는 기준을 정리합니다.
클래스끼리 관계를 맺는 방법은 크게 두 가지입니다. 하나는 이미 있는 클래스의 상태와 동작을 물려받아 고치거나 더하는 상속(inheritance)이고, 다른 하나는 클래스가 어떤 인터페이스의 모양을 갖추었는지 검사받는 구현(implementation)입니다. 둘은 문법이 비슷해 보이지만, 상속은 실제 코드를 물려주고 구현은 모양만 검사한다는 점이 다릅니다.
이 글은 extends와 implements를 차례로 보고, 두 관계를 고르는 기준과 함께 구현을 물려받는 대신 다른 객체에 일을 맡기는 합성(composition)을 짧게 비교합니다. 예제는 ts-first 프로젝트에 inheritance.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
TYPE / RUNTIME / CONTRACT
extends는 구현 상속, implements는 구조 검사입니다
Dog는 Animal의 상태·동작을 재사용합니다. Robot은 Runnable과 Walkable을 직접 구현합니다. 두 관계는 한 클래스에서 함께 사용할 수도 있습니다.
EXTENDS
Animal을 상속한 Dog·Cat
Dog는 super(name)으로 이름을 초기화하고 move를 재정의하여 부모 move도 호출합니다. Cat.move는 부모 구현을 사용하며 기본 거리는 0입니다.
IMPLEMENTS
본문의 Robot·Vehicle → Runnable
Robot은 speed·run과 Walkable의 walk를 구현합니다. Vehicle은 Runnable을 구현합니다. implements는 메서드 코드를 주입하거나 매개변수 타입을 자동 생성하지 않습니다.
TYPE VIEW
변수 타입이 보이는 멤버를 정합니다
myRunner: Runnable로 Robot을 참조하면 run은 호출할 수 있지만 walk는 해당 변수 타입에 없습니다. 실제 객체가 Robot인 사실과 변수의 정적 계약은 구분됩니다.
COMPOSITION
독립 기능을 객체로 주입하는 확장
교체 가능한 동작은 별도 객체에 위임할 수 있습니다. 이 절에는 composition 실행 코드가 없고 다음 인터페이스 설계 절에서 로거 주입을 다룹니다.
클래스 부모는 하나이며 여러 구조 역할을 만족할 수 있습니다. private 상태가 자식 인스턴스에 존재해도 자식 소스에서 직접 접근할 수는 없습니다.
위 그림의 왼쪽은 Dog와 Cat이 Animal의 상태와 동작을 물려받는 상속을, 오른쪽은 Robot과 Vehicle이 Runnable의 모양을 갖추는 구현을 보여 줍니다. 두 영역은 서로 다른 관계이며, 한 클래스가 두 관계를 함께 가질 수도 있습니다. 그림의 합성 설명은 이 글에 합성을 실행하는 코드가 없다고 적었지만, 이 글의 뒤에서 Scooter가 Engine에 시동을 맡기는 예제로 합성을 직접 실행해 봅니다.
상속
상속은 extends로 적습니다. 상속받는 자식 클래스(subclass)는 부모 클래스(superclass)의 속성과 메서드를 그대로 가지고, 여기에 새 멤버를 더하거나 부모의 메서드를 같은 이름으로 다시 정의할 수 있습니다.
class Animal {
name: string;
constructor(name: string) {
this.name = name;
}
move(distanceInMeters: number = 0): void {
console.log(`${this.name}가 ${distanceInMeters}m 이동했습니다.`);
}
}
class Dog extends Animal {
breed: string;
constructor(name: string, breed: string) {
super(name);
this.breed = breed;
}
override move(distanceInMeters: number = 5): void {
console.log("개가 꼬리를 흔듭니다.");
super.move(distanceInMeters);
}
bark(): void {
console.log(`${this.name}(${this.breed})가 멍멍 짖습니다!`);
}
}
class Cat extends Animal {
purr(): void {
console.log(`${this.name}가 야옹하고 골골거립니다.`);
}
}
const myDog = new Dog("바둑이", "시바견");
myDog.bark();
myDog.move(10);
const myCat = new Cat("나비");
myCat.purr();
myCat.move();바둑이(시바견)가 멍멍 짖습니다!
개가 꼬리를 흔듭니다.
바둑이가 10m 이동했습니다.
나비가 야옹하고 골골거립니다.
나비가 0m 이동했습니다.자식 클래스의 생성자는 this를 쓰기 전에 super(...)로 부모의 생성자를 먼저 불러야 합니다. 부모가 name을 초기화해야 자식이 그 위에 breed를 더할 수 있기 때문이며, super 앞에서 this를 쓰면 'super' must be called before accessing 'this' in the constructor of a derived class.라는 TS17009가 나옵니다. Cat처럼 부모의 생성자에 넘길 값만 받는 경우에는 생성자를 생략해도 부모의 생성자가 그대로 쓰입니다.
Dog의 move는 부모의 move를 다시 정의한 재정의(override)입니다. 안에서 super.move(...)로 부모의 원래 구현을 부를 수 있어서, 꼬리를 흔드는 문장을 출력한 뒤 부모의 이동 문장을 출력합니다. 메서드 앞의 override는 이 메서드가 부모의 것을 다시 정의한다는 표시이며, 부모에 없는 메서드에 붙이면 진단이 나옵니다.
override를 빠뜨리는 실수도 잡으려면 noImplicitOverride 옵션을 켭니다. 그러면 부모의 메서드를 재정의하면서 override를 적지 않은 멤버에 This member must have an 'override' modifier because it overrides a member in the base class 'Animal'.이 나와, 부모의 메서드 이름이 바뀌었을 때 자식의 메서드가 조용히 새 메서드로 남는 일을 막아 줍니다. 이 옵션은 strict에 포함되지 않으므로 따로 켜야 합니다.
더 깊이: 부모와 자식이 초기화되는 순서
자식 클래스의 인스턴스를 만들 때 필드와 생성자가 실행되는 순서는 자바스크립트가 정해 둡니다. 공식 Handbook의 초기화 순서 설명에 따르면, 부모 클래스의 필드가 먼저 초기화되고, 부모의 생성자가 실행되고, 그다음에 자식 클래스의 필드가 초기화되고, 마지막으로 자식의 생성자가 실행됩니다. 그래서 부모가 name = "base"를, 자식이 name = "derived"를 필드로 선언하고 부모 생성자에서 this.name을 출력하면, 자식을 만들었는데도 base가 출력됩니다. 부모의 생성자가 실행되는 동안에는 자식의 필드가 아직 초기화되지 않았기 때문입니다.
같은 순서 때문에 부모 생성자에서 재정의될 수 있는 메서드를 부르는 것도 위험합니다. 재정의한 자식의 메서드는 실행되지만, 그 메서드가 읽는 자식의 필드는 아직 초기값이 들어가기 전이어서 undefined를 읽게 됩니다. 그래서 생성자에서는 그 클래스가 직접 가진 상태만 다루고, 재정의를 전제로 한 일은 인스턴스를 다 만든 뒤에 부르게 설계합니다.
부모 타입으로 다루기
자식 클래스의 인스턴스는 부모 클래스의 타입이 필요한 자리에 넣을 수 있습니다. Dog와 Cat은 모두 Animal이 가진 멤버를 가지므로 Animal[] 배열에 함께 담을 수 있고, 이렇게 같은 타입으로 다루면서 객체마다 다르게 동작하게 하는 것을 다형성(polymorphism)이라고 합니다.
const animalFarm: Animal[] = [myDog, myCat];
for (const animal of animalFarm) {
animal.move(5);
}개가 꼬리를 흔듭니다.
바둑이가 5m 이동했습니다.
나비가 5m 이동했습니다.첫 요소는 실제로 Dog이므로, 변수의 타입이 Animal이어도 Dog가 재정의한 move가 실행되어 꼬리 문장이 먼저 나옵니다. Cat은 move를 재정의하지 않았으므로 Animal의 구현이 그대로 실행됩니다. 부모 타입으로 보는 것은 쓸 수 있는 멤버를 정할 뿐 실행되는 구현을 바꾸지 않으며, 그래서 animal.bark()는 Animal 타입에 bark가 없어 진단됩니다.
구현
구현은 implements로 적습니다. class Robot implements Runnable은 "Robot의 인스턴스는 Runnable 인터페이스의 모양을 갖추어야 한다"는 뜻이며, 컴파일러는 클래스가 인터페이스의 필수 멤버를 모두 맞는 타입으로 가졌는지 검사합니다.
interface Runnable {
speed: number;
run(): void;
}
interface Walkable {
walk(distance: number): void;
}
class Robot implements Runnable, Walkable {
speed: number;
constructor(initialSpeed: number) {
this.speed = initialSpeed;
}
run(): void {
console.log(`로봇이 ${this.speed}km/h 속도로 달립니다.`);
}
walk(distance: number): void {
console.log(`로봇이 ${distance}m 걸었습니다.`);
}
}
class Vehicle implements Runnable {
constructor(public speed: number) {}
run(): void {
console.log(`차량이 ${this.speed}km/h 속도로 주행합니다.`);
}
}
let myRunner: Runnable = new Robot(10);
myRunner.run();
myRunner = new Vehicle(100);
myRunner.run();로봇이 10km/h 속도로 달립니다.
차량이 100km/h 속도로 주행합니다.클래스는 부모 클래스를 하나만 상속할 수 있지만, 인터페이스는 Robot처럼 여러 개를 동시에 구현할 수 있습니다. Robot과 Vehicle은 서로 아무 관계가 없는 클래스이지만, 둘 다 Runnable의 모양을 갖추었으므로 Runnable 타입의 변수에 번갈아 넣을 수 있습니다. 다만 myRunner의 타입은 Runnable이므로, 실제 객체가 Robot이어도 myRunner.walk(1)은 Property 'walk' does not exist on type 'Runnable'.로 진단됩니다.
필수 멤버를 빠뜨리면 클래스 선언에서 바로 진단됩니다. class Broken implements Runnable { speed = 1; }은 run이 없으므로 Class 'Broken' incorrectly implements interface 'Runnable'.로 시작하는 TS2420이 나옵니다.
implements는 검사만 할 뿐 클래스에 아무것도 물려주지 않는다는 점에 주의합니다. 인터페이스에 적은 메서드의 본문은 물론, 매개변수의 타입도 클래스 쪽으로 전해지지 않으므로, 클래스의 메서드에도 매개변수의 타입을 직접 적어야 합니다. 컴파일한 자바스크립트에서는 implements 절과 인터페이스가 모두 지워집니다.
합성으로 기능 맡기기
상속은 부모의 구현을 그대로 물려받는 대신, 부모가 바뀌면 자식도 함께 영향을 받는 강한 관계입니다. 다른 클래스의 기능이 필요할 뿐 그 클래스의 한 종류는 아니라면, 그 기능을 가진 객체를 속성으로 두고 일을 맡기는 합성이 더 느슨한 선택입니다.
class Engine {
start(): string {
return "엔진 시동";
}
}
class Scooter implements Runnable {
speed = 30;
private engine = new Engine();
run(): void {
console.log(`${this.engine.start()} 후 ${this.speed}km/h로 달립니다.`);
}
}
new Scooter().run();엔진 시동 후 30km/h로 달립니다.Scooter는 Engine을 상속하지 않고 engine 속성으로 가지고 있다가, 시동이 필요할 때 그 객체의 start를 부릅니다. "A는 B의 한 종류다"가 자연스러우면 상속을, "A는 B를 가지고 있다"가 자연스러우면 합성을 떠올리면 됩니다. 엔진을 다른 구현으로 바꾸고 싶을 때 Scooter의 부모를 바꿀 필요가 없다는 것도 합성의 장점입니다.
상속과 구현 고르기
| 비교 | 상속 extends | 구현 implements |
|---|---|---|
| 관계 | 자식은 부모의 한 종류이다 | 클래스가 인터페이스의 모양을 갖춘다 |
| 물려받는 것 | 부모의 상태와 메서드 구현 | 없다, 모양의 검사만 받는다 |
| 개수 | 부모 클래스는 하나 | 인터페이스는 여러 개 |
| 실행 중에 남는 것 | 프로토타입으로 이어진 부모 클래스 | 아무것도 남지 않는다 |
공통의 구현을 함께 쓰고 일부만 재정의해야 한다면 상속이, 서로 다른 계층의 클래스가 같은 역할을 해야 한다면 구현이 알맞습니다. 두 관계는 함께 쓸 수도 있어서, 한 클래스가 부모 클래스를 하나 상속하면서 여러 인터페이스를 구현할 수 있습니다. 상속의 계층이 깊어질수록 부모의 변경이 넓게 퍼지므로, 물려받을 구현과 지켜야 할 역할을 나누어 생각하면 계층을 얕게 유지할 수 있습니다.
정리
extends로 상속한 자식 클래스는 부모의 상태와 메서드를 물려받고, 생성자에서는this를 쓰기 전에super(...)를 불러야 합니다.- 재정의한 메서드에는
override를 붙일 수 있고,noImplicitOverride를 켜면override를 빠뜨린 재정의를 알려 줍니다. - 부모 타입의 변수로 자식 인스턴스를 다루어도 실행되는 것은 실제 객체가 재정의한 구현이며, 쓸 수 있는 멤버는 변수의 타입이 정합니다.
implements는 클래스가 인터페이스의 필수 멤버를 갖추었는지 검사할 뿐, 구현이나 매개변수의 타입을 물려주지 않습니다.- 다른 클래스의 한 종류가 아니라 그 기능만 필요하다면, 상속 대신 그 객체를 속성으로 두고 일을 맡기는 합성을 고려합니다.
연습 문제
1. 부모 타입의 변수
const pet: Animal = new Dog("초코", "푸들"); pet.move();는 무엇을 출력하나요? 이어서 pet.bark()를 바로 부를 수 있나요?
해설 보기
실제 객체는 Dog이므로 Dog의 move가 실행되고, 인수를 넘기지 않았으니 Dog가 정한 기본값 5가 쓰여 개가 꼬리를 흔듭니다.와 초코가 5m 이동했습니다.가 출력됩니다. 하지만 pet의 타입은 Animal이고 Animal에는 bark가 없으므로, pet.bark()는 Property 'bark' does not exist on type 'Animal'.로 진단됩니다.
2. 인터페이스 타입의 변수
const runner: Runnable = new Robot(7); runner.run();의 출력과, runner.walk(2)의 검사 결과는 무엇인가요?
해설 보기
run은 로봇이 7km/h 속도로 달립니다.를 출력합니다. 실제 객체에는 walk가 있지만 Runnable에는 walk가 없으므로, runner.walk(2)는 TS2339로 진단됩니다. 인터페이스 타입의 변수에 넣는다고 객체에서 멤버가 잘려 나가지는 않으며, 그 타입으로는 쓸 수 없을 뿐입니다.
3. 전해지지 않는 매개변수 타입
다음 코드는 검사를 통과하나요? 통과하지 않는다면 어떻게 고쳐야 하나요?
interface Checkable {
check(name: string): boolean;
}
class NameChecker implements Checkable {
check(s) {
return s.toLowerCase() === "ok";
}
}해설 보기
통과하지 않습니다. implements는 인터페이스의 매개변수 타입을 클래스의 메서드에 전해 주지 않으므로 s는 타입을 알 수 없고, strict의 noImplicitAny 때문에 Parameter 's' implicitly has an 'any' type.이라는 TS7006이 나옵니다. check(s: string): boolean처럼 클래스의 메서드에 타입을 직접 적으면, 인터페이스와 맞는지도 함께 검사됩니다.
다음 글 추상 클래스에서는 직접 인스턴스를 만들 수 없고, 자식 클래스가 반드시 채워야 할 메서드를 남겨 두는 추상 클래스를 다룹니다.