디자인 패턴
GitLab v19.4요약
이 페이지에서는 권장하는 디자인 패턴과 안티패턴을 다룹니다. 이 문서에 디자인 패턴을 추가할 때는 그 패턴이 해결하는 문제를 명확히 밝힙니다. 다음 디자인 패턴은 일반적인 문제를 해결하기 위해 권장하는 접근 방식입니다.
이 페이지에서는 권장하는 디자인 패턴과 안티패턴을 다룹니다.
이 문서에 디자인 패턴을 추가할 때는 그 패턴이 해결하는 문제를 명확히 밝힙니다. 디자인 안티패턴을 추가할 때는 그 안티패턴이 방지하는 문제를 명확히 밝힙니다.
패턴#
다음 디자인 패턴은 일반적인 문제를 해결하기 위해 권장하는 접근 방식입니다. 특정 패턴이 현재 상황에 맞는지 평가할 때는 신중하게 판단합니다. 패턴이라고 해서 모든 문제에 좋은 해법인 것은 아닙니다.
안티패턴#
안티패턴은 처음에는 좋은 접근 방식처럼 보이지만, 이점보다 폐해가 더 크다는 점이 확인된 방식입니다. 일반적으로 피하는 편이 좋습니다.
GitLab 코드베이스 전반에는 이러한 안티패턴이 과거에 사용된 흔적이 남아 있을 수 있습니다. 이런 레거시 패턴을 사용하는 코드를 수정할 때 리팩터링 여부를 판단하려면 신중하게 판단합니다.
새 기능에서 안티패턴이 반드시 금지되는 것은 아니지만, 다른 접근 방식을 찾기를 강력히 권장합니다.
공유 전역 객체(Shared Global Object)#
공유 전역 객체는 어디서나 접근할 수 있어 소유자가 명확하지 않은 인스턴스를 말합니다.
다음은 이 패턴을 Vuex 스토어에 적용한 예시입니다.
const createStore = () => new Vuex.Store({
actions,
state,
mutations
});
// Notice that we are forcing all references to this module to use the same single instance of the store.
// We are also creating the store at import-time and there is nothing which can automatically dispose of it.
//
// As an alternative, we should export the `createStore` and let the client manage the
// lifecycle and instance of the store.
export default createStore();
공유 전역 객체가 일으키는 문제#
공유 전역 객체는 어디서나 접근할 수 있어 편리합니다. 그러나 그 편리함이 다음과 같은 큰 비용을 항상 상쇄하지는 않습니다.
- 소유권 부재. 이러한 객체에는 명확한 소유자가 없으므로 비결정적이고 영구적인 수명 주기를 갖게 됩니다. 이는 특히 테스트에서 문제가 됩니다.
- 접근 제어 부재. 공유 전역 객체가 상태를 관리하는 경우, 객체에 대한 접근 제어가 없어 버그가 많고 풀기 어려운 결합 상황이 생길 수 있습니다.
- 순환 참조 가능성. 공유 전역 객체의 하위 모듈이 자기 자신을 참조하는 모듈을 참조할 수 있으므로 순환 참조 상황이 생기기도 합니다 (예시는 이 머지 리퀘스트를 참고합니다).
다음은 이 패턴이 문제로 확인된 과거 사례입니다.
공유 전역 객체 패턴이 적합한 경우#
공유 전역 객체는 무언가를 전역에서 접근할 수 있게 만드는 문제를 해결합니다. 이 패턴은 다음과 같은 경우에 적합할 수 있습니다.
- 책임이 실제로 전역적이며 애플리케이션 전반에서 참조되어야 하는 경우 (예: 애플리케이션 전역 이벤트 버스).
이런 상황에서도 부작용을 파악하기가 매우 어렵다는 점을 고려해 공유 전역 객체 패턴을 피하는 방안을 검토합니다.
참조#
자세한 내용은 C2 위키의 Global Variables Are Bad를 참고합니다.
싱글톤(Singleton)#
고전적인 싱글톤 패턴은 어떤 대상의 인스턴스가 하나만 존재하도록 보장하는 접근 방식입니다.
다음은 이 패턴의 예시입니다.
class MyThing {
constructor() {
// ...
}
// ...
}
MyThing.instance = null;
export const getThingInstance = () => {
if (MyThing.instance) {
return MyThing.instance;
}
const instance = new MyThing();
MyThing.instance = instance;
return instance;
};
싱글톤이 일으키는 문제#
어떤 대상의 인스턴스가 하나만 존재해야 한다는 것은 큰 가정입니다. 대체로 싱글톤은 잘못 사용되며, 싱글톤 자신과 이를 참조하는 모듈 사이에 매우 강한 결합을 만듭니다.
다음은 이 패턴이 문제로 확인된 과거 사례입니다.
다음은 싱글톤이 자주 일으키는 폐해입니다.
- 비결정적 테스트. 싱글톤은 하나의 인스턴스를 여러 테스트가 공유하므로 한 테스트의 상태가 다른 테스트로 흘러 들어가는 비결정적 테스트를 유발합니다.
- 높은 결합도. 내부적으로 싱글톤 클래스의 클라이언트는 모두 객체의 단일 인스턴스를 공유하므로, 이 패턴은 소유권이 불명확하고 접근 제어가 없다는 공유 전역 객체의 문제를 그대로 물려받습니다. 그 결과 버그가 많고 풀기 어려운 높은 결합 상황이 생깁니다.
- 전염성. 싱글톤은, 특히 상태를 관리할 때 전염성을 띱니다. Web IDE에서 사용하는 컴포넌트
RepoEditor
를 예로 들 수 있습니다. 이 컴포넌트는 Monaco 작업에 필요한 상태를 관리하는 싱글톤
Editor
와 상호작용합니다. Editor 클래스가 싱글톤이기 때문에
컴포넌트
RepoEditor도 싱글톤이 될 수밖에 없습니다.Editor인스턴스를 실제로 소유하는 주체가 없으므로 이 컴포넌트의 인스턴스가 여러 개 있으면 운영 환경에서 문제가 발생합니다.
Java 등 다른 언어에서 싱글톤 패턴이 널리 쓰이는 이유#
모든 것을 클래스로 감싸야 하는 Java 같은 언어의 제약 때문입니다. JavaScript에는 객체 리터럴과 함수 리터럴이 있어, 유틸리티 함수를 내보내는 모듈만으로도 많은 문제를 해결할 수 있습니다.
싱글톤 패턴이 적합한 경우#
싱글톤은 어떤 대상의 인스턴스를 하나로 강제하는 문제를 해결합니다. 다음과 같은 드문 경우에는 싱글톤이 적합할 수 있습니다.
- 인스턴스가 반드시 하나여야 하는 리소스를 관리해야 하는 경우(예: 하드웨어 제약).
- 실제로 횡단 관심사(예: 로깅)가 있고 싱글톤이 가장 단순한 API를 제공하는 경우.
이런 상황에서도 싱글톤 패턴을 피하는 방안을 검토합니다.
싱글톤 패턴의 대안#
유틸리티 함수(Utility Functions)#
관리할 상태가 없다면 클래스 인스턴스화를 건드리지 않고 모듈에서 유틸리티 함수를 내보낼 수 있습니다.
// bad - Singleton
export class ThingUtils {
static create() {
if(this.instance) {
return this.instance;
}
this.instance = new ThingUtils();
return this.instance;
}
bar() { /* ... */ }
fuzzify(id) { /* ... */ }
}
// good - Utility functions
export const bar = () => { /* ... */ };
export const fuzzify = (id) => { /* ... */ };
의존성 주입(Dependency Injection)#
의존성 주입은 모듈의 의존성을 모듈 외부에서 주입하도록 선언해
결합을 끊는 접근 방식입니다(예: 생성자 파라미터, 정식 의존성 주입 프레임워크, Vue의 provide/inject).
// bad - Vue component coupled to Singleton
export default {
created() {
this.mediator = MyFooMediator.getInstance();
},
};
// good - Vue component declares dependency
export default {
inject: ['mediator']
};
// bad - We're not sure where the singleton is in it's lifecycle so we init it here.
export class Foo {
constructor() {
Bar.getInstance().init();
}
stuff() {
return Bar.getInstance().doStuff();
}
}
// good - Lets receive this dependency as a constructor argument.
// It's also not our responsibility to manage the lifecycle.
export class Foo {
constructor(bar) {
this.bar = bar;
}
stuff() {
return this.bar.doStuff();
}
}
이 예시에서 mediator의 수명 주기와 구현 세부 사항은 모두 컴포넌트 외부
(대부분 페이지 진입점)에서 관리합니다.