post-thumbnail

[Typescript] Abstract Class vs Interface

typescript
CS
114
5

추상클래스? 인터페이스?

추상 클래스랑 인터페이스의 차이는 무엇인가요?
일전에 아는 형이 면접질문으로 해당 질문을 받았다고 얘기했습니다. 그래서 이번 기회에 정리해보려고 합니다.

추상 클래스 (Abstract Class)

추상 클래스를 정의할 때는 class 앞에 abstract라고 표기합니다. 또한 추상 메서드를 정의할 때도 abstract라고 표기합니다.
일반적인 추상 클래스의 정의는 "하나이상의 추상 메서드를 포함하는 클래스"입니다. 하지만 abstract 키워드를 통해 추상 메서드가 없는 추상 클래스를 만들 수도 있습니다. (어떤 분이 설명이 하시길 추상 클래스는 "객체를 설계할 때, 추상화 작업, 즉 객체들의 공통 부분을 추출하는 부분에서 다형성을 구현할 수 있는 방법 중 하나"라고 설명하셨습니다.)
1abstract class Animal {
2  name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7
8  // 추상 메서드
9  abstract makeSound(): void;
10  // 일반 메서드
11  move(): void {
12    console.log("roaming the earth...");
13  }
14}
추상 메서드는 메서드의 선언만 있고, 구현이 없는 메서드입니다. 추상 메서드를 선언할 때는 abstract 키워드를 사용합니다. 추상 클래스 자체를 인스턴스화 할 수 없는 이유는 추상 메서드가 구현되지 않았기 때문입니다.
추상 클래스를 상속받은 클래스는 반드시 추상 클래스의 추상 메서드를 구현해야 합니다. 또한 하위 클래스에서 상위 클래스에 있는 메서드를 재정의할 수 있습니다.
1class Dog extends Animal {
2  // 메서드 재정의
3  move() {
4    console.log("running...");
5  }
6
7  // 추상 메서드 구현
8  makeSound() {
9    console.log("bow wow");
10  }
11}
12
13class Cat extends Animal {}
14// Error: Non-abstract class 'Cat' does not implement inherited abstract member 'makeSound' from class 'Animal'.

인터페이스 (Interface)

동적 타입 언어인 JavaScript에서는 객체의 형태를 정의할 수 없습니다. 이러한 문제를 해결하기 위해 TypeScript에서는 인터페이스를 제공합니다. 인터페이스는 객체의 형태를 정의하는 역할을 합니다.
인터페이스는 interface 키워드를 사용하여 정의합니다. 인터페이스는 상호 간에 정의한 약속 혹은 규칙입니다. Typescript에서 인터페이스는 다음과 같은 범주에 대해 약속을 정의할 수 있습니다.
  • 객체의 스펙(속성과 속성의 타입)
  • 함수의 파라미터
  • 함수의 반환 타입
  • 배열과 객체의 인덱싱 방식
  • 클래스
현재 글에선 추상 클래스와 인터페이스의 차이에 대해 집중하고 있기 때문에 클래스에 대한 인터페이스만 다루겠습니다.

클래스에 대한 인터페이스

클래스에 대한 인터페이스는 클래스의 구조를 정의합니다. 클래스의 구조를 정의하기 위해서는 클래스의 멤버 변수와 메서드를 포함해야 합니다. 인터페이스는 클래스의 구조만을 정의하고 구현은 포함하지 않습니다. (즉, 껍데기만 정의합니다.)
1interface Animal {
2  name: string;
3  makeSound(): void;
4  move(): void;
5}
다음과 같이 정의된 인터페이스는 implements 키워드를 사용하여 클래스를 구현할 수 있습니다.
1class Dog implements Animal {
2  name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7
8  makeSound() {
9    console.log("bow wow");
10  }
11
12  move() {
13    console.log("running...");
14  }
15}

추상 클래스 vs 인터페이스

추상 클래스와 인터페이스의 차이점에 대해 3가지 관점으로 접근해보겠습니다.
  • 구현 방식
  • 상속(extends)와 구현(implements)
  • 접근 제한자

구현 방식

앞서 설명했듯이 추상 클래스는 추상 메서드를 포함할 수 있습니다. 또한 상위 클래스에서 구현한 메서드를 해당 클래스를 상속받은 모든 클래스가 공유합니다. 이는 추상 클래스가 일반적으로 공통의 구현을 제공하고 하위 클래스에서 구현을 확장하는 데 사용된다는 것을 의미합니다.
1abstract class Animal {
2  name: string;
3
4  constructor(name: string) {
5    this.name = name;
6  }
7
8  abstract makeSound(): void;
9
10  move(): void {
11    console.log("roaming the earth...");
12  }
13}
14
15class Dog extends Animal {
16  constructor(name: string) {
17    super(name);
18  }
19
20  // 추상 메서드 구현
21  makeSound() {
22    console.log("bow wow");
23  }
24
25  // 상위 클래스의 메서드를 재정의
26  move() {
27    // 상위 클래스의 메서드 호출
28    super.move();
29    console.log("running...");
30  }
31}
반면 인터페이스는 구현을 포함하지 않습니다. 인터페이스는 클래스의 구조만을 정의하고 구현은 포함하지 않습니다. 또한 클래스가 인터페이스를 구현(implements)하면 해당 클래스는 인터페이스에 정의된 모든 메서드를 구현해야 합니다.
1interface Animal {
2  name: string;
3  makeSound(): void;
4  move(): void;
5}
6
7class Dog implements Animal {
8  name: string;
9
10  constructor(name: string) {
11    this.name = name;
12  }
13
14  makeSound() {
15    console.log("bow wow");
16  }
17
18  move() {
19    console.log("running...");
20  }
21}
22
23class Cat implements Animal {
24  name: string;
25
26  constructor(name: string) {
27    this.name = name;
28  }
29
30  makeSound() {
31    console.log("meow");
32  }
33}
34// Error: Class 'Cat' incorrectly implements interface 'Animal'.

상속(extends)와 구현(implements)

추상 클래스는 클래스 간의 상속을 통해 재사용을 가능하게 합니다. 추상 클래스는 extends 키워드를 사용하여 상속받을 수 있습니다. 추상 클래스는 단일 상속만 지원합니다.
왜 단일 상속만 지원할까요? 만약 두 개이상의 클래스가 동일한 추상 클래스를 상속하고 그 클래스 들이 하나에 클래스에게 상속될 경우 "다이아몬드 문제"가 발생할 수 있습니다.
1class Animal {
2  makeSound() {
3    console.log("roaming the earth...");
4  }
5}
6
7class Dog extends Animal {
8    makeSound() {
9        console.log("bow wow");
10    }
11}
12
13class Cat extends Animal {
14    makeSound() {
15        console.log("meow");
16    }
17}
18
19// Error
20class DogCat extends Dog, Cat {
21    makeSound() {
22        super.makeSound(); // ??
23    }
24}
상속 관계가 다음과 같은 구조이기 때문에 다이아몬드 문제라고 부릅니다

상속 관계가 다음과 같은 구조이기 때문에 다이아몬드 문제라고 부릅니다

위와 같이 DogCat 클래스가 DogCat 클래스를 상속받을 수 있다면 super.makeSound() 호출 시 어떤 클래스의 makeSound 메서드를 호출해야 할지 알 수 없습니다.
이러한 문제를 피하기 위해 TypeScript는 단일 상속만을 지원하며 interface를 통해 구현(implements)하는 방식으로 다중 상속을 대체합니다.
1interface Animal {
2  makeSound(): void;
3}
4
5interface Movable {
6  move(): void;
7}
8
9class Dog implements Animal, Movable {
10  makeSound() {
11    console.log("bow wow");
12  }
13
14  move() {
15    console.log("running...");
16  }
17}

접근 제한자

추상 클래스는 "public", "protected", "private"와 같은 접근 제한자를 사용할 수 있습니다. 반면 인터페이스는 접근 제한자를 사용할 수 없습니다. (다만 "readonly"와 같은 속성 제한자는 사용할 수 있습니다.)
1class Animal {
2  // 어디서든 접근 가능
3  public name: string;
4  // 클래스 내부와 상속받은 클래스에서만 접근 가능
5  protected age: number;
6  // 클래스 내부에서만 접근 가능
7  private weight: number;
8}

그래서 둘 중에 뭐가 더 좋을까?

앞서 설명한 내용을 정리하면 다음과 같습니다.
  • Interface: 클래스에서 공통된 메서드와 속성을 정의할 때 사용, implements를 통해 다중 상속을 대체
  • Abstract Class: 클래스에서 공통된 메서드와 속성을 정의할 깨 사용, 구현을 재공하고 하위 클래스에서 구현을 확장할 때 사용, 코드의 중복을 줄이고 상속을 통해 계층 구조를 만들 때 사용
추상 클래스와 인터페이스는 각각의 장단점이 있습니다. 인터페이스가 속히 말하는 껍데기만 정의한다고 쓸모가 없는 것은 아닙니다. 인터페이스는 implements를 통해 다중 상속을 대체할 수 있고 추상 클래스는 기본적인 구현을 재공할 수 있으며 하위 클래스에서 구현을 확장할 수 있습니다. 어느 것이 더 좋다고 말하기 보단 상황에 맞게 사용하는 것이 중요합니다.

참고