디자인 패턴

이 페이지에서는 권장되는 디자인 패턴과 안티 패턴에 대해 다룹니다.

참고: 이 문서에 디자인 패턴을 추가할 때는 반드시 해결하는 문제를 명확히 밝혀야 합니다. 디자인 안티 패턴을 추가할 때는 반드시 막는 문제를 명확히 밝혀야 합니다.

패턴

다음 디자인 패턴은 일반적인 문제를 해결하기 위한 권장 접근 방법입니다. 특정 패턴이라고 해서 귀하의 문제에 적합한 것은 아닐 수 있으므로 평가할 때 주의해야 합니다.

안티 패턴

안티 패턴은 처음에는 좋은 접근 방법처럼 보일 수 있지만, 그 결과는 이득보다 피해가 더 크다는 것이 밝혀졌습니다. 이러한 패턴은 일반적으로 피해야 합니다.

GitLab 코드베이스 전체에는 이러한 안티 패턴이 사용된 이력이 있을 수 있습니다. 이러한 레거시 패턴을 사용하는 코드를 건드리는 경우 리팩터링을 할지 여부를 결정할 때 신중해야 합니다.

참고: 새로운 기능을 추가할 때는 안티 패턴이 반드시 금지되는 것은 아니지만, 강력히 권장되는 다른 접근 방법을 찾는 것이 좋습니다.

공유 전역 객체

공유 전역 객체는 어디서든 액세스할 수 있는 인스턴스로, 명확한 소유자가 없는 특징을 가지고 있습니다.

VueX 저장소에 이러한 패턴이 적용된 예시입니다:

const createStore = () => new Vuex.Store({
  actions,
  state,
  mutations
});

// 이 모듈에 대한 모든 참조가 저장소의 하나의 단일 인스턴스를 사용하도록 강제하고 있음에 주목하세요.
// 또한 저장소를 가져오는 시점에 생성하고, 자동으로 폐기할 수 있는 방법이 없습니다.
//
// 대신 `createStore`를 내보내어 클라이언트가 저장소의 수명주기와 인스턴스를 관리하도록 합시다.
export default createStore();

공유 전역 객체가 일으키는 문제는 무엇인가요?

공유 전역 객체는 어디서든 액세스할 수 있기 때문에 편리하지만, 이러한 편의성이 항상 무거운 대가를 지우는 것은 아닙니다:

  • 소유권이 없음. 이러한 객체에는 명확한 소유자가 없으므로 결정론적이지 않고 영구적인 수명주기를 갖게 됩니다. 특히 테스트에서 이는 문제가 될 수 있습니다.
  • 액세스 제어가 없음. 공유 전역 객체가 일부 상태를 관리할 때, 이는 이 객체에 대한 액세스 제어가 없기 때문에 매우 버그가 생기고 복잡한 결합 상황을 만들 수 있습니다.
  • 순환 참조 가능성. 공유 전역 객체는 하위 모듈이 자신을 참조하는 모듈을 참조하므로 일부 순환 참조 상황을 만들 수도 있습니다 (이 MR을 참조).

이 패턴이 문제가 될 수 있었던 몇 가지 예시는 다음과 같습니다:

공유 전역 객체 패턴이 실제로 적절한 경우는 언제인가요?

공유 전역 객체는 무언가를 전역적으로 액세스 가능하게 하는 문제를 해결합니다. 이 패턴은 다음과 같은 경우에 적절할 수 있습니다:

  • 책임이 실제로 글로벌하고 애플리케이션 전체에서 참조해야 하는 경우(예: 애플리케이션 전체적으로 사용되는 이벤트 버스).

심지어 이러한 시나리오에서도 공유 전역 객체 패턴을 피하는 것이 좋습니다. 왜냐하면 부작용이 이해하기 어려울 수 있기 때문입니다.

참고 자료

자세한 정보는 C2 위키의 전역 변수는 나쁘다(Global Variables Are Bad)를 참조하세요.

싱글톤

클래식한 싱글톤 패턴은 한 가지 인스턴스만 존재할 수 있도록 하는 접근 방법입니다.

다음은 이러한 패턴의 예시입니다:

class MyThing {
  constructor() {
    // ...
  }

  // ...
}

MyThing.instance = null;

export const getThingInstance = () => {
  if (MyThing.instance) {
    return MyThing.instance;
  }

  const instance = new MyThing();
  MyThing.instance = instance;
  return instance;
};

싱글톤이 일으키는 문제는 무엇인가요?

한 가지 인스턴스만 존재해야 한다는 것은 큰 가정입니다. 더 자주, 싱글톤은 잘못 사용되며, 자체와 그것을 참조하는 모듈 사이에 매우 강한 결합을 유발합니다.

이 패턴이 문제가 될 수 있었던 몇 가지 예시는 다음과 같습니다:

싱글톤이 종종 야기하는 일들은 다음과 같습니다:

  1. 결정론적이지 않은 테스트. 싱글톤은 개별 테스트 사이에 단일 인스턴스가 공유되므로 종종 한 테스트의 상태가 다른 테스트로 누출되어 결정론적이지 않은 테스트를 유발합니다.
  2. 높은 결합성. 싱글톤 클래스의 클라이언트들은 모두 특정 객체의 단일한 인스턴스를 공유하기 때문에, 이 패턴은 공유 전역 객체가 일으키는 문제를 상속받으며, 명확한 소유도 없고 액세스 제어가 없는 상황으로 인해 발생하는 높은 결합 상황을 야기합니다. 이는 버그가 발생하기 쉽고 해결하기 어려운 상황을 만들어냅니다.
  3. 전염성. 특히 상태를 관리할 때 싱글톤은 전염성이 있습니다. 예를 들어 웹 IDE에서 사용되는 RepoEditor 컴포넌트입니다. 이 컴포넌트는 싱글톤인 Editor와 상호작용하여 Monaco와 작업하기 위한 상태를 관리합니다. Editor 클래스가 싱글톤의 성질을 가지고 있기 때문에 RepoEditor 컴포넌트도 강제적으로 싱글톤이어야 합니다. 이 컴포넌트의 여러 인스턴스는 제대로된 인스턴스를 소유하지 않아서 프로덕션 문제를 일으키기 때문입니다.

다른 언어인 Java와 같은 곳에서 싱글톤 패턴이 인기 있는 이유는 무엇인가요?

이는 Java와 같은 언어의 제약 때문입니다. Java에서는 모든 것이 클래스로 래핑되어야 하는데, JavaScript에서는 객체 및 함수 리터럴과 같은 것들이 있어 모듈을 통해 유틸리티 함수를 내보낼 수 있습니다.

싱글톤 패턴이 실제로 적절한 경우는 언제인가요?

싱글톤은 어떤 것이 단 하나의 인스턴스만 존재해야 하는 것을 보장하는 문제를 해결합니다. 싱글톤이 다음의 드문 경우에 적절할 수 있습니다:

  • 단 하나의 인스턴스를 가져야 하는 리소스를 관리해야 하는 경우 (즉, 어떤 하드웨어 제약).
  • 실제 교차 관심 사항(예: 로깅)이 있고, 싱글톤이 가장 간단한 API를 제공하는 경우.

심지어 이러한 시나리오에서도 싱글톤 패턴을 피하는 것을 고려해 보세요.

싱글톤 패턴에 대안은 무엇이 있나요?

유틸리티 함수

상태를 관리할 필요가 없는 경우, 클래스 인스턴스화와 전혀 관련이 없이 모듈에서 유틸리티 함수를 내보낼 수 있습니다.

// bad - 싱글톤
export class ThingUtils {
  static create() {
    if(this.instance) {
      return this.instance;
    }

    this.instance = new ThingUtils();
    return this.instance;
  }

  bar() { /* ... */ }

  fuzzify(id) { /* ... */ }
}

// good - 유틸리티 함수
export const bar = () => { /* ... */ };

export const fuzzify = (id) => { /* ... */ };
의존성 주입

의존성 주입은 모듈의 종속성을 외부에서 주입받도록 선언하여 결합을 줄이는 접근 방식입니다(예: 생성자 매개변수를 통한 주입, 명시적 의존성 주입 프레임워크, Vue의 provide/inject).

// bad - 싱글톤에 결합된 Vue 컴포넌트
export default {
  created() {
    this.mediator = MyFooMediator.getInstance();
  },
};

// good - Vue 컴포넌트가 의존성을 선언
export default {
  inject: ['mediator']
};
// bad - 싱글톤의 라이프사이클이 불분명하여 여기서 초기화함. 
export class Foo {
  constructor() {
    Bar.getInstance().init();
  }

  stuff() {
    return Bar.getInstance().doStuff();
  }
}

// good - 이 의존성을 생성자 인수로 받습니다. 
// 또한 라이프사이클을 관리하는 것은 우리의 책임이 아닙니다.
export class Foo {
  constructor(bar) {
    this.bar = bar;
  }

  stuff() {
    return this.bar.doStuff();
  }
}

이 예에서 mediator의 라이프사이클과 구현 세부 정보는 모두 컴포넌트 에서 관리됩니다(아마도 페이지 진입점에서).