디자인 패턴은 반복적으로 발생하는 설계 문제에 대한 검증된 해결책입니다. GoF의 23가지 패턴 중 JavaScript 실무에서 자주 쓰이는 핵심 패턴들을 실제 코드로 살펴봅니다. 패턴 자체보다 어떤 문제를 해결하는지에 집중하세요.
1. 싱글턴 (Singleton)
클래스의 인스턴스를 하나만 생성하고 전역 접근점을 제공합니다. DB 연결, 설정, 로거에 적합합니다.
// ES 모듈 자체가 싱글턴 (가장 간단한 방법)
// db.js
let connection = null;
export function getDB() {
if (!connection) {
connection = createDatabaseConnection();
}
return connection;
}
// 클래스 기반 싱글턴
class ConfigManager {
static #instance = null;
#config = {};
static getInstance() {
if (!ConfigManager.#instance) {
ConfigManager.#instance = new ConfigManager();
}
return ConfigManager.#instance;
}
set(key, value) { this.#config[key] = value; }
get(key) { return this.#config[key]; }
}
const config = ConfigManager.getInstance();
config.set('apiUrl', 'https://api.devlog.kr');
2. 옵저버 (Observer)
객체의 상태 변화를 여러 곳에서 구독(subscribe)하는 패턴. 이벤트 시스템, 상태 관리에 광범위하게 사용됩니다.
class EventEmitter {
#listeners = new Map();
on(event, callback) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(callback);
// 구독 해제 함수 반환
return () => this.off(event, callback);
}
off(event, callback) {
this.#listeners.get(event)?.delete(callback);
}
emit(event, data) {
this.#listeners.get(event)?.forEach(cb => cb(data));
}
}
// 사용 예시
const store = new EventEmitter();
const unsubscribe = store.on('userLogin', (user) => {
console.log(`${user.name} 로그인`);
updateUI(user);
});
store.emit('userLogin', { name: '홍길동', role: 'admin' });
// 필요 없을 때 구독 해제
unsubscribe();
3. 팩토리 (Factory)
객체 생성 로직을 캡슐화합니다. 생성할 객체 타입이 런타임에 결정되거나, 복잡한 초기화 로직이 있을 때 유용합니다.
// 알림 타입에 따라 다른 알림 객체 생성
class Notification {
constructor(message) { this.message = message; }
send() { throw new Error('구현 필요'); }
}
class EmailNotification extends Notification {
send() { console.log(`📧 이메일: ${this.message}`); }
}
class SMSNotification extends Notification {
send() { console.log(`📱 SMS: ${this.message}`); }
}
class PushNotification extends Notification {
send() { console.log(`🔔 푸시: ${this.message}`); }
}
// 팩토리 함수
function createNotification(type, message) {
const types = {
email: EmailNotification,
sms: SMSNotification,
push: PushNotification,
};
const NotificationClass = types[type];
if (!NotificationClass) throw new Error(`알 수 없는 알림 타입: ${type}`);
return new NotificationClass(message);
}
// 사용
const notification = createNotification('email', '새 댓글이 달렸습니다');
notification.send();
4. 전략 (Strategy)
알고리즘을 캡슐화하고 교체 가능하게 만듭니다. 정렬 방식, 결제 방법, 인증 방식 같은 교체 가능한 로직에 적합합니다.
// 결제 전략
const paymentStrategies = {
card: async ({ amount, cardNumber }) => {
// 카드 결제 API 호출
return { success: true, method: 'card', amount };
},
kakao: async ({ amount, userId }) => {
// 카카오페이 API 호출
return { success: true, method: 'kakao', amount };
},
transfer: async ({ amount, accountNumber }) => {
// 계좌이체 처리
return { success: true, method: 'transfer', amount };
}
};
class PaymentProcessor {
constructor(strategy) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
async pay(amount, details) {
return this.strategy({ amount, ...details });
}
}
const processor = new PaymentProcessor(paymentStrategies.card);
await processor.pay(10000, { cardNumber: '1234-5678-9012-3456' });
// 런타임에 전략 교체
processor.setStrategy(paymentStrategies.kakao);
await processor.pay(5000, { userId: 'user123' });
5. 데코레이터 (Decorator)
기존 함수/객체를 수정하지 않고 기능을 추가합니다. 로깅, 캐싱, 검증 등 횡단 관심사(cross-cutting concerns)에 이상적입니다.
// 함수 데코레이터 — 로깅
function withLogging(fn) {
return async function (...args) {
console.log(`[${fn.name}] 호출`, args);
const start = Date.now();
try {
const result = await fn(...args);
console.log(`[${fn.name}] 완료 (${Date.now() - start}ms)`, result);
return result;
} catch (err) {
console.error(`[${fn.name}] 오류`, err);
throw err;
}
};
}
// 캐싱 데코레이터
function withCache(fn, ttl = 60000) {
const cache = new Map();
return async function (...args) {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
return cached.value;
}
const value = await fn(...args);
cache.set(key, { value, time: Date.now() });
return value;
};
}
// 조합 사용
const fetchUser = withLogging(withCache(
async (id) => fetch(`/api/users/${id}`).then(r => r.json()),
300000 // 5분 캐시
));
디자인 패턴은 도구이지 목표가 아닙니다. 싱글턴은 공유 자원 관리, 옵저버는 이벤트 기반 통신, 팩토리는 유연한 객체 생성, 전략은 교체 가능한 알고리즘, 데코레이터는 기능 합성에 각각 강합니다. 패턴을 억지로 끼워 맞추기보다, 실제 문제가 생겼을 때 적합한 패턴을 자연스럽게 적용하는 것이 좋습니다.
6. 퍼사드 (Facade)
복잡한 서브시스템을 단순한 인터페이스로 감춥니다. 라이브러리나 API를 래핑해 사용하기 쉽게 만들 때 유용합니다.
// 복잡한 스토리지 조작을 단순하게 래핑
class StorageFacade {
#prefix;
constructor(prefix = 'app') {
this.#prefix = prefix;
}
#key(name) { return `${this.#prefix}:${name}`; }
set(name, value, ttl = null) {
const item = { value, timestamp: Date.now(), ttl };
localStorage.setItem(this.#key(name), JSON.stringify(item));
}
get(name) {
const raw = localStorage.getItem(this.#key(name));
if (!raw) return null;
const { value, timestamp, ttl } = JSON.parse(raw);
if (ttl && Date.now() - timestamp > ttl) {
this.remove(name);
return null;
}
return value;
}
remove(name) { localStorage.removeItem(this.#key(name)); }
}
const storage = new StorageFacade('myapp');
storage.set('user', { name: '홍길동' }, 3_600_000); // 1시간 TTL
const user = storage.get('user');
7. 커맨드 (Command)
요청을 객체로 캡슐화해 실행 취소(undo), 재실행(redo), 큐잉이 가능하도록 합니다. 텍스트 에디터, 그래픽 툴의 히스토리 기능에 필수적입니다.
class TextEditor {
#content = '';
#history = [];
#redoStack = [];
execute(command) {
command.execute(this);
this.#history.push(command);
this.#redoStack = [];
}
undo() {
const command = this.#history.pop();
if (command) {
command.undo(this);
this.#redoStack.push(command);
}
}
redo() {
const command = this.#redoStack.pop();
if (command) {
command.execute(this);
this.#history.push(command);
}
}
setContent(text) { this.#content = text; }
getContent() { return this.#content; }
}
class InsertTextCommand {
constructor(text, position) {
this.text = text;
this.position = position;
}
execute(editor) {
const c = editor.getContent();
editor.setContent(c.slice(0, this.position) + this.text + c.slice(this.position));
}
undo(editor) {
const c = editor.getContent();
editor.setContent(c.slice(0, this.position) + c.slice(this.position + this.text.length));
}
}
패턴 선택 빠른 가이드
| 상황 | 추천 패턴 |
|---|---|
| 전역 단일 인스턴스 필요 | 싱글턴 (Singleton) |
| 상태 변화를 여러 곳에서 감지 | 옵저버 (Observer) |
| 생성 로직이 복잡하거나 타입이 런타임 결정 | 팩토리 (Factory) |
| 알고리즘을 교체 가능하게 | 전략 (Strategy) |
| 기능을 동적으로 추가 (로깅, 캐싱) | 데코레이터 (Decorator) |
| 복잡한 API 단순화 | 퍼사드 (Facade) |
| undo/redo, 작업 큐잉 | 커맨드 (Command) |
디자인 패턴을 처음 배울 때는 "이 상황에 어떤 패턴을 쓸까?"보다 "이 코드의 문제가 뭔가?"를 먼저 생각하는 것이 중요합니다. 문제를 명확히 정의하면 적합한 패턴이 자연스럽게 보입니다.
디자인 패턴 도입 시 주의사항
디자인 패턴은 코드의 품질을 높이지만, 무분별하게 적용하면 오히려 복잡도만 증가합니다. 실제 현업에서 패턴을 도입할 때 흔히 범하는 실수와 올바른 접근 방법을 살펴보겠습니다.
가장 흔한 실수는 패턴을 위한 패턴을 만드는 것입니다. 예를 들어, 단순히 두세 개의 버튼 컴포넌트를 만들면서 팩토리 패턴을 억지로 적용하거나, 전역 설정값 하나를 관리하기 위해 복잡한 싱글턴 클래스를 설계하는 경우입니다. 이런 과설계(Over-engineering)는 처음 읽는 개발자를 혼란스럽게 만들고, 테스트도 어렵게 만듭니다.
반대로 패턴을 너무 늦게 도입하는 경우도 문제입니다. 이미 비슷한 로직이 10곳에 복사·붙여넣기 되어 있고, 수정할 때마다 모든 곳을 찾아 고쳐야 하는 상황이 되어서야 팩토리나 전략 패턴을 적용하면 리팩터링 비용이 매우 커집니다.
이상적인 접근은 코드를 처음 작성할 때는 가장 단순한 방법으로 구현하고, 같은 문제가 세 번 이상 반복될 때 비로소 패턴 도입을 고려하는 것입니다. 이를 "Rule of Three"라고 부릅니다. 코드 리뷰와 페어 프로그래밍 과정에서 팀원과 함께 검토하면 과설계와 미설계의 균형을 잡는 데 큰 도움이 됩니다.
패턴별 실무 적용 빈도
GoF의 23가지 패턴 중 실제 JavaScript/TypeScript 프론트엔드 개발에서 자주 만나는 패턴과 그 사용 빈도를 정리했습니다. 자주 사용되는 패턴부터 먼저 익히면 학습 효율이 높아집니다.
매우 자주 사용 — 옵저버(이벤트 시스템, 상태 관리), 전략(폼 유효성 검사, 정렬), 데코레이터(로깅, 캐싱, 인증), 팩토리(UI 컴포넌트 생성)
가끔 사용 — 싱글턴(설정 관리, DB 연결), 퍼사드(복잡한 API 래핑), 커맨드(undo/redo), 이터레이터(컬렉션 탐색)
특수 상황에서 사용 — 프록시(지연 로딩, 접근 제어), 컴포지트(트리 구조 UI), 빌더(복잡한 객체 생성), 방문자(AST 처리)
React와 Vue 같은 현대 프레임워크 자체가 많은 패턴을 내장하고 있습니다. React의 Context API는 옵저버 패턴의 변형이고, Hooks는 전략 패턴을 함수형으로 구현한 것이라 볼 수 있습니다. 프레임워크가 제공하는 패턴을 먼저 충분히 활용하고, 그 이상이 필요할 때 추가 패턴을 도입하는 것이 바람직합니다.