Sqsung DevLog

<타입스크립트 프로그래밍> 5장: 클래스와 인터페이스 정리 본문

TypeScript

<타입스크립트 프로그래밍> 5장: 클래스와 인터페이스 정리

sqsung 2023. 5. 29. 15:31

5장: 클래스와 인터페이스

5-1. 클래스와 상속

  • 타입스크립트는 클래스의 프로퍼티와 메서드에 3 가지 접근 한정자를 제공한다

    • public: 어디에서나 접근할 수 있음 (default)
    • protected: 이 클래스와 서브클래스의 인스턴스에서만 접근 가능함
    • private: 이 클래스의 인스턴스에서만 접근할 수 있음
    type Color = 'Black' | 'White';
    type File = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
    type Rank = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
    
    /**
     * @private 접근 한정자는 자동으로 매개변수를 this에 할당하며 (eg. file => this.file) 가시성은 private으로 한다
    */
    class Position {
      constructor(
        private file: File,
        private rank: Rank
      ) { }
    }
    
    class Piece {
      protected position: Position // 마찬가지로 this에 할당, 하지만 Piece 및 서브클래스 인스턴스에게 모두 접근 허용
      constructor(
        prviate readonly color: Color, // 클래스 인스턴스만 접근 가능하며 변경 불가능
        file: File,
        rank: Rank,
      ) {
        this.position = new Position(file, rank);
      }
    }
  • abstract 키워드를 사용하면 사용자가 인스턴스를 직접 생성하지 못하게 막고, 클래스를 상속받은 클래스를 통해서만 인스턴스화할 수 있도록 허용할 수 있다

    abstract class Piece {
      constructor(
        //...
      )
    }
    
    // 직접 인스턴스를 생성하려고 하면 에러가 발생한다
    new Piece('White', 'E', 1); // Error: 추상 클래스의 인스턴스는 생성할 수 없음
  • 또한, 필요한 메서드를 추상 클래스에 자유롭게 추가할 수 있다

  • 아래 예제의 경우 canMoveTo라는 메서드를 주어진 시그니처와 호환되도록 구현해야 함을 하위 클래스에 알리는 것이다

    abstract class Piece {
      moveTo(position: Position) {
        this.position = position;
      }
    
      // *Piece를 상속받았으나, canMoveTo 메서드 구현 안하면 컴파일 타임에 타입 에러 발생
      abstract canMoveTo(position: Position): boolean
    }
  • Piece를 상속받은 하위 클래스이자, canMoveTo를 가지고 있는 King 클래스를 만들면 아래와 같다

    class Position {
      // ...
      distanceFrom(position: Position) {
        return {
          rank: Math.abs(position.rank - this.rank);
          file: Math.abs(position.file.charCodeAt(0) - this.file.charCodeAt(0));
        }
      }
    }
    
    class King extends Piece {
      canMoveTo(position: Position) {
        let distance = this.position.distanceFrom(position);
        return distance.rank < 2 && ditsance.file < 2;
      }
    }

타입스크립트에서의 클래스를 요약하자면 아래와 같다 :

  1. class 키워드로 클래스를 선언한 후 extends 키워드로 다른 클래스를 상속 받을 수 있다

  2. 구체 클래스와 추상 클래스가 존재하며, 추상 클래스의 경우 추상 메서드와 추상 프로퍼티를 가질 수 있다

  3. 메서드는 default로 public이지만, private, protected, public 중 한 가지의 한정자를 가질 수 있다. 또한, 메서드는 인스턴스 메서드와 정적 메서드 두 가지로 구분된다

    💡 인스턴스 vs. 정적 메서드
    
    1. 정적 메서드는 인스턴스를 생성하지 않아도 사용 가능한 메서드 (예: Array.isArray(arr))
    
    2. 인스턴스 메서드는 class prototype에 소속되어 모든 인스턴스에게 상속되며, 인스턴스를 통해 사용해야 함 (예: arr.push(4))
  4. 클래스는 인스턴스 프로퍼티도 가질 수 있으며, 이 프로퍼티들은 private, protected, public 중 한 가지 한정자를 갖는다 (마찬가지로 public이 기본값). constructor의 매개변수나 프로퍼티 초기자에도 이들 한정자를 사용할 수 있다.

  5. 인스턴스 프로퍼티를 선언할 때 readonly를 추가할 수 있다


5-2. super

  • constructor에서만 호출할 수 있는 super() 특별한 타입의 생성자 호출은 자식 클래스에 생성자 함수가 있으면 무조건 호출해야 한다 (안하면 부모 클래스와 정상적으로 연결되지 않음)
  • 또한, 자식 인스턴스는 super를 이용해 부모 버전의 메서드 호출할 수 있다
    • 예를 들어 자식, 부모 클래스 모두 getName이라는 메서드를 가지고 있다고 치면, 자식 클래스의 getName 메서드가 부모의 getName 메서드를 overriding 하지만, super.getName으로 호출하면 부모 버전의 메서드를 사용할 수 있다
    • 단, super 키워드로 부모의 메서드에는 접근할 수 있지만 프로퍼티에는 접근할 수 없다

5-3. this를 반환 타입으로 사용하기

  • this를 값뿐 아니라 타입으로도 사용할 수 있다

  • 클래스를 정의할 때라면 메서드의 반환 타입을 지정할 때 this 타입을 유용하게 사용할 수 있다

  • 예를 들어 간단한 Set 클래스를 만들어보면 아래와 같을 것이다

    class Set {
      has(value: number): boolean {
        // has: Set 인스턴스가 특정 값을 가지고 있는지 확인하는 인스턴스 메서드
      }
    
      add(value: number): Set {
        // add: Set 인스턴스에 특정 값을 추가한 후, 변경된 Set 인스턴스 반환하는 메서드
      }
    }
  • Set 클래스를 상속받는 MutableSet 서브클래스를 구현한다고 치면, has 메서드는 문제 없이 상속받을 수 있지만, add 메서드의 경우 Set 인스턴스를 반환해야 한다. 서브클래스에서 add 메서드를 현재 상태 그대로 상속받아 사용하면 MutableSet 대신 Set 인스턴스를 반환햔다.

  • MutableSet에서는 새로운 add 메서드로 오버라이드해야 한다.

    class MutableSet extends Set {
      delete(value: number): boolean {
        // MutableSet 인스턴스만 가지고 있는 delete 메서드
      }
    
      add(value: number): MutableSet {
        // Set으로부터 상속받은 add 메서드 오버라이드
      }
    }
  • 하지만 이렇게 하면 모든 서브클래스는 this를 반환하는 모든 메서드의 시그니처를 오버라이드해야 하므로, 클래스와 상속구조를 이용하는 것의 의미가 없어졌다

  • 대신, 부모클래스에서 반환 타입을 this로 지정하면 이 번거로운 작업을 타입스크립트가 알아서 해준다

    class Set {
      has(value: number): boolean {
        // ...
      }
    
      add(value: number): this {
        // Set의 this는 Set 인스턴스를, MutableSet의 this는 MutableSet의 인스턴스를 자동으로 가리킴
        // 즉, MutableSet에서 add 메서드를 오버라이드할 필요 없어짐
      }
    }

5-4. 인터페이스

  • 클래스는 주로 인터페이스를 통해 사용할 때가 많다

  • type 키워드처럼 인터페이스도 타입에 이름을 지어주는 수단이지만, 인터페이스를 사용하면 타입을 더 깔끔하게 정의할 수 있다

  • 타입과 인터페이스의 차이는 다음과 같다

    1. 타입 별칭의 오른편에는 타입 표현식(타입, 그리고 &, | 등의 타입 연산자)를 포함한 모든 타입이 등장할 수 있지만, 인터페이스의 오른 편에는 반드시 형태가 나와야 한다.
    2. 인터페이스를 상속할 때 타입스크립트는 상속받는 인터페이스의 타입에 상위 인터페이스를 할당할 수 있는지 확인한다
    interface A {
      good(x: number): string
      bad(x: number): string
    }
    
    interface B extends A {
      good(x: string | number): string
      bad(x: string): string // Error: number 타입은 string 타입에 할당할 수 없음
    }
    1. 이름과 범위가 같은 인터페이스가 여러 개 있다면 이들이 자동으로 합쳐진다 (선언 합침)

5-4-1. 선언 합침

  • 선언 합침declaration merging은 컴파일러가 같은 이름으로 선언된 개별적인 선언 두 개를 하나의 정의로 합치는 것을 의미한다

    // 1. User 라는 interface는 한 개의 필드('name')을 가지고 있음
    interface User {
      name: string
    }
    
    // 2. 이제 User라는 interface는 두 개의 필드('name, age')를 가지고 있음
    interface User {
      age: number
    }
    
    // 3. User가 선언 합침되었기 때문에 에러가 발생하지 않음
    let a: User = {
      name: 'James',
      age: 29
    }
  • 같은 로직을 type 키워드를 사용해서 표현하면 충돌이 일어나게 된다

    type User = { name: string };
    type User = { age: number }; // Error: 중복된 식별자 'User'
  • 선언 합침을 통해 동명의 인터페이스를 사용하더라도, 프로퍼티의 타입 간의 충돌이 일어나면 에러가 발생한다

    interface User { age: string };
    interface User { age: number }; // Error: 프로퍼티 age는 반드시 string 타입이어야 됨
  • 또한 제네릭을 선언한 인터페이스들의 경우, 제네릭들의 선언 방법과 이름까지 똑같아야 합칠 수 있다

    interface User<Age extends number> { age: Age }
    interface User<Age extends string> { age: Age } // Error: User의 모든 선언은 같은 타입 매개변수를 가져야 함

5-4-2. 구현

  • 클래스를 선언할 때 implements라는 키워드를 이용해 특정 인터페이스를 만족 시킴을 표현할 수 있다

    interface Animal {
      eat(food: string): void
      sleep(hours: number): void
    }
    
    class Cat implements Animal {
      eat(food: string) {
        console.info(`Ate some ${food}, Mmm!`);
      }
    
      sleep(hours: number) {
        console.info(`Slept for ${hours} hours!`);
      }
    }
  • Cat은 Animal이 선언하는 모든 메서드를 구현해야 하며, 필요하면 메서드나 프로퍼티를 추가로 구현할 수 있다

  • 인터페이스로 인스턴스 프로퍼티를 정의할 수 있지만, 가시성 한정자(private, protected, public)는 선언할 수 없으며, static 키워드도 사용할 수 없다. readonly로 설정할 수는 있다.

  • 또한, 한 클래스가 하나의 인터페이스만 구현할 수 있는 것은 아니므로 필요하면 여러 인터페이스를 구현할 수 있다

    interface Animal {
      readonly name: string,
      eat(food: string): void,
      sleep(hours: number): void
    }
    
    interface Feline {
      meow(): void
    }
    
    class Cat implements Animal, Feline {
      name = 'Whiskers'
    
      eat(food: string) {
        console.info(`Ate some ${food}, Mmm!`);
      }
    
      sleep(hours: number) {
        console.info(`Slept for ${hours} hours!`);
      }
    
      meow() {
        console.info('Meowwww!');
      }
    }

5-4-3. 인터페이스 구현 vs. 추상 클래스 상속

  • 인터페이스는 아무런 자바스크립트 코드를 만들지 않으며, 컴파일 타임에만 존재하지만, 추상 클래스는 런타임의 자바스크립트 클래스 코드를 만든다
  • 반면 추상 클래스는 기능이 더 풍부하다. 예로, 생성자 함수와 기본 구형늘 가질 수 있으며, 프로퍼티와 메서드에 접근 한정자를 지정할 수 있다 (모두 인터페이스에서는 제공되지 않음)
  • 여러 클래스에서 공유하는 구현이라면 추상 클래스를 사용하고, 가볍게 클래스의 형태를 정의할 때는 인터페이스를 사용하는 것이 좋다

5-5. 클래스는 구조 기반 타입을 지원한다

  • 타입스크립트는 클래스를 비교할 때 다른 타입과 달리 이름이 아니라 구조를 기준으로 삼는다

    class Zebra {
      trot() { /* ... */ }
    }
    
    class Poodle {
      trot() { /* ... */ }
    }
    
    function ambleAround(animal: Zebra) {
      animal.trot();
    }
    
    const zebra = new Zebra;
    const poodle = new Poodle;
    
    /**
     * 타입스크립트 입장에서는 Zebra, Poodle 두 클래스 모두 `.trot`을 구현하며 서로 호환된다
     * Zebra 클래스를 받는 `ambleAround` 함수에 Zebra 대신 Poodle을 전달해도 아무 문제 없다
    */
    ambleAround(zebra); // OK
    ambleAround(poodle); // OK
  • 단, 클래스에 private/protected 필드가 있고, 할당하려는 클래스 및 서브클래스의 인스턴스가 아니라면 할당할 수 없다고 판정한다

    class A {
      private x = 1
    }
    
    class B extends A {
      // ...
    }
    
    function randomFunc(param: A) {
      // ...
    }
    
    randomFunc(new A); // OK
    randomFunc(new B); // OK
    
    // 'A'의 프로퍼티는 privte 이지만 { x: number }는 private이 아니므로 실패
    randomFunc({ x: 1}) // Error!

5-6. 클래스는 값과 타입을 모두 선언한다

  • 타입스크립트에서 값과 타입은 별도의 네임스페이스에 존재한다

  • 사용 방법에 따라 타입스크립트가 알아서 값 또는 타입으로 해석한다

    if (typeOrValue + 1 > 3); // 값으로 추론
    
    let x: typeOrValue = 3; // 타입으로 추론
  • 하지만 클래스와 열거형은 특별하게 값 네임스페이스에 값을, 타입 네임스페이스에 타입을 동시 생성한다

    class C { /* ... */ }
    
    let c: C = new C;
    
    enum E { F, G}
    
    let e: E = E.F

5-7. 다형성

  • 함수와 타입처럼, 클래스와 인터페이스도 기본값과 상한/하한 설정을 포함한 다양한 제네릭 타입 매개변수 기능을 지원한다

  • 제네릭 타입의 범위는 클래스나 인터페이스가 되게 할 수도 있고 특정 메서드로 한정할 수도 있다

    // 1. class와 제네릭을 선언했으므로 클래스 전체에서 타입을 사용할 수 있다 (MyMap의 모든 메서드 및 프로퍼티)
    class MyMap<K, V> {
      // 2. 생성자 함수에는 제네릭 타입을 선언할 수 없다. constructor 대신 class 선언에 사용해야 한다
      constructor(initialKey: K, initialValue: V) {
        // ...
      }
    
      // 3. class로 한정된 타입은 클래스 내부의 어디에서나 사용 가능하다
      get(key: K): V {
        // ...
      }
    
      set(key: K, value: V): void {
        // ...
      }
    
      // 4. 인스턴스 메서드는 클래스 수준의 제네릭을 사용할 수 있으며, 자신만의 제네릭 타입 (V1, K1)을 추가로 선언할 수 있다
      merge<K1, V1>(map: MyMap<K1, V1>): MyMap<K | K1, V | V1> {
        // ...
      }
    
      // 5. 정적 메서드는 class 수준의 제네릭을 사용할 수 없다
      // 5a. of는 1에서 선언한 K, V에 접근할 수 없고 자신만의 K, V를 직접 선언했다
      static of<K, V>(k: K, v: V): MyMap<K, V> {
        // ...
      }
    }
  • 새로운 인스턴스를 생성할 때는 제네릭에 구체 타입을 명시하거나, 타입스크립트가 타입을 추론하도록 할 수 있다

    let a = new MyMap<string, number>('k', 1); // 명시: MyMap<string, number>
    let b = new MyMap('k', true); // 추론: MyMap<string, boolean>
  • 인터페이스에서도 제네릭을 사용할 수 있다

    interface MyMap<K, V> {
      get(key: K): V
      set(key: K, value: V): void
    }

5-8. 믹스인

  • 믹스인mixin은 클래스 A가 클래스 B를 확장해서 기능을 상속받는 것이 아니라, 함수 B가 클래스 A를 받고 기능이 추가된 새로운 클래스를 반환하는 것이다.
💡 믹스인 (mixin)

1. 생성자(constructor)를 받음
2. 생성자를 확장하여 새 기능 추가한 클래스 생성
3. 새 클래스 반환
// 1. 모든 생성자를 표현하는 ClassConstructor 타입 선언
type ClassConstructor = new (...args: any[]) => {};

/**
 * 2. 한 개의 매개변수 C만 받는 withEZDebug 믹스인 함수
 * extends로 강제했듯이 C는 최소한 클래스 생성자여야 한다
 */
function withEZDebug<C extends ClassConstructor>(Class: C) {
  // 3. 생성자를 인수로 받아 생성자를 반환하는 함수이므로 익명 클래스 생성자 반환
  return class extends Class {
    // 4. 매개변수로 받는 클래스가 받는 인수는 다 받을 수 있어야 한다
    constructor(...args: any[]) {
      // 5. 익명 클래스는 다른 클래스를 상속받으므로 Class의 생성자를 호출해야 함
      super(...args)
    }
  }
}

// * constructor 로직이 따로 없는 경우 4, 5번 스텝은 넘어가도 된다

5-10. final 클래스 흉내내기

  • 객체지향언어들에서 final 키워드는 클래스나 메서드를 확장하거나 오버라이드할 수 없게 만드는 기능이다

  • 타입스크립트에서는 비공개 생성자(private constructor)로 final 클래스를 흉내낼 수 있다

  • 생성자를 private으로 선언하면 new로 인스턴스를 생성하거나 클래스를 확장할 수 없게 된다

    class MesageQueue {
      private constructor(private messages: string[]) { /* ... */ }
    }
  • 하지만 이렇게 constructor만 private으로 선언하는 경우, 상속을 막는 것은 물론 인스턴스 생성도 불가해진다 (반대로 final 키워드를 사용하면 상속만 막을 뿐, 인스턴스는 정상 생성 가능하다)

  • 이를 위해 해당 클래스를 반환하는 새로운 정적 메서드를 구현해두면 된다

    class MessageQueue {
      private constructor(private messages: string[]) { /* ... */ }
    
      static create(messages: string[]) {
        return new MessageQueue(messages);
      }
    }
    
    MessageQueue.create([]); // MessageQueue
    
    class BadQueue extends MessageQueue { } // Error: MessageQueue 클래스를 확장할 수 없음

5-11. 디자인 패턴

5-11-1. 팩토리 패턴

  • 팩토리 패턴은 어떤 객체를 만들지 전적으로 팩토리에 위임한다

    type Shoe = {
      purpose: string
    }
    
    class BalletFlat implements Shoe {
      purpose = 'dancing'
    }
    
    class Boot implements Shoe {
      purpose = 'woodcutting'
    }
    
    class Sneaker implements Shoe {
      purpose = 'walking'
    }
    
    let Shoe = {
      create(type: 'balletFlat' | 'boot' | 'sneaker'): Shoe {
        swtich(type) {
          case 'balletFlat': return new BalletFlat
          case 'boot': return new Boot
          case 'sneaker': return new Sneaker
        }
      }
    }
  • Shoe 팩토리를 사용하기 위해서는 단순히 .create 함수를 호출하면 된다

    Shoe.create('boot');

5-11-2. 빌더 패턴

  • 빌더 패턴으로 객체의 생성과 객체 구현 방식을 분리할 수 있다

    class RequestBuilder {
      private url: string | null = null
      private method: 'get' | 'post' | null = null
      private data: object | null = null
    
      setUrl(url: string): this {
        this.url = url;
        return this;
      }
    
      setMethod(method: 'get' | 'post'): this {
        this.method = method;
        return this;
      }
    
      setData(data: object): this {
        this.data = data;
        return this;
      }
    }