[ts] constrained mixin pattern - 클래스 참조해 공통메서드 정의하기

GY·2023년 3월 11일
1

Typescript

목록 보기
14/14
post-thumbnail

vanilla js로 웹 페이지를 만들기 위해 클래스로 컴포넌트를 구현했습니다.
컴포넌트 구현을 담당하는 2개의 클래스들은 컴포넌트를 생성하는 메서드는 달랐지만 생성한 컴포넌트를 삽입해 렌더링하기 위해서는 동일한 메서드를 사용해야 했는데, 이 공통 메서드를 재사용하기 위해 고민했던 내용을 정리해보았습니다.

클래스의 공통 메서드 재사용하기

기존 코드

처음에는 페이지 컴포넌트와 그 내부의 다른 작은 컴포넌트를 만들기 위해 각각 PageComponent와 Component라는 이름의 클래스를 구현했습니다.

두 클래스는 각각 컴포넌트 요소를 생성하는 방식이 달랐습니다.
페이지를 만들기 위해서는 PageComponent에 해당 페이지의 최상위 클래스 이름이 될 문자열을 생성자로 넘겨주면 됩니다.
컴포넌트를 만들기 위해서는 Component에 template literal형태의 htmlString을 넘겨주면 됩니다.

PageComponent

export class PageComponent extends Element {
  constructor(className: string) {
    super();

    this.setElement = document.createElement("div");
    this.getElement?.classList.add(className);
  }
}
Component
export class Component extends Element {
  constructor() {
    super();
  }

  create(htmlString: string) {
    const $template = document.createElement("template");
    $template.innerHTML = htmlString;
    this.setElement = $template.content.firstElementChild as HTMLElement;
    return this.getElement;
  }
}

그리고 각각의 생성한 요소들을 렌더링하기 위한 메서드는 동일하게 사용할 수 있었습니다.


#1 상위 클래스를 만들어 공통 메서드 상속하기

이 메서드를 각 클래스에 따로 정의하는 것보다는 묶어서 재사용하고 싶었기 때문에 Element라는 상위 클래스를 만들어 공통 메서드를 정의했습니다.

export class Element {
  #$element: HTMLElement;

  constructor() {
    this.#$element = document.createElement("div");
  }

  render() {
    return this.#$element;
  }

  add($child: HTMLElement) {
    if (!$child) return;
    this.#$element?.appendChild($child);
  }

  attachTo($parent: HTMLElement) {
    if (this.#$element) $parent?.appendChild(this.#$element);
  }
}

예를 들어 이렇게 사용합니다.
ArticleList와 Article은 모두 Component클래스를 상속받고, Component클래스는 Element클래스를 상속받기 때문에 add메서드를 사용해 원하는 요소를 자식으로 붙여 렌더링할 수 있습니다.

export class ArticleList extends Component {
  constructor(ArticleList: Array<IArticle>) {
    super();
    this.create(`
      <ul class="post_list">
        <h1 class="post_list__title">개발</h1>
      </ul>
    `);
    ArticleList.forEach((article: IArticle) => {
      const $article = new Article(article).render();
      if ($article) this.add($article);
    });
  }
}

문제: Element class의 불분명한 역할과 불필요한 코드 구조

Element 클래스는 엄밀히 말하면 각 클래스의 부모 역할을 하지 않습니다.
기존 Element 클래스는 Component와 PageComponent 클래스가 상속받아 사용할 공통 메서드를 정의하는 역할입니다.

그렇기 때문에 불필요한 구조가 생겼는데,
공통메서드가 각 컴포넌트가 생성한 요소를 사용하기 위해서는 상위 클래스에서 element field를 관리하고,
하위 클래스에서 setter로 접근해 이 element field를 업데이트 해주는 방식을 사용했습니다.

즉, Element 클래스에 $element 필드가 있고,

export class Element {
  #$element: HTMLElement;

하위 Component 클래스에서 this.$element의 값을 새로 생성한 컴포넌트 요소로 할당해줍니다.

    this.setElement = $template.content.firstElementChild as HTMLElement;

그러면 Element 클래스에서는 이 $element에 접근해 요소를 삽입할 수 있는 add와 같은 메서드가 동작하고, 이것을 Component 클래스에서 사용합니다.

if ($article) this.add($article);

공통 메서드는 하위로 내려주는데... 메서드에서 사용할 요소는 하위 클래스에서 상위 클래스로 전달받을 필요가 있을까 하는 의문이 들었습니다.



#2 믹스인 패턴 적용

단순히 각 클래스들의 공통 메서드를 재사용하기 위한 목적이므로 mixin pattern을 사용해 공통 메서드만 필요한 클래스에 추가해 사용해보았습니다.
Component, PageComponent 클래스에서 생성한 element 필드를 관리하고, 믹스인 함수에서 이 요소들을 받아 공통 메서드를 추가하도록 변경했습니다.


믹스인 패턴이란?

  • 재사용 가능한 부분 클래스들로 클래스를 구성/조립하는 방법
    (typescript와 자바스크립트의 클래스는 엄격하게 단일 상속만 지원)
  • 클래스 A가 B를 확장해서 기능을 받는게 아닌,
    함수 B가 클래스 A를 받고 기능이 추가된 새 클래스를 반환할 때 함수 B를 믹스인이라 합니다.

사용하는 이유

  • 횡단 관심사: 상속 없이 유사한 메서드를 공유하고자 할 때 사용합니다.

사용하는 방법

생성자를 재 정의하는 믹스인 타입 선언

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

믹스인 클래스 선언

  • 새로 정의가 필요한 클래스를 상속받아서 정의한 뒤 반환
    반환 시 제네릭에 정의해둔 MixinType 상속
function mixinFunc<T extends MixinType>(Class: T) {
  return class Scaling extends Class {
    addedVariable: number = 1;

    addedFunction(arg: any) {
      console.log(arg)
    };
  };
};

믹스인 클래스 사용

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

const A = function mixinFunc(BaseClass);
const newClass = new A();

console.log(newClass.name); //BaseClass에 존재하는 변수
newClass.addedFunction(1234); //mixin으로 받은 함수
console.log(newClass.addedVariable); //mixin으로 받은 변수

믹스인 함수를 만들었는데.. 문제가 있다.

html요소를 삽입하고 해당 요소를 반환해 렌더링하는 데 사용하는 메서드를 특정 클래스에 붙여 사용할 수 있도록 만들 것이므로, mixinElementMethods라고 네이밍했습니다.

해당 함수는 인자로 전달받은 클래스를 상속받은 다음, 공통 메서드를 추가로 선언한 클래스를 만들어 리턴합니다.
반환된 새로운 클래스는 인자로 전달받은 클래스와 공통메서드를 모두 가지고 있게 됩니다.

하지만 끝이 아니었다! 타입 에러 발생...

type MixinConstructor = new (...args: any[]) => {};
type MixinType = MixinConstructor<{ $element: HTMLElement }>;

function mixinElement<T extends MixinType>(Class: T) {
  return class Mixin extends Class implements IMixin {
    // 타입 에러 발생!
    render(): HTMLElement {
      return this.$element;
    }
    //...
  };
}

만약 믹스인 패턴을 사용할 클래스가 생성자 파라미터로 아무것도 받지 않는다면, 믹스인 함수 내부에서는 해당 클래스에 대한 정보를 알 필요가 없습니다.

하지만 Component와 PageComponent클래스는 각각 element라는 필드를 가지고 있고, 각각의 메서드로 생성한 요소를 이 필드에 할당합니다.
그리고 해당 요소를 사용하거나, 다른 요소에 삽입하는 메서드는 이 필드를 참조해야 합니다.

그런데 믹스인에서는 파라미터로 받는 클래스의 내부 정보를 알 수 없기 때문에 내부 $element필드에 접근해 render()라는 메서드를 정의할 수 없었습니다.


constrained mixin

mixin은 위에 언급한 내용과 같이 상속받은 상위 클래스의 내부 정보에 대해 알지 못하므로 해당 클래스의 변수를 사용하기 위해 메서드를 선언하는 데에는 한계가 있었습니다.
이럴 때는 constructor type에도 제네릭을 사용해 특정 클래스를 위한 믹스인을 지정해줄 수 있습니다.

ex)

type GenericConstructor<T = {}> = new (...args: any[]) => T;
// 1. constructor 타입 지정

class ExClass {// 상속받을 상위 클래스
  constructor(public x: number, public y: number) { }

  say() {
    console.log(`X: ${this.x}, Y: ${this.y}`);
  }
}
// 2. constructor 타입에 상속받을 특정한 클래스를 넘겨주어 특정한 타입 지정
type MixinType = GenericConstructor<{ say: () => void }>;

function MixinGenerator<T extends MixinType>(Base: T) {
  return class MixinGenerator extends Base {
    say = () => {
      console.log("Hello Mixin");
    }
  };
}

const MixinClass = MixinGenerator(ExClass);
const mixin = new MixinClass(1, 3);


mixin.say(); // Hello Mixin

constrained mixin을 적용해 해결

이제 생성자 타입에도 제네릭을 사용해 인자로 전달할 클래스의 규격사항을 믹스인 내부에서도 알 수 있도록 했습니다.

믹스인 함수에서 공통 메서드가 인자로 전달받은 함수의 필드를 참조할 수 있도록 만들었습니다.

import { IComponent } from "../interfaces";

type MixinConstructor<T = {}> = new (...args: any[]) => T;
type MixinType = MixinConstructor<IComponent>;

export interface IMixin {
  render(): HTMLElement;
  add($child: HTMLElement): void;
  attachTo($parent: HTMLElement): void;
}

export function mixinElementMethods<T extends MixinType>(Class: T) {
  return class Mixin extends Class implements IMixin {
    render() {
      return this.$element;
    }

    add($child: HTMLElement) {
      if ($child) this.$element?.appendChild($child);
    }

    attachTo($parent: HTMLElement) {
      if (this.$element) $parent.appendChild(this.$element);
    }
  };
}

그리고 Component 클래스를 선언하되, 믹스인 함수를 사용해 공통 메서드를 가진 클래스를 리턴하여 export시켜 사용하도록 했습니다.

//Component
class OriginComponent implements IComponent {
  $element: HTMLElement;
//...

export const Component = mixinComponentMethods(OriginComponent);

Component클래스는 더 이상 Element클래스를 상속받지 않고 PageComponent와 동일한 공통메서드를 사용할 수 있습니다.

export class ArticleList extends Component {
  constructor(ArticleList: Array<IArticle>) {
    super();
    this.create(`
      <ul class="post_list">
        <h1 class="post_list__title">개발</h1>
      </ul>
    `);
    ArticleList.forEach((article: IArticle) => {
      const $article = new Article(article).render();
      if ($article) this.add($article);
    });
  }

믹스인 함수에만 공통메서드를 한번 정의해두면, 이를 사용한 Component와 PageComponent 클래스는 각각 똑같은 메서드를 선언하지 않아도 상속없이 재사용할 수 있습니다. 👏


결론

결국, 리팩토링의 끝은 순정... 기존 컴포넌트 상속으로 😅

믹스인 패턴을 사용하고 보니 또 막상 "굳이?"라는 생각이 들었습니다.
그래서 결국은 믹스인 패턴을 사용하지 않고, Component에 공통 메서드를 정의한 다음 이 클래스를 PageComponent 클래스가 상속하는 방식으로 변경했습니다.

왜 진작에 이렇게 하지 않았나 ㅎㅎ

PageComponent 또한 Component의 일부이기 때문에 상속관계가 자연스러웠는데, 이 프로젝트보다 믹스인 패턴을 사용했을 때 분명한 장점이 있는 다른 상황이 있을 듯합니다. 그 때 다시 사용하는 걸로!

하지만 믹스인 패턴에 대해 제대로 익힐 수 있었던 기회 😃😃

더불어 타입스크립트를 사용할 때 constraint mixin을 사용해 파라미터로 받는 클래스 또한 제네릭 타입으로 지정해주는 방법까지 알 수 있어서, 믹스인 패턴에 대해서 제대로 공부할 수 있었습니다.

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글