본문 바로가기

디자인 패턴 in 자바스크립트

싱글톤·팩토리·옵저버·모듈 패턴이 해결하는 반복 설계 문제를 비교하고 JavaScript 특성에 맞게 적용합니다.

7장에서는 비동기 처리, 이터레이터와 제너레이터, 모듈, 메모리 관리처럼 자바스크립트가 동작하는 원리를 살펴봤습니다. 하지만 원리를 안다고 좋은 코드가 저절로 나오지는 않습니다. 좋은 코드는 동작하는 것을 넘어, 기능을 더하기 쉽고 고치기 쉬우며 읽기 쉽고 다시 쓸 수 있어야 합니다.

이런 코드를 쓰기 위한 설계 지침이 디자인 패턴(design pattern)입니다. 디자인 패턴은 자주 마주치는 설계 문제에 대해 이미 여러 번 검증된, 다시 쓸 수 있는 해결 방법입니다. 자바스크립트는 유연하고 동적인 언어라서 다른 언어의 패턴을 그대로 옮기기보다 언어의 특성에 맞게 적용하는 것이 좋습니다. 이 글에서는 자바스크립트에서 특히 자주 쓰이는 패턴 네 가지를 구현해 봅니다.


디자인 패턴이란

디자인 패턴은 이름을 외우는 목록이 아니라, 반복되는 객체 사이의 협력 문제에 붙인 공통의 이름입니다. 먼저 어떤 문제를 나누려는지 생각해 두면 싱글톤, 팩토리, 옵저버, 모듈 패턴을 자연스럽게 구분할 수 있습니다.

객체 지향 설계 패턴의 분류를 널리 알린 책은 에릭 감마, 리처드 헬름, 랄프 존슨, 존 블리시디스가 쓴 『Design Patterns: Elements of Reusable Object-Oriented Software』입니다. 네 저자를 흔히 GoF(Gang of Four)라고 부르며, 출판사는 이 책의 초판을 1994년에 펴냈다고 안내합니다. 이 책은 객체 지향 프로그래밍에서 반복되는 설계 문제를 푸는 23가지 패턴을 소개했지만, 디자인 패턴이라는 생각 자체가 이 책에서 처음 나온 것은 아닙니다.

패턴을 배우면 몇 가지 이점이 있습니다. 패턴의 이름이 공통 언어가 되어 “여기는 옵저버로 알림을 보낸다”처럼 설계를 짧게 설명할 수 있고, 비슷한 문제를 만났을 때 검증된 방법을 바로 적용할 수 있습니다. 패턴을 따른 코드는 구조가 분명해 변경에 대응하기 쉽고, 관련된 코드는 모으고 서로 다른 코드 사이의 연결은 줄이는 데 도움이 됩니다.

GoF는 패턴을 세 갈래로 나눕니다. 생성 패턴(creational pattern)은 객체를 만드는 방법을 감춰 생성 방식을 유연하게 바꿀 수 있게 합니다. 구조 패턴(structural pattern)은 클래스나 객체를 조합해 더 큰 구조를 만듭니다. 행위 패턴(behavioral pattern)은 객체들이 할 일을 어떻게 나누고 서로 어떻게 통신할지를 정합니다. 자바스크립트는 함수를 값으로 다루고 객체를 자유롭게 만들 수 있어 모든 GoF 패턴이 그대로 들어맞지는 않지만, 그 원칙은 여전히 쓸모가 있습니다. 23개 패턴이 세 갈래에 어떻게 나뉘는지와 패턴을 고르는 기준은 디자인 패턴의 목적과 선택에서 더 자세히 다룹니다.


싱글톤 패턴

싱글톤 패턴(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();

“새로운 인스턴스 생성됨”은 첫 getInstance() 호출에서 한 번만 출력됩니다. 두 번째 호출은 클로저에 남아 있는 instance를 그대로 돌려주므로 두 변수가 같은 난수를 출력하고, singletonA === singletonB도 true입니다.

이 예제가 보장하는 것은 이 IIFE가 실행된 한 실행 환경 안에서 같은 객체를 돌려준다는 점입니다. 다른 탭이나 Web Worker처럼 실행 환경이 다르면 인스턴스도 따로 생깁니다. ES 모듈도 같은 모듈로 해석된 곳끼리는 상태를 함께 쓰므로 싱글톤처럼 동작하지만, 실행 환경이나 모듈 경로가 다르면 별개입니다.

싱글톤은 남용하면 사실상 전역 상태가 되어, 여러 코드가 보이지 않게 서로 얽히고 테스트하기 어려워집니다. 하나를 함께 써야 하는 자원에만 쓰고, 테스트마다 상태를 초기화할 방법도 함께 마련해 두는 것이 좋습니다.

더 깊이: ES 모듈은 왜 저절로 싱글톤처럼 동작할까

요즘 코드에서는 IIFE 대신 모듈의 최상위에서 객체를 하나 만들어 export하는 경우가 많습니다. 이 객체도 싱글톤처럼 동작하는데, 그 이유는 브라우저가 모듈을 불러오는 방식에 있습니다. HTML 표준에 따르면 문서나 워커마다 모듈 맵(module map)이 하나씩 있고, 이 맵은 요청한 URL과 모듈의 종류를 키로 삼아 이미 불러온 모듈을 기록합니다. 그래서 여러 파일이 같은 URL의 모듈을 가져와도 그 모듈은 한 번만 내려받고 실행되며, 모두 같은 객체를 받습니다.

같은 이유로 이 싱글톤이 깨지는 경우도 정해집니다. 키가 URL이므로 ./store.js와 ./store.js?v=2처럼 주소가 조금이라도 다르면 브라우저는 서로 다른 모듈로 보고 두 번 실행하며, 각자의 객체가 생깁니다. 캐시를 무효화하려고 일부 import에만 쿼리 문자열을 붙이면 상태가 둘로 나뉠 수 있다는 뜻입니다. 또 모듈 맵은 문서나 워커마다 따로 있으므로, 앞에서 말한 것처럼 다른 탭이나 Web Worker는 각자의 인스턴스를 가집니다.

Node.js의 CommonJS도 require()로 불러온 모듈을 해석된 파일 이름을 기준으로 캐시하므로 비슷하게 동작합니다. 그래서 같은 패키지라도 서로 다른 node_modules 폴더에서 찾아져 다른 파일로 해석되면 두 번 실행되고, 대소문자를 구분하지 않는 파일 시스템에서는 require('./foo')와 require('./FOO')가 같은 파일인데도 서로 다른 객체를 돌려줍니다. 모듈 맵의 규칙은 HTML 표준의 모듈 스크립트 가져오기 절에 있습니다.


팩토리 패턴

팩토리 패턴(factory pattern)은 객체를 만드는 코드를 별도의 팩토리 함수나 객체에 모아 두는 패턴입니다. 쓰는 쪽은 객체를 직접 만드는 대신 팩토리를 호출해 객체를 받습니다. 만들어야 할 객체의 종류가 많거나 만드는 과정이 복잡할 때 유용합니다.

// 팩토리 함수
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(); // 결과: 멀린이(가) 마법을 시전합니다!

createCharacter()는 type에 따라 어떤 객체를 만들지 정하는 간단한 팩토리 함수입니다. 쓰는 쪽은 Warrior나 Mage를 만드는 세부 과정을 몰라도 되고, 새 캐릭터 종류가 생겨도 팩토리 한 곳만 고치면 됩니다. GoF의 팩토리 메서드(Factory Method) 패턴은 상속과 재정의로 생성할 객체를 바꾸는 구조라서 이 코드와 똑같지는 않지만, 생성하는 분기를 한곳으로 모은다는 목적은 같습니다.

warrior.attack()은 메서드 호출이므로 this가 warrior 객체라 this.name이 "아서"가 됩니다. 이 메서드를 따로 떼어 콜백으로 넘기면 6장의 this 규칙대로 this를 잃는다는 점도 기억해 두세요.


옵저버 패턴

옵저버 패턴(observer pattern)은 객체 사이의 일대다(one-to-many) 의존 관계를 정의하는 패턴입니다. 하나의 주제(subject) 객체가 바뀔 때마다, 그 주제를 구독한 모든 관찰자(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()를 차례로 동기 호출합니다. 이벤트나 알림이라는 이름이 붙었다고 비동기로 실행되는 것은 아닙니다. 또 한 관찰자의 update()가 오류를 던지면 forEach()가 거기서 멈춰 뒤의 관찰자는 알림을 받지 못하므로, 관찰자의 실패를 서로 떼어 놓아야 한다면 따로 처리해야 합니다. DOM의 addEventListener()나 RxJS 같은 라이브러리에도 관찰과 알림이 있지만, 같은 리스너를 두 번 등록하거나 오류가 났을 때 어떻게 되는지는 각 API의 규칙을 따릅니다.

옵저버와 발행-구독

발행-구독 패턴(publish-subscribe pattern)은 옵저버 패턴의 변형으로, 발행자와 구독자 사이에 메시지 중개자(broker)나 이벤트 버스(event bus)를 둡니다. 두 패턴 모두 알림을 퍼뜨리지만 누가 누구를 아는지가 다릅니다.

Observer와 Pub/Sub 결합도 차이

ARCHITECTURE

Observer와 Pub/Sub 결합도 차이

Observer는 Subject가 등록된 관찰자를 직접 호출한다. Pub/Sub는 topic과 중개자를 통해 전달하여 발행자가 개별 구독자를 직접 알 필요를 줄인다.

직접 관찰자 호출과 topic 중개Observer에서는 Subject가 A와 B에 직접 알림을 보내며 등록과 해제는 Subject를 향한다. Subject가 목록과 순회 및 오류 정책을 소유한다. Pub/Sub는 Publisher에서 Broker와 topic을 거쳐 Subscriber A, B로 갈라진다. 모바일도 한 관찰자를 거쳐 다른 관찰자로 전달하는 직렬 관계로 바꾸지 않는다. 각 알림은 원래 주제나 중개자에서 독립적으로 갈라진다. 중개가 항상 비동기, 분산, 재시도와 재전송을 보장하지는 않는다. Observer: 목록과 직접 호출Subject관찰자 목록 소유Observer A직접 callbackObserver B직접 callback알림알림등록 · 해제Subject: 순회·오류 정책을 정함Pub/Sub: topic과 중개자Publisher개별 구독자 모름Broker / topic등록·전달 정책Subscriber Atopic 구독Subscriber Btopic 구독발행중개가 항상 비동기·분산·재시도를 뜻하지 않음
직접 관찰자 호출과 topic 중개Observer에서는 Subject가 A와 B에 직접 알림을 보내며 등록과 해제는 Subject를 향한다. Subject가 목록과 순회 및 오류 정책을 소유한다. Pub/Sub는 Publisher에서 Broker와 topic을 거쳐 Subscriber A, B로 갈라진다. 모바일도 한 관찰자를 거쳐 다른 관찰자로 전달하는 직렬 관계로 바꾸지 않는다. 각 알림은 원래 주제나 중개자에서 독립적으로 갈라진다. 중개가 항상 비동기, 분산, 재시도와 재전송을 보장하지는 않는다.Observer: 직접 참조Subject관찰자 목록 소유Observer A직접 callbackObserver B직접 callback알림등록·해제: 주제로 향함순회·오류 정책: SubjectPub/Sub: 중개 경로Publisher개별 구독자 모름Broker / topic등록·전달 정책Subscriber Atopic 구독Subscriber Btopic 구독발행전달중개자에서 각각 전달항상 비동기인 것은 아님

연결

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를 제공하는 것은 아니며, 구현의 정책에 달려 있다.

위 그림의 위쪽은 옵저버 패턴입니다. 주제가 관찰자 A와 B의 참조를 직접 갖고 있다가 알림을 보내고, 구독과 해제도 주제에게 요청합니다. 관찰자 목록을 어떻게 순회하고 오류가 나면 어떻게 할지도 주제가 정합니다. 앞의 EventPublisher가 바로 이 구조입니다.

아래쪽은 발행-구독 패턴입니다. 발행자는 개별 구독자를 모르고, 중개자의 토픽(topic)에 메시지를 발행할 뿐입니다. 중개자가 그 토픽을 구독한 A와 B에 각각 메시지를 전달합니다. 어느 패턴이든 알림은 주제나 중개자에서 각 관찰자나 구독자로 따로 갈라지며, 한 관찰자를 거쳐 다른 관찰자로 전달되는 것이 아닙니다.

그림의 표는 두 패턴을 연결, 수명, 오류, 결합 정도로 비교합니다. 옵저버는 대개 같은 프로그램 안에서 주제가 콜백을 직접 호출하고, 구독 관리와 오류 처리도 주제의 notify() 반복문이 정합니다. 발행-구독은 중개자가 구독 정보를 관리하며, 메시지 큐 같은 중개자는 재시도나 다시 보내기 같은 기능을 제공하기도 합니다. 그 대신 발행자와 구독자가 서로 몰라도 되어, 실행 시점이나 위치가 달라도 연결할 수 있습니다. 다만 그림 아래의 설명처럼 중개자가 있다고 해서 항상 비동기로 전달되거나, 여러 서버에 나뉘거나, 재시도가 보장되는 것은 아니며 구현마다 다릅니다.

다음은 한 프로그램 안에서 동작하는 간단한 이벤트 버스입니다. 발행자는 토픽 이름만 알고, subscribe()는 그 등록 하나만 해제하는 함수를 돌려줍니다.

const eventBus = {
    topics: {}, // 토픽 이름 → 구독 함수 목록

    subscribe(topic, handler) {
        if (!this.topics[topic]) {
            this.topics[topic] = [];
        }
        this.topics[topic].push(handler);
        return () => { // 이 등록 하나만 해제하는 함수
            const handlers = this.topics[topic];
            const index = handlers.indexOf(handler);
            if (index !== -1) {
                handlers.splice(index, 1);
            }
        };
    },

    publish(topic, data) {
        const handlers = this.topics[topic] || [];
        handlers.forEach(handler => handler(data));
    }
};

const unsubscribeMail = eventBus.subscribe("article", data => console.log("메일:", data.title));
eventBus.subscribe("article", data => console.log("검색 색인:", data.title));

eventBus.publish("article", { title: "새 글" });
unsubscribeMail();
eventBus.publish("article", { title: "두 번째 글" });

출력은 “메일: 새 글”, “검색 색인: 새 글”, “검색 색인: 두 번째 글”입니다. 글을 발행하는 쪽은 메일을 보내는 코드나 검색 색인을 만드는 코드를 전혀 모르고 "article" 토픽만 압니다. 이 이벤트 버스도 forEach()로 동기 호출하므로, 중개자를 두었다고 비동기가 되지는 않는다는 점을 함께 확인할 수 있습니다.


모듈 패턴

모듈 패턴(module pattern)은 IIFE와 클로저로 비공개(private) 상태와 공개(public) API를 나누는 패턴입니다. ES 모듈이 나오기 전에는 가장 널리 쓰이던 모듈화 방법이었고, 지금도 클로저로 정보를 감추는 원리를 잘 보여 줍니다. 모듈 시스템의 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 (접근 불가)

items와 findItem()은 IIFE 안에 감춰져 있어 ShoppingCart.items처럼 바깥에서 접근하면 undefined이고, 장바구니는 돌려준 네 메서드로만 다룰 수 있습니다. 총액은 사과 1000 × 5와 바나나 1500 × 1을 더한 6500입니다. 같은 이름의 품목을 다시 넣으면 처음 가격을 유지한 채 수량만 더합니다. 이 코드는 가격과 수량이 유한한 양수라고 가정하며, 입력 검증이나 가격 변경까지 다루는 장바구니는 아닙니다.

배열을 복사하면 품목도 보호될까

getCartItems()는 [...items]로 배열을 복사해 돌려줍니다. 앞 예제에 이어 다음 코드를 실행해 보세요.

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입니다. 스프레드 문법은 배열만 새로 만들고 품목 객체는 그대로 공유하므로, 바깥에서 사과의 quantity를 1로 바꾸자 내부의 사과도 바뀌어 총액이 1000 × 1 + 1500이 됩니다. 반면 배열 자체는 다른 객체라서, 바깥 배열에 push()한 배는 내부 배열에 들어가지 않습니다.

items를 클로저 안에 감췄다는 것과 돌려준 값을 바깥에서 바꿀 수 있는지는 별개의 문제입니다. 내부 상태를 지키려면 6장에서 본 것처럼 바뀌는 경로의 객체까지 복사해서 돌려줘야 합니다. ES 모듈이 널리 쓰이면서 IIFE 모듈 패턴을 직접 쓸 일은 줄었지만, 클로저로 정보를 감추는 원리와 그 한계를 이해하는 데는 여전히 좋은 예입니다.


패턴을 고르는 기준

디자인 패턴은 반복되는 설계 문제에 대한 정리된 해결 방법이지만, 맹목적으로 적용하면 오히려 코드가 복잡해집니다. 중요한 것은 문제를 정확히 이해하고 그 문제에 맞는 방법을 고르는 것입니다. 싱글톤은 하나를 함께 써야 할 때, 팩토리는 생성 분기를 모아야 할 때, 옵저버와 발행-구독은 변화를 여러 곳에 알려야 할 때, 모듈 패턴은 내부를 감춰야 할 때 의미가 있습니다.

작은 문제에 거창한 패턴을 적용하는 과도한 설계(over-engineering)는 피하세요. 패턴은 유연성을 주지만, 때로는 함수 하나나 객체 리터럴 하나 같은 단순한 해결책이 더 낫습니다. 패턴은 따라야 할 규칙이 아니라 참고할 지침이므로, 자바스크립트의 특성에 맞게 고치거나 섞어 쓰는 판단이 필요합니다.


정리

  • 디자인 패턴은 반복되는 설계 문제에 대한 검증된 해결 방법이며, GoF는 생성, 구조, 행위 패턴으로 나눴습니다.
  • 싱글톤은 클로저로 인스턴스를 하나만 만들어 돌려주지만, 한 실행 환경 안에서만 하나이고 남용하면 전역 상태가 됩니다.
  • 팩토리는 어떤 객체를 만들지 정하는 분기를 한곳으로 모아, 쓰는 쪽이 생성 과정을 몰라도 되게 합니다.
  • 옵저버는 주제가 관찰자 목록을 직접 갖고 알리며, 발행-구독은 중개자의 토픽을 거쳐 발행자와 구독자를 떼어 놓습니다.
  • 알림이라는 이름이 비동기를 뜻하지는 않으며, 동기 호출 중 한 관찰자의 오류는 뒤의 관찰자 호출을 막을 수 있습니다.
  • 모듈 패턴은 클로저로 내부 상태를 감추지만, 돌려준 객체를 바깥에서 바꿀 수 있는지는 따로 따져야 합니다.

연습 문제

1. 같은 관찰자를 두 번 구독하면

앞의 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()는 같은 객체 참조도 배열에 그대로 두 번 넣으므로, 첫 알림에서 update()가 두 번 호출됩니다. unsubscribe()의 filter()는 그 참조와 같은 원소를 모두 지우므로, 둘째 알림에서는 아무도 호출되지 않아 횟수가 그대로입니다.

두 등록 가운데 하나만 해제해야 한다면 지금 구현으로는 둘을 구별할 수 없습니다. 앞의 이벤트 버스처럼 등록마다 해제 함수를 돌려주거나 등록별 식별자를 두어야 합니다. 이 결과를 DOM의 addEventListener()에 그대로 적용하면 안 되며, DOM은 같은 리스너를 같은 조건으로 두 번 등록하면 한 번만 등록합니다.

2. 품목까지 복사해 돌려주기

장바구니 예제를 새로 실행한 뒤, const snapshotItemsForPattern = ShoppingCart.getCartItems().map(item => ({ ...item }));로 받은 배열의 첫 품목 수량을 1로 바꿨습니다. ShoppingCart.getTotal()은 얼마일까요? 이 방법이 언제나 충분할까요?

해설 보기

총액은 6500 그대로입니다. map()으로 품목마다 새 객체를 만들어 복사했으므로, 복사본의 수량을 바꿔도 내부의 품목 객체는 영향을 받지 않습니다.

이 예제의 품목은 name, price, quantity가 모두 원시값이라 품목마다 한 단계만 복사해도 충분합니다. 품목 안에 다른 객체가 들어 있다면 그 객체는 여전히 공유되므로, 중첩된 객체까지 복사해야 내부 상태를 지킬 수 있습니다.

3. 한 구독자가 실패하면

옵저버 예제에서 Logger의 update() 첫 줄에 throw new Error("로그 저장 실패");를 넣었습니다. 첫 notify()를 호출하면 분석 서비스는 알림을 받을까요? 한 관찰자가 실패해도 나머지가 알림을 받게 하려면 notify()를 어떻게 고치면 될까요?

해설 보기

받지 못합니다. 이메일 서비스는 알림을 받지만, 로깅 서비스의 update()가 던진 오류가 forEach()를 빠져나가 notify()를 호출한 쪽까지 전해지므로, 그 뒤에 있는 분석 서비스는 호출되지 않습니다. 분석 서비스도 같은 Logger 클래스라 결국 실패하겠지만, 이 순서에서는 호출되기 전에 멈춥니다.

notify() 안에서 this.observers.forEach(observer => { try { observer.update(data); } catch (error) { console.error(observer.name, "실패:", error.message); } });처럼 관찰자마다 try...catch로 감싸면, 한 관찰자의 실패가 다른 관찰자의 알림을 막지 않습니다. 실패를 기록만 할지, 모아서 다시 던질지는 애플리케이션에 맞게 정합니다.

GoF 책의 초판과 23가지 패턴은 출판사의 도서 안내에서 확인할 수 있습니다. 중복 구독과 장바구니의 결과는 이 글의 구현을 직접 따라간 것이며, 모든 라이브러리의 동작을 설명하지는 않습니다.

이것으로 7장을 마칩니다. 7장에서는 Promise와 async/await로 비동기 흐름을 다루고, 이터레이터와 제너레이터, 모듈 시스템, 메모리 관리를 거쳐 디자인 패턴으로 코드 구조를 설계하는 방법까지 살펴봤습니다. 다음 장의 let, const와 블록 스코프부터는 화살표 함수, 구조 분해 할당과 스프레드 연산자, Symbol과 Map, Set, Proxy와 Reflect까지 최신 ECMAScript가 더한 기능을 차례로 익힙니다.