디자인 패턴 in 자바스크립트
싱글톤·팩토리·옵저버·모듈 패턴이 해결하는 반복 설계 문제를 비교하고 JavaScript 특성에 맞게 적용합니다.
우리는 7장 자바스크립트 심화 II에서 비동기, 이터레이터/제너레이터, 모듈, 메모리 관리 등 핵심 원리를 학습했습니다.
하지만 지식만으로 자동으로 좋은 코드가 만들어지지는 않습니다.
좋은 코드는 동작을 넘어 확장성, 유지보수성, 가독성, 재사용성을 함께 갖춰야 합니다.
이러한 좋은 코드를 작성하기 위한 일종의 설계 지침 또는 문제 해결 전략이 바로 디자인 패턴(Design Pattern)입니다.
디자인 패턴은 특정 유형의 문제에 대해 검증되고 반복적으로 사용될 수 있는 일반적인 해결책을 의미합니다.
이는 건축가가 건물 설계 시 여러 가지 설계 도면과 방법을 참고하는 것과 유사합니다.
자바스크립트는 유연하고 동적인 언어이므로, 다른 언어의 패턴을 그대로 복사하기보다 언어 특성에 맞게 적용하는 것이 중요합니다.
이번 장에서는 자바스크립트에서 특히 유용한 핵심 디자인 패턴과 적용 예시를 정리합니다.
디자인 패턴이란 무엇인가?
디자인 패턴은 이름을 외우는 목록이 아니라, 반복되는 객체 협력 문제에 붙인 공통 언어입니다.
먼저 어떤 책임을 분리하려는지 잡아두면 싱글톤, 팩토리, 옵저버, 모듈 패턴을 더 자연스럽게 구분할 수 있습니다.
객체 지향 설계 패턴의 대표적인 분류를 널리 알린 책이 GoF(Gang of Four)의 에리히 감마, 리처드 헬름, 랄프 존슨, 존 블리시데스가 쓴 <Design Patterns: Elements of Reusable Object-Oriented Software>입니다. 출판사는 초판 발행일을 1994년 10월 31일로 안내합니다. 디자인 패턴이라는 아이디어 전체가 이 책에서 처음 생겼다는 뜻은 아닙니다.
이 책에서는 객체 지향 프로그래밍에서 반복적으로 발생하는 설계 문제를 해결하기 위한 23가지 패턴을 소개했습니다.
디자인 패턴을 학습하는 이유는 다음과 같습니다.
- 공통 언어: 개발자들 간에 특정 패턴을 지칭함으로써 코드를 더 쉽게 이해하고 소통할 수 있습니다.
- 문제 해결 능력 향상: 특정 문제에 직면했을 때, 이미 검증된 해결책을 적용하여 시간과 노력을 절약할 수 있습니다.
- 유지보수성 및 확장성: 패턴을 적용하여 작성된 코드는 일반적으로 구조가 명확하고 변경에 유연하게 대응할 수 있습니다.
- 코드 품질 향상: 재사용 가능한 구성 요소를 설계하여 코드의 응집도를 높이고 결합도를 낮춥니다.
디자인 패턴은 크게 세 가지 카테고리로 분류됩니다.
생성 패턴 (Creational Patterns): 객체 생성 메커니즘을 추상화하여, 객체 생성 방식을 유연하게 제어합니다.
구조 패턴 (Structural Patterns): 클래스나 객체를 더 큰 구조로 조합하여 새로운 구조를 만듭니다.
행위 패턴 (Behavioral Patterns): 객체 간의 책임 분배 및 통신 방법을 정의하여 객체 간의 상호작용을 관리합니다.
자바스크립트의 유연성 때문에 모든 GoF 패턴이 그대로 적용되지는 않지만, 그 원칙들은 여전히 유효합니다.
이제 자바스크립트에서 자주 사용되는 몇 가지 패턴을 살펴보겠습니다.
자바스크립트에서 자주 사용되는 디자인 패턴
싱글톤 패턴 (Singleton Pattern)
싱글톤 패턴은 특정 클래스의 인스턴스를 오직 하나만 생성하고, 어디서든 그 인스턴스에 접근할 수 있도록 하는 패턴입니다.
주로 애플리케이션 전반에 걸쳐 공유되어야 하는 유일한 리소스(예: 데이터베이스 연결, 설정 관리자, 로거)에 사용됩니다.
자바스크립트에서의 구현 클로저와 IIFE(즉시 실행 함수 표현)를 활용하여 외부에서 인스턴스를 여러 번 생성하는 것을 막고, 최초 한 번만 생성되도록 합니다.
const Singleton = (function() {
let instance; // 클로저에 의해 은닉된 인스턴스
function init() {
// 싱글톤 객체의 실제 생성 로직
const privateRandomNumber = Math.random(); // 단 한 번만 생성
console.log(`새로운 인스턴스 생성됨: ${privateRandomNumber}`);
return {
getPublicNumber: function() {
return privateRandomNumber;
},
publicMethod: function() {
console.log("싱글톤 공개 메서드 호출");
}
};
}
return {
getInstance: function() {
if (!instance) { // 인스턴스가 없으면 새로 생성
instance = init();
}
return instance; // 이미 존재하면 기존 인스턴스 반환
}
};
})();
const singletonA = Singleton.getInstance();
const singletonB = Singleton.getInstance();
console.log(singletonA.getPublicNumber()); // 동일한 숫자 출력
console.log(singletonB.getPublicNumber()); // 동일한 숫자 출력
console.log(singletonA === singletonB); // 결과: true (동일한 인스턴스)
singletonA.publicMethod();주의: 싱글톤 패턴은 남용하면 전역 상태를 만들고 결합도를 높여 테스트하기 어려워질 수 있으므로 신중하게 사용해야 합니다.
위 예시는 이 IIFE가 한 번 평가된 실행 환경 안에서 같은 객체를 반환합니다. 다른 탭·Worker·별도 실행 환경까지 단 하나의 인스턴스를 보장하지 않습니다. ES Module도 같은 모듈로 해석된 인스턴스의 상태를 공유할 수 있지만, 실행 환경과 모듈 식별 경계가 다르면 별개입니다. 공유 상태가 필요하더라도 테스트마다 초기화할 방법을 함께 고민하세요.
팩토리 패턴 (Factory Pattern)
팩토리 패턴은 객체를 생성하는 로직을 별도의 팩토리(Factory) 객체에 캡슐화하는 패턴입니다.
클라이언트는 특정 객체를 직접 생성하는 대신 팩토리 메서드를 호출하여 객체를 얻습니다.
이는 생성할 객체의 종류가 많거나, 객체 생성 로직이 복잡할 때 유용합니다.
// 팩토리 함수
function createCharacter(type, name) {
if (type === 'Warrior') {
return {
name: name,
attack: function() {
console.log(`${this.name}이(가) 검으로 공격합니다!`);
}
};
} else if (type === 'Mage') {
return {
name: name,
attack: function() {
console.log(`${this.name}이(가) 마법으로 공격합니다!`);
},
castSpell: function() {
console.log(`${this.name}이(가) 마법을 시전합니다!`);
}
};
} else {
throw new Error("알 수 없는 캐릭터 타입입니다.");
}
}
const warrior = createCharacter('Warrior', '아서');
const mage = createCharacter('Mage', '멀린');
warrior.attack(); // 결과: 아서이(가) 검으로 공격합니다!
mage.attack(); // 결과: 멀린이(가) 마법으로 공격합니다!
mage.castSpell(); // 결과: 멀린이(가) 마법을 시전합니다!위 코드는 문자열에 따라 생성할 객체를 선택하는 간단한 팩토리 함수입니다. GoF의 Factory Method가 사용하는 상속·재정의 구조를 그대로 구현한 코드라고 일대일 대응시키지는 않습니다. 생성 분기를 호출부에서 한곳으로 모았다는 역할을 먼저 확인하세요. warrior.attack()은 메서드 호출이므로 this.name을 읽고, 함수를 떼어 전달할 때의 수신자 문제는 앞 장의 this 규칙을 따릅니다.
옵저버 패턴 (Observer Pattern)
옵저버 패턴은 객체 간의 일대다(one-to-many) 의존성을 정의하는 패턴입니다.
하나의 주제(Subject) 객체가 변경될 때마다, 그 주제를 구독(subscribe)하고 있는 모든 관찰자(Observer) 객체들에게 자동으로 알림이 전송됩니다.
이 패턴은 이벤트 시스템이나 비동기 통신에서 매우 흔하게 사용됩니다.
발행-구독 패턴은 옵저버 패턴의 변형으로, 중간에 메시지 브로커(이벤트 버스)가 있어 발행자와 구독자 간의 직접적인 의존성을 제거합니다.
// 간단한 이벤트 발행자 (Subject) 구현
class EventPublisher {
constructor() {
this.observers = []; // 구독자 목록
}
// 구독자 추가
subscribe(observer) {
this.observers.push(observer);
console.log(`새로운 구독자 추가됨: ${observer.name}`);
}
// 구독자 제거
unsubscribe(observer) {
this.observers = this.observers.filter(obs => obs !== observer);
console.log(`구독자 제거됨: ${observer.name}`);
}
// 모든 구독자에게 알림
notify(data) {
console.log("이벤트 발생! 모든 구독자에게 알림...");
this.observers.forEach(observer => observer.update(data));
}
}
// 구독자 (Observer) 구현
class EmailSender {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name}: 이메일 전송 - ${data.message}`);
}
}
class Logger {
constructor(name) {
this.name = name;
}
update(data) {
console.log(`${this.name}: 로그 기록 - ${data.message} (발생 시간: ${new Date().toLocaleTimeString()})`);
}
}
const newsPublisher = new EventPublisher();
const emailService = new EmailSender("이메일 서비스");
const loggerService = new Logger("로깅 서비스");
const analyticsService = new Logger("분석 서비스"); // Logger를 재사용
newsPublisher.subscribe(emailService);
newsPublisher.subscribe(loggerService);
newsPublisher.subscribe(analyticsService);
newsPublisher.notify({ message: "새로운 기사가 발행되었습니다!" });
newsPublisher.unsubscribe(loggerService); // 로깅 서비스 구독 해제
newsPublisher.notify({ message: "특별 할인 이벤트 시작!" });위 notify()는 forEach로 구독자의 update()를 동기적으로 호출합니다. “이벤트”나 “알림”이라는 이름이 비동기 실행을 보장하지 않습니다. 첫 알림은 이메일·로깅·분석 순서, 로깅을 해제한 뒤의 둘째 알림은 이메일·분석 순서입니다. 로그의 실제 시간 문자열은 실행 시점에 따릅니다. 구독자가 오류를 던지면 현재 구현은 뒤의 호출을 계속하지 않으므로, 독립적인 실패 처리가 필요한지 별도로 정해야 합니다.
DOM 이벤트(addEventListener)나 비동기 데이터 스트림(RxJS 같은 라이브러리)에도 관찰·알림 역할이 있지만, 등록 중복과 오류 처리 규칙은 사용하는 API의 계약을 따라야 합니다.
옵저버 패턴과 발행-구독 패턴은 모두 알림을 전파하지만 결합도 구조가 다릅니다.
옵저버는 주제가 관찰자 목록을 직접 알고, 발행-구독은 이벤트 버스가 중간에서 발행자와 구독자를 분리합니다.
ARCHITECTURE
Observer와 Pub/Sub 결합도 차이
Observer는 Subject가 등록된 관찰자를 직접 호출한다. Pub/Sub는 topic과 중개자를 통해 전달하여 발행자가 개별 구독자를 직접 알 필요를 줄인다.
연결
Observer Subject가 Observer 참조를 직접 보유
Pub/Sub Publisher는 Broker/topic만 앎
전달
Observer 대개 같은 process의 직접 callback
Pub/Sub broker가 subscriber로 fan-out
수명
Observer subscribe/unsubscribe를 Subject가 관리
Pub/Sub subscription·offset·retention을 broker가 관리 가능
오류
Observer observer 실패의 전파·격리 정책을 notify loop에서 정함
Pub/Sub broker가 retry·dead letter·replay 정책 제공 가능
결합도
Observer Subject와 observer interface가 직접 결합
Pub/Sub publisher와 subscriber가 시간·위치상 분리 가능
- 직접 notify / broker publish
- 등록·해제
직접 결합과 topic을 통한 중개
Subject는 관찰자 참조·인터페이스를 안다. Publisher는 topic과 중개자에 결합한다. 중개자가 있다고 항상 비동기·분산 전달이나 retry/replay를 제공하는 것은 아니며, 구현의 정책에 달려 있다.
독립 연습: 중복 구독의 의미
위 EventPublisher 정의를 실행한 상태에서 아래 코드를 실행합니다. 각 시점의 update 호출 횟수를 먼저 예측하세요. 두 번 등록했다는 것이 서로 다른 두 객체를 만들었다는 뜻인지도 확인해야 합니다.
const publisherForPractice = new EventPublisher();
let callsForPractice = 0;
const observerForPractice = {
name: "횟수 관찰자",
update() { callsForPractice += 1; }
};
publisherForPractice.subscribe(observerForPractice);
publisherForPractice.subscribe(observerForPractice);
publisherForPractice.notify({ message: "첫 알림" });
console.log("첫 누적", callsForPractice);
publisherForPractice.unsubscribe(observerForPractice);
publisherForPractice.notify({ message: "둘째 알림" });
console.log("둘째 누적", callsForPractice);등록과 해제의 실제 계약
첫 누적 2, 둘째 누적 2입니다. subscribe는 같은 참조도 두 번 배열에 넣으므로 첫 알림에서 두 번 호출합니다. unsubscribe의 filter는 그 참조와 같은 원소를 모두 제거합니다. 두 등록 중 하나만 해제하는 요구라면 현재 계약으로는 구별되지 않으므로 등록별 식별자나 별도 해제 함수를 설계해야 합니다. 이 결과를 DOM addEventListener의 중복 규칙으로 일반화하지 않습니다.
모듈 패턴 (Module Pattern)
자바스크립트의 모듈 패턴은 IIFE와 클로저를 활용하여 비공개(private) 상태와 공개(public) API를 분리하는 강력한 패턴입니다.
ES Modules가 나오기 전에는 가장 널리 사용되던 모듈화 방식이었으며, 여전히 클로저의 활용을 보여주는 좋은 예시입니다.
앞선 모듈 시스템의 IIFE 예시와 같은 은닉 원리를 장바구니에 적용합니다.
const ShoppingCart = (function() {
let items = []; // private 변수 (클로저에 의해 외부 접근 불가)
function findItem(name) { // private 함수
return items.find(item => item.name === name);
}
return { // public API
addItem: function(name, price, quantity) {
const existingItem = findItem(name);
if (existingItem) {
existingItem.quantity += quantity;
} else {
items.push({ name, price, quantity });
}
console.log(`${name} ${quantity}개 추가됨.`);
},
removeItem: function(name) {
items = items.filter(item => item.name !== name);
console.log(`${name} 제거됨.`);
},
getTotal: function() {
return items.reduce((total, item) => total + (item.price * item.quantity), 0);
},
getCartItems: function() {
return [...items]; // 배열만 얕게 복사하므로 품목 객체는 공유됨; 깊은 불변성은 아님
}
};
})();
ShoppingCart.addItem("사과", 1000, 2);
ShoppingCart.addItem("바나나", 1500, 1);
ShoppingCart.addItem("사과", 1000, 3); // 사과 2 + 3 = 5개 됨
console.log("장바구니 총액:", ShoppingCart.getTotal()); // 결과: 6500 (1000 × 5 + 1500)
console.log("장바구니 품목:", ShoppingCart.getCartItems());
// console.log(ShoppingCart.items); // undefined (접근 불가)
// console.log(ShoppingCart.findItem); // undefined (접근 불가)위 계산은 사과 1000 × 5와 바나나 1500 × 1을 더해 6500입니다. 유한한 양수 가격·수량을 가정한 학습 코드이며, 같은 이름의 품목을 다시 넣으면 원래 가격을 유지하고 수량만 더합니다. 입력 검증이나 가격 변경 정책까지 구현된 장바구니는 아닙니다.
배열을 복사하면 내부 품목도 보호될까요?
원래 예제를 실행한 뒤 아래 코드를 붙입니다. 배열만 복사했다는 주석이 어떤 결과를 만드는지 확인하세요.
const exposedItemsForPattern = ShoppingCart.getCartItems();
exposedItemsForPattern[0].quantity = 1;
console.log("바뀐 총액", ShoppingCart.getTotal());
exposedItemsForPattern.push({ name: "배", price: 2000, quantity: 1 });
console.log("내부 품목 수", ShoppingCart.getCartItems().length);출력은 바뀐 총액 2500, 내부 품목 수 2입니다. 품목 객체는 공유해 사과 수량이 내부에서도 바뀌지만, 배열 자체는 별개라 push한 배가 내부 배열에 추가되지는 않습니다. 클로저에 items를 숨겼다는 사실과 반환한 값의 변경 가능성은 다른 문제입니다.
독립적으로 원래 예제를 새로 실행한 뒤, 반환값을 const snapshotItemsForPattern = ShoppingCart.getCartItems().map(item => ({ ...item }));로 바꾸고 첫 품목 수량만 1로 바꿔 보세요. 원래 총액은 6500을 유지합니다. 이 예시 품목의 값은 원시 값뿐이라 품목별 얕은 복사가 충분하지만, 중첩 객체까지 자동으로 깊게 복사하지는 않습니다.
ES Modules가 대중화되면서 이 패턴의 필요성은 줄어들었지만, 클로저를 이용한 정보 은닉의 원리를 이해하는 데는 중요한 패턴입니다.
근거: 출판사의 GoF 초판·23개 패턴 안내. 중복 구독과 장바구니의 결과는 이 본문 구현을 직접 추적한 것이며 모든 라이브러리의 계약을 설명하지 않습니다.
디자인 패턴 적용의 중요성
디자인 패턴은 반복되는 설계 문제에 대한 구조화된 해결 방식입니다.
하지만 패턴을 맹목적으로 적용하는 것은 오히려 코드를 복잡하게 만들 수 있습니다.
중요한 것은 문제를 정확히 이해하고, 그 문제에 가장 적합한 패턴을 선택하는 지혜입니다.
자바스크립트에서 패턴을 고를 때는 문제의 성격과 결합도 위험을 함께 봐야 합니다.
다음 기준으로 좁혀 보면 패턴 이름보다 적용 이유가 먼저 보입니다.
- 과도한 패턴 사용은 피하라: 작은 문제에 거창한 패턴을 적용하는 것은 과도한 설계(Over-engineering)가 될 수 있습니다.
- 유연성과 단순성을 유지하라: 패턴은 유연성을 제공하지만, 때로는 단순한 해결책이 더 나을 때도 있습니다.
- 패턴을 학습하되, 맹신하지 마라: 패턴은 가이드라인이지, 절대적인 규칙이 아닙니다. 자바스크립트의 특성을 고려하여 적절히 변형하거나 조합하는 능력이 필요합니다.
디자인 패턴 정리
이번 장의 핵심은 패턴 이름 암기가 아니라 문제 유형을 먼저 보는 것입니다.
싱글톤은 인스턴스 공유, 팩토리는 생성 분기, 옵저버는 알림 전파, 모듈 패턴은 정보 은닉 문제를 줄일 때 의미가 있습니다.
싱글톤, 팩토리, 옵저버, 모듈 패턴은 각각 공유, 생성 분기, 알림 전파, 정보 은닉 문제에 연결해 판단합니다.