TypeScript 핸드북: Mixins(믹스인)

TypeScript 핸드북: Mixins(믹스인)

전통적인 객체지향 계층 구조와는 별개로, 재사용 가능한 부품들로 클래스를 조립하는 데 쓰는 인기 있는 방법이 하나 더 있어요. 바로 비교적 단순한 부분 클래스들을 조합해서 하나의 클래스를 만들어 내는 방식이죠. Scala 같은 언어에서 mixin이나 trait이라는 이름으로 들어보신 적이 있을 거예요. 이 패턴은 JavaScript 커뮤니티에서도 꽤 자리를 잡았습니다.

출처: TypeScript 공식문서

본문

Mixin은 어떻게 동작할까요?

이 패턴은 제네릭(generics)과 클래스 상속을 함께 써서 기본 클래스를 확장하는 방식에 기대요. TypeScript에서 mixin을 가장 잘 쓸 수 있는 방법은 클래스 표현식(class expression) 패턴을 사용하는 겁니다. 이 패턴이 JavaScript에서 어떻게 동작하는지 더 자세히 알고 싶다면 여기를 읽어 보세요.

우선 위에 mixin을 얹을 기본이 될 클래스 하나가 필요해요.

class Sprite {
  name = "";
  x = 0;
  y = 0;

  constructor(name: string) {
    this.name = name;
  }
}

그다음에는 타입 하나와, 기본 클래스를 확장하는 클래스 표현식을 돌려주는 팩토리 함수가 필요합니다. 여기서 타입이 하는 역할은 "받아들이는 대상이 클래스다"라고 선언해 주는 것뿐이라는 점을 눈여겨보세요.

// To get started, we need a type which we'll use to extend
// other classes from. The main responsibility is to declare
// that the type being passed in is a class.

type Constructor = new (...args: any[]) => {};

// This mixin adds a scale property, with getters and setters
// for changing it with an encapsulated private property:

function Scale<TBase extends Constructor>(Base: TBase) {
  return class Scaling extends Base {
    // Mixins may not declare private/protected properties
    // however, you can use ES2020 private fields
    _scale = 1;

    setScale(scale: number) {
      this._scale = scale;
    }

    get scale(): number {
      return this._scale;
    }
  };
}

이렇게 다 준비됐다면, 기본 클래스에 mixin을 얹은 새 클래스를 만들어 낼 수 있어요.

// Compose a new class from the Sprite class,
// with the Mixin Scale applier:
const EightBitSprite = Scale(Sprite);

const flappySprite = new EightBitSprite("Bird");
flappySprite.setScale(0.8);
console.log(flappySprite.scale);

Scale(Sprite)를 호출하면 Sprite를 상속하면서 scale 프로퍼티와 setScale 메서드가 추가된 클래스가 반환되고, 우리는 그 결과를 EightBitSprite라는 이름으로 받아 쓴 겁니다.

제약이 있는 Mixin(Constrained Mixins)

위 형태의 mixin은 자기에게 넘어올 클래스에 대해 아무 정보도 갖고 있지 않아요. 그래서 원하는 설계를 만들기가 어려워지기도 합니다. 이 문제를 풀려면 아까 만든 생성자 타입을 제네릭 인자를 받는 버전으로 바꿔주면 돼요.

// This was our previous constructor:
type Constructor = new (...args: any[]) => {};
// Now we use a generic version which can apply a constraint on
// the class which this mixin is applied to
type GConstructor<T = {}> = new (...args: any[]) => T;

이렇게 하면 특정 제약을 만족하는 기본 클래스와만 함께 쓰이는 클래스를 만들어 낼 수 있어요.

type Positionable = GConstructor<{ setPos: (x: number, y: number) => void }>;
type Spritable = GConstructor<Sprite>;
type Loggable = GConstructor<{ print: () => void }>;

GConstructor<{ setPos: ... }>처럼 제네릭 타입 인자로 요구 사항을 박아 두면, 해당 요구 사항을 갖춘 기본 클래스를 써야만 동작하는 mixin을 정의할 수 있습니다. 다음 JumpablePositionable 제약 때문에 setPos가 정의된 기본 클래스에서만 작동하는 mixin이에요.

function Jumpable<TBase extends Positionable>(Base: TBase) {
  return class Jumpable extends Base {
    jump() {
      // This mixin will only work if it is passed a base
      // class which has setPos defined because of the
      // Positionable constraint.
      this.setPos(0, 20);
    }
  };
}

대안 패턴(Alternative Pattern)

이 문서의 이전 버전에서는 mixin을 쓰는 다른 방법도 소개했었어요. 런타임 계층과 타입 계층을 각각 따로 만들고, 마지막에 둘을 합치는 방식입니다.

// Each mixin is a traditional ES class
class Jumpable {
  jump() {}
}

class Duckable {
  duck() {}
}

// Including the base
class Sprite {
  x = 0;
  y = 0;
}

// Then you create an interface which merges
// the expected mixins with the same name as your base
interface Sprite extends Jumpable, Duckable {}
// Apply the mixins into the base class via
// the JS at runtime
applyMixins(Sprite, [Jumpable, Duckable]);

let player = new Sprite();
player.jump();
console.log(player.x, player.y);

// This can live anywhere in your codebase:
function applyMixins(derivedCtor: any, constructors: any[]) {
  constructors.forEach((baseCtor) => {
    Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => {
      Object.defineProperty(
        derivedCtor.prototype,
        name,
        Object.getOwnPropertyDescriptor(baseCtor.prototype, name) ||
          Object.create(null)
      );
    });
  });
}

핵심은 interface Sprite extends Jumpable, Duckable {}로 타입 정보를 합쳐 주고, applyMixins로 런타임 동작을 붙여 준다는 점이에요. 다만 이 패턴은 컴파일러가 아니라 우리 코드베이스가 런타임과 타입 시스템을 항상 일치시켜야 한다는 데 더 의존한다는 걸 기억해 둬야 합니다.

제약 사항(Constraints)

mixin 패턴은 TypeScript 컴파일러 안에서 코드 흐름 분석(code flow analysis) 덕분에 기본으로 지원돼요. 다만 이 기본 지원의 경계에 걸리는 경우가 몇 가지 있습니다.

데코레이터와 Mixin — #4881

코드 흐름 분석으로 mixin을 제공하기 위해 데코레이터를 쓰는 것은 불가능해요.

// A decorator function which replicates the mixin pattern:
const Pausable = (target: typeof Player) => {
  return class Pausable extends target {
    shouldFreeze = false;
  };
};

@Pausable
class Player {
  x = 0;
  y = 0;
}

// The Player class does not have the decorator's type merged:
const player = new Player();
player.shouldFreeze;

// The runtime aspect could be manually replicated via
// type composition or interface merging.
type FreezablePlayer = Player & { shouldFreeze: boolean };

const playerTwo = (new Player() as unknown) as FreezablePlayer;
playerTwo.shouldFreeze;

@Pausable 데코레이터를 붙여도 Player 타입에는 shouldFreeze가 합쳐지지 않아서, player.shouldFreeze에 접근하면 타입 오류가 나요. 데코레이터의 타입까지 반영하고 싶다면 Player & { shouldFreeze: boolean }처럼 인터페이스 병합이나 타입 합성으로 직접 만들어 줘야 합니다.

정적 프로퍼티 Mixin — #17829

이건 제약이라기보다 **함정(gotcha)**에 가까워요. 클래스 표현식 패턴은 싱글턴을 만들어 내기 때문에, 타입 시스템에서 이를 각기 다른 변수 타입에 대응시키도록 매핑할 수 없습니다.

이 문제는 제네릭에 따라 달라지는 클래스를 반환하는 함수를 사용하면 우회할 수 있어요.

function base<T>() {
  class Base {
    static prop: T;
  }
  return Base;
}

function derived<T>() {
  class Derived extends base<T>() {
    static anotherProp: T;
  }
  return Derived;
}

class Spec extends derived<string>() {}

Spec.prop; // string
Spec.anotherProp; // string

derived<string>()처럼 타입 인자를 넘겨 호출하면 그 타입에 맞는 정적 프로퍼티를 가진 클래스가 생기고, Spec.propSpec.anotherProp이 모두 string으로 추론되는 걸 확인할 수 있어요.

더 알아보기