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을 정의할 수 있습니다. 다음 Jumpable은 Positionable 제약 때문에 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.prop과 Spec.anotherProp이 모두 string으로 추론되는 걸 확인할 수 있어요.
더 알아보기
- Real Mixins with JavaScript Classes — 클래스 표현식을 이용한 mixin 패턴의 JavaScript 구현
- TypeScript 핸드북: Generics — mixin이 기대는 제네릭 문법 정리
- TypeScript 핸드북: Classes — 클래스 표현식과 상속의 기초
- TypeScript 핸드북: Declaration Merging — 인터페이스 병합으로 타입을 이어 붙이는 방법
- GitHub Issue #4881 — Decorators and Mixins
- GitHub Issue #17829 — Static Property Mixins