JavaScript 디자인 패턴 | 싱글톤, 팩토리, 옵저버 패턴

이 글의 핵심

디자인 패턴을 클래스 기반 언어의 방식 그대로 옮기면 JavaScript에서는 불필요하게 장황해지기 쉽습니다. 모듈 자체가 싱글톤처럼 동작하는 점, 옵저버 패턴이 이벤트 시스템의 뼈대가 되는 점처럼 언어 특성에 맞는 구현을 보여 주고, 간단한 상태 관리 예제에서 여러 패턴을 조합해 봅니다.

들어가며

디자인 패턴이란?

디자인 패턴(Design Pattern)은 반복되는 설계 문제에 대해 이름 붙여 둔 검증된 해결 템플릿입니다. 팀 안에서 “팩토리 쓰자”처럼 짧게 의사소통할 때도 도움이 됩니다. 패턴을 알아 두면 좋은 이유: 이미 여러 프로젝트에서 쓰이며 장단점이 알려진 구조라서, 비슷한 문제를 만났을 때 처음부터 설계하지 않아도 됩니다. 다만 패턴은 문제가 있을 때 꺼내는 도구이지 목표가 아니므로, JavaScript에서는 모듈 스코프나 클로저처럼 언어가 이미 제공하는 기능으로 충분한 경우 클래스 기반 구현을 억지로 따를 필요가 없습니다.


싱글톤 패턴 (Singleton)

개념

하나의 인스턴스만 생성하고 전역에서 접근 가능하게 합니다.

구현

class Database {
    constructor() {
        if (Database.instance) {
            return Database.instance;
        }
        
        this.connection = null;
        Database.instance = this;
    }
    
    connect() {
        if (!this.connection) {
            this.connection = "DB 연결됨";
            console.log(this.connection);
        }
        return this.connection;
    }
}
// 사용
const db1 = new Database();
const db2 = new Database();
console.log(db1 === db2);  // true (같은 인스턴스)
db1.connect();  // DB 연결됨
db2.connect();  // (이미 연결됨)

이 구현은 생성자에서 객체를 명시적으로 반환하면 new의 결과가 그 객체로 바뀐다는 JavaScript 규칙을 이용합니다. 두 번째 new Database()는 새로 만든 this를 버리고 저장해 둔 인스턴스를 돌려줍니다. 동작은 하지만 읽는 사람에게는 new가 새 객체를 만들지 않는다는 점이 놀라울 수 있고, Database.instance가 외부에서 보이는 정적 프로퍼티라 Database.instance = null로 누구나 초기화할 수 있습니다. 또 이 클래스를 상속하면 자식 클래스 생성자도 부모의 인스턴스를 받게 되어 동작이 꼬입니다.

모던 방식 (ES6 Module)

// database.js
class Database {
    constructor() {
        this.connection = null;
    }
    
    connect() {
        if (!this.connection) {
            this.connection = "DB 연결됨";
        }
        return this.connection;
    }
}
export default new Database();
// main.js
import db from './database.js';
db.connect();

ES 모듈은 처음 import될 때 한 번만 평가되고, 이후 다른 파일에서 import해도 같은 모듈 인스턴스를 받습니다. 그래서 export default new Database()만으로 앱 전체가 같은 객체를 공유합니다. 클래스를 export하지 않으므로 다른 곳에서 두 번째 인스턴스를 만들 수도 없습니다.

다만 “같은 모듈”의 기준이 해석된 경로(URL)라는 점은 알아 둬야 합니다. 번들러 설정이나 심볼릭 링크 때문에 같은 파일이 다른 경로로 두 번 로드되거나, 모노레포에서 같은 패키지가 서로 다른 버전으로 중복 설치되면 인스턴스가 두 개 생깁니다. “싱글톤인데 상태가 공유되지 않는다”는 증상은 대개 이 경우입니다. 또 테스트에서는 모듈 상태가 테스트 사이에 남으므로, Jest의 jest.resetModules()처럼 모듈 캐시를 초기화하는 기능이 필요해집니다.


팩토리 패턴 (Factory)

개념

객체 생성 로직을 캡슐화하여 유연하게 객체를 생성합니다.

구현

class User {
    constructor(name, role) {
        this.name = name;
        this.role = role;
    }
    
    getPermissions() {
        return [];
    }
}
class Admin extends User {
    getPermissions() {
        return ['read', 'write', 'delete'];
    }
}
class Guest extends User {
    getPermissions() {
        return ['read'];
    }
}
class Member extends User {
    getPermissions() {
        return ['read', 'write'];
    }
}
// Factory
class UserFactory {
    static createUser(name, role) {
        switch (role) {
            case 'admin':
                return new Admin(name, role);
            case 'member':
                return new Member(name, role);
            case 'guest':
                return new Guest(name, role);
            default:
                throw new Error(`알 수 없는 역할: ${role}`);
        }
    }
}
// 사용
const admin = UserFactory.createUser("홍길동", "admin");
console.log(admin.getPermissions());  // ['read', 'write', 'delete']
const guest = UserFactory.createUser("손님", "guest");
console.log(guest.getPermissions());  // ['read']

팩토리의 핵심은 호출하는 쪽이 구체 클래스를 몰라도 된다는 것입니다. 서버에서 받은 role 문자열로 객체를 만드는 코드가 여러 곳에 흩어져 있으면, 역할이 하나 추가될 때마다 모든 switch를 고쳐야 합니다. 생성 로직을 팩토리 한 곳에 모으면 새 역할은 팩토리만 바꾸면 됩니다. default에서 예외를 던지는 것도 중요합니다. 알 수 없는 역할에 기본 User를 조용히 돌려주면 권한이 빈 사용자가 만들어져 원인을 찾기 어렵습니다.

JavaScript에서는 클래스의 정적 메서드 대신 객체 맵으로 더 짧게 쓰는 경우가 많습니다. const roles = { admin: Admin, member: Member, guest: Guest };를 두고 new (roles[role] ?? throwUnknown(role))(name, role)처럼 조회하면 switch가 사라지고, 역할 추가는 맵에 한 줄을 넣는 것으로 끝납니다. 단, 사용자 입력을 키로 쓸 때 roles['constructor']처럼 Object.prototype의 프로퍼티가 걸리지 않도록 Object.hasOwn(roles, role)로 확인하거나 Map을 쓰는 것이 안전합니다. 사실 이 예제처럼 권한 목록만 다르다면 상속 없이 { admin: ['read', 'write', 'delete'], ... } 데이터 하나로 충분할 수도 있습니다. 패턴을 적용하기 전에 더 단순한 구조로 풀리는지 먼저 따져 보는 것이 좋습니다.


모듈 패턴 (Module)

개념

캡슐화를 통해 private 변수와 public 메서드를 구분합니다.

구현 (IIFE)

const Counter = (function() {
    // Private 변수
    let count = 0;
    
    // Private 함수
    function log() {
        console.log(`현재 카운트: ${count}`);
    }
    
    // Public API
    return {
        increment() {
            count++;
            log();
        },
        decrement() {
            count--;
            log();
        },
        getCount() {
            return count;
        }
    };
})();
// 사용
Counter.increment();  // 현재 카운트: 1
Counter.increment();  // 현재 카운트: 2
console.log(Counter.getCount());  // 2
console.log(Counter.count);  // undefined (private)

즉시 실행 함수(IIFE)는 호출이 끝나도 반환한 객체의 메서드들이 count 변수를 계속 참조하므로(클로저), count는 사라지지 않고 외부에서는 접근할 수 없는 상태로 남습니다. ES 모듈이 없던 시절 전역 변수 충돌을 피하려고 널리 쓰던 방식이며, jQuery 플러그인 같은 오래된 코드에서 자주 보입니다. 지금은 파일 자체가 모듈 스코프를 가지므로 모듈 최상위에 let count = 0을 두고 필요한 함수만 export하면 같은 효과를 얻습니다.

클로저 방식의 비밀 변수는 정말로 접근할 수 없어서, 디버거로 들여다보거나 테스트에서 상태를 강제로 바꾸는 것도 어렵습니다. 테스트를 위해 reset() 같은 메서드를 따로 공개하게 되는 경우가 많습니다.

모던 방식 (ES6 Class)

class Counter {
    #count = 0;  // Private field
    
    increment() {
        this.#count++;
        this.#log();
    }
    
    decrement() {
        this.#count--;
        this.#log();
    }
    
    getCount() {
        return this.#count;
    }
    
    #log() {
        console.log(`현재 카운트: ${this.#count}`);
    }
}
const counter = new Counter();
counter.increment();  // 현재 카운트: 1
console.log(counter.getCount());  // 1
// console.log(counter.#count);  // SyntaxError (private)

#으로 시작하는 private 필드(ES2022)는 이름 규칙(_count)과 달리 언어가 접근을 막습니다. 클래스 밖에서 counter.#count를 쓰면 실행 중 오류가 아니라 파싱 단계의 SyntaxError라서, 그 줄이 있는 파일 전체가 실행되지 않습니다. 예제에서 주석으로 처리한 이유입니다. 반면 counter['#count']처럼 문자열로 접근하면 오류 없이 undefined가 나오는데, #count는 일반 프로퍼티 이름이 아니기 때문입니다.

IIFE 방식과 비교하면 private 필드는 인스턴스를 여러 개 만들 수 있고(new Counter() 두 번), 메서드가 프로토타입에 한 번만 정의되어 메모리 효율이 좋습니다. 반대로 Proxy로 감싼 객체에서 private 필드에 접근하는 메서드를 호출하면 this가 프록시라서 “Cannot read private member #count from an object whose class did not declare it” 오류가 나는 제약이 있습니다. Vue 3의 반응형 객체처럼 프록시를 쓰는 라이브러리와 섞어 쓸 때 만나는 문제입니다.


옵저버 패턴 (Observer)

개념

객체의 상태 변화를 구독자들에게 알립니다.

구현

class Subject {
    constructor() {
        this.observers = [];
    }
    
    subscribe(observer) {
        this.observers.push(observer);
    }
    
    unsubscribe(observer) {
        this.observers = this.observers.filter(obs => obs !== observer);
    }
    
    notify(data) {
        this.observers.forEach(observer => observer.update(data));
    }
}
class Observer {
    constructor(name) {
        this.name = name;
    }
    
    update(data) {
        console.log(`${this.name}이(가) 알림 받음:`, data);
    }
}
// 사용
const subject = new Subject();
const observer1 = new Observer("관찰자1");
const observer2 = new Observer("관찰자2");
subject.subscribe(observer1);
subject.subscribe(observer2);
subject.notify("새 데이터!");
// 관찰자1이(가) 알림 받음: 새 데이터!
// 관찰자2이(가) 알림 받음: 새 데이터!
subject.unsubscribe(observer1);
subject.notify("또 다른 데이터");
// 관찰자2이(가) 알림 받음: 또 다른 데이터

Subject는 구독자가 누구인지 모르고 update()를 가진 객체라는 사실만 압니다. 그래서 알림을 받는 쪽을 추가하거나 빼도 Subject 코드는 바뀌지 않습니다. 브라우저의 addEventListener, Node.js의 EventEmitter, RxJS의 Observable, React 상태 관리 라이브러리의 subscribe가 모두 이 구조입니다.

옵저버 패턴에서 가장 흔한 문제는 구독 해제를 잊어 생기는 메모리 누수입니다. Subject가 구독자를 배열에 붙잡고 있으므로, 화면에서 사라진 컴포넌트가 구독을 해제하지 않으면 가비지 컬렉션되지 않고 알림도 계속 받습니다. SPA에서 페이지를 오갈 때마다 같은 로그가 한 줄씩 늘어난다면 이 문제를 의심해 보세요. 또 notify 중 한 구독자의 update에서 예외가 나면 forEach가 중단되어 뒤쪽 구독자는 알림을 못 받으므로, 구독자마다 try/catch로 감싸는 구현도 흔합니다.

이벤트 시스템 예제

class EventEmitter {
    constructor() {
        this.events = {};
    }
    
    on(event, listener) {
        if (!this.events[event]) {
            this.events[event] = [];
        }
        this.events[event].push(listener);
    }
    
    off(event, listener) {
        if (!this.events[event]) return;
        this.events[event] = this.events[event].filter(l => l !== listener);
    }
    
    emit(event, data) {
        if (!this.events[event]) return;
        this.events[event].forEach(listener => listener(data));
    }
    
    once(event, listener) {
        const wrapper = (data) => {
            listener(data);
            this.off(event, wrapper);
        };
        this.on(event, wrapper);
    }
}
// 사용
const emitter = new EventEmitter();
function onUserLogin(user) {
    console.log(`${user.name} 로그인`);
}
emitter.on('login', onUserLogin);
emitter.on('login', (user) => {
    console.log(`환영합니다, ${user.name}님!`);
});
emitter.emit('login', { name: '홍길동' });
// 홍길동 로그인
// 환영합니다, 홍길동님!
// once: 한 번만 실행
emitter.once('logout', (user) => {
    console.log(`${user.name} 로그아웃`);
});
emitter.emit('logout', { name: '홍길동' });  // 홍길동 로그아웃
emitter.emit('logout', { name: '홍길동' });  // (실행 안 됨)

EventEmitter는 옵저버를 이벤트 이름별로 나눈 형태입니다. 구독자가 객체가 아니라 함수라서 update 메서드를 가진 클래스를 만들 필요가 없습니다. JavaScript에서 옵저버 패턴이 대부분 이 모양으로 쓰이는 이유입니다.

off는 등록할 때와 같은 함수 참조를 넘겨야 동작합니다. onUserLogin처럼 이름 있는 함수는 off('login', onUserLogin)으로 뗄 수 있지만, 두 번째로 등록한 화살표 함수는 참조를 저장해 두지 않아 다시는 제거할 수 없습니다. once도 비슷한 함정이 있습니다. 실제로 등록되는 것은 wrapper이므로, once로 등록한 뒤 원래 listener로 off를 호출하면 아무것도 제거되지 않습니다. Node.js의 내장 EventEmitter는 wrapper에 원래 함수를 저장해 이 경우도 처리해 주며, 브라우저 addEventListener는 { once: true } 옵션과 AbortController로 여러 리스너를 한 번에 해제하는 방법을 제공합니다.

emit 안에서 리스너가 off를 호출해도 이 구현은 안전합니다. off가 filter로 새 배열을 만들어 대입하므로, 순회 중인 기존 배열은 바뀌지 않기 때문입니다. splice로 원래 배열을 직접 수정하는 구현이었다면 once 리스너가 자기 자신을 지우는 순간 다음 리스너 하나를 건너뛰는 버그가 생깁니다.


프록시 패턴 (Proxy)

개념

객체에 대한 접근을 제어하거나 추가 기능을 제공합니다.

구현 (ES6 Proxy)

const user = {
    name: "홍길동",
    age: 25,
    email: "[email protected]"
};
const handler = {
    get(target, prop) {
        console.log(`${prop} 읽기`);
        return target[prop];
    },
    set(target, prop, value) {
        console.log(`${prop}을(를) ${value}로 설정`);
        
        // 유효성 검사
        if (prop === 'age' && typeof value !== 'number') {
            throw new TypeError("나이는 숫자여야 합니다");
        }
        
        target[prop] = value;
        return true;
    }
};
const proxyUser = new Proxy(user, handler);
console.log(proxyUser.name);  // name 읽기 -> 홍길동
proxyUser.age = 26;  // age을(를) 26로 설정
// proxyUser.age = "26";  // TypeError

Proxy는 객체에 대한 기본 동작(읽기 get, 쓰기 set, 삭제 deleteProperty, 함수 호출 apply 등)을 가로채는 함수(trap)를 끼워 넣습니다. 원본 객체를 고치지 않고 검증, 로깅, 접근 제어를 추가할 수 있어서 Vue 3의 반응형 시스템, MobX, Immer 같은 라이브러리가 이 기능 위에 만들어져 있습니다.

set 트랩이 true를 반환하는 이유는 “쓰기가 성공했다”를 알리기 위해서입니다. false를 반환하면 strict mode(ES 모듈과 클래스는 기본이 strict)에서 TypeError: 'set' on proxy: trap returned falsish가 납니다. 값을 대입하는 방식으로는 target[prop] = value 대신 Reflect.set(target, prop, value, receiver)를 쓰는 편이 getter/setter와 상속까지 올바르게 처리합니다. 또 프록시는 프록시를 통해 접근할 때만 동작한다는 점이 중요합니다. 원본 user 객체에 직접 user.age = "26"을 하면 검증을 우회하므로, 원본 참조를 외부에 노출하지 않아야 의미가 있습니다. 트랩은 모든 접근마다 호출되어 일반 객체보다 느리므로, 반복문 안에서 수백만 번 접근하는 핫 경로에는 적합하지 않습니다.

캐싱 프록시 예제

function createCachedFunction(fn) {
    const cache = new Map();
    
    return new Proxy(fn, {
        apply(target, thisArg, args) {
            const key = JSON.stringify(args);
            
            if (cache.has(key)) {
                console.log("캐시에서 반환");
                return cache.get(key);
            }
            
            console.log("계산 중...");
            const result = target.apply(thisArg, args);
            cache.set(key, result);
            return result;
        }
    });
}
// 사용
function fibonacci(n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}
const cachedFib = createCachedFunction(fibonacci);
console.log(cachedFib(10));  // 계산 중... -> 55
console.log(cachedFib(10));  // 캐시에서 반환 -> 55

apply 트랩은 프록시로 감싼 함수가 호출될 때 실행되어, 인자를 키로 결과를 저장해 두었다가 같은 인자로 다시 호출되면 계산을 건너뜁니다. 원래 함수는 캐시의 존재를 전혀 모르므로 어떤 순수 함수에도 같은 방식으로 붙일 수 있습니다.

이 예제에는 눈여겨볼 한계가 있습니다. fibonacci 내부의 재귀 호출은 프록시가 아니라 원래 함수 fibonacci를 직접 호출하므로 캐시를 거치지 않습니다. 그래서 첫 cachedFib(10)은 캐시 없는 재귀와 똑같이 지수 시간이 걸리고, 캐시는 “똑같은 인자로 다시 호출했을 때”만 도움이 됩니다. cachedFib(40)을 처음 호출하면 여전히 수 초가 걸리는 이유입니다. 재귀까지 캐시하려면 함수 안에서 cachedFib를 호출하도록 바꿔야 합니다.

JSON.stringify(args)를 키로 쓰는 방식도 주의가 필요합니다. 객체 인자는 프로퍼티 순서가 다르면 다른 키가 되고, 함수나 undefined는 직렬화되지 않으며, 순환 참조가 있으면 예외가 납니다. 숫자·문자열 인자에는 충분하지만 범용 캐시로 쓰기는 어렵습니다. 캐시 크기에 제한이 없어 입력 종류가 많으면 메모리가 계속 늘어난다는 점도 실무에서는 LRU 같은 제거 정책으로 보완합니다.


전략 패턴 (Strategy)

개념

알고리즘을 캡슐화하여 런타임에 선택할 수 있게 합니다.

구현

// 전략들
class CreditCardStrategy {
    pay(amount) {
        console.log(`신용카드로 ${amount}원 결제`);
    }
}
class PayPalStrategy {
    pay(amount) {
        console.log(`PayPal로 ${amount}원 결제`);
    }
}
class CryptoStrategy {
    pay(amount) {
        console.log(`암호화폐로 ${amount}원 결제`);
    }
}
// Context
class PaymentContext {
    constructor(strategy) {
        this.strategy = strategy;
    }
    
    setStrategy(strategy) {
        this.strategy = strategy;
    }
    
    executePayment(amount) {
        this.strategy.pay(amount);
    }
}
// 사용
const payment = new PaymentContext(new CreditCardStrategy());
payment.executePayment(10000);  // 신용카드로 10000원 결제
payment.setStrategy(new PayPalStrategy());
payment.executePayment(20000);  // PayPal로 20000원 결제

전략 패턴은 if (method === 'card') { ... } else if (method === 'paypal') { ... }로 길어지는 분기를 같은 인터페이스(pay)를 가진 객체들로 바꿉니다. PaymentContext는 어떤 결제 수단인지 모르고 pay만 호출하므로, 결제 수단을 추가해도 기존 코드를 고치지 않습니다. 각 전략을 따로 테스트할 수 있다는 것도 장점입니다.

JavaScript에서는 함수가 일급 값이라 전략을 클래스로 만들 필요가 없는 경우가 많습니다. const strategies = { card: (amount) => ..., paypal: (amount) => ... }처럼 함수를 담은 객체를 두고 strategies[method](amount)로 호출하면 같은 효과를 훨씬 짧게 얻습니다. Array.prototype.sort에 비교 함수를 넘기는 것도 전략 패턴의 한 형태입니다. 전략이 여러 메서드(결제, 환불, 수수료 계산)를 함께 가져야 하거나 내부 상태(API 클라이언트)를 가져야 할 때 클래스 형태가 의미를 가집니다.


옵저버를 응용한 상태 관리 스토어

class Store {
    constructor(initialState = {}) {
        this.state = initialState;
        this.listeners = [];
    }
    
    getState() {
        return this.state;
    }
    
    setState(newState) {
        this.state = { ...this.state, ...newState };
        this.notify();
    }
    
    subscribe(listener) {
        this.listeners.push(listener);
        return () => {
            this.listeners = this.listeners.filter(l => l !== listener);
        };
    }
    
    notify() {
        this.listeners.forEach(listener => listener(this.state));
    }
}
// 사용
const store = new Store({ count: 0, user: null });
const unsubscribe = store.subscribe((state) => {
    console.log("상태 변경:", state);
});
store.setState({ count: 1 });
// 상태 변경: { count: 1, user: null }
store.setState({ user: { name: "홍길동" } });
// 상태 변경: { count: 1, user: { name: "홍길동" } }
unsubscribe();  // 구독 해제

이 Store는 앞의 패턴 몇 가지를 조합한 형태입니다. 상태 변경을 구독자에게 알리는 부분은 옵저버이고, 모듈에서 인스턴스 하나를 export하면 싱글톤처럼 앱 전체가 같은 상태를 공유합니다. Redux와 Zustand의 핵심 구조가 거의 이 모양입니다.

두 가지 설계 선택이 중요합니다. 첫째, setState는 기존 상태를 고치지 않고 스프레드(...)로 새 객체를 만듭니다. 그래서 구독자는 prevState !== nextState처럼 참조 비교 한 번으로 변경 여부를 알 수 있고, React가 리렌더링 여부를 판단하는 방식과도 맞습니다. 반대로 store.getState().count = 5처럼 외부에서 직접 고치면 알림 없이 상태가 바뀌어 화면과 데이터가 어긋나므로, 실제 라이브러리는 개발 모드에서 Object.freeze로 막거나 Immer로 불변 업데이트를 강제합니다. 스프레드는 한 단계만 복사하므로 user.name처럼 중첩된 값을 바꿀 때는 중첩 객체도 새로 만들어야 합니다.

둘째, subscribe가 구독 해제 함수를 반환합니다. 호출한 쪽이 리스너 참조를 따로 보관할 필요가 없고, React의 useEffect에서 return store.subscribe(...) 한 줄로 정리까지 연결할 수 있어 널리 쓰이는 관례입니다. 옵저버 절에서 말한 메모리 누수를 줄이는 가장 간단한 방법이기도 합니다.


여섯 가지 패턴 요약

  1. 싱글톤: 하나의 인스턴스
  2. 팩토리: 객체 생성 캡슐화
  3. 모듈: private/public 구분
  4. 옵저버: 상태 변화 알림
  5. 프록시: 접근 제어
  6. 전략: 알고리즘 교체

다음 단계


관련 글


자주 묻는 질문 (FAQ)

Q. ES 모듈을 쓰면 싱글톤을 클래스로 따로 구현할 필요가 있나요?

A. 대부분은 필요 없습니다. ES 모듈은 처음 import될 때 한 번만 평가되고 이후에는 같은 인스턴스를 공유하므로, 모듈에서 객체 하나를 만들어 export하는 것만으로 싱글톤처럼 동작합니다. 다만 전역 상태가 되기 때문에 테스트마다 상태를 초기화하기 어려워지므로, 테스트에서 교체해야 하는 의존성이라면 생성 함수를 export하고 주입받는 구조가 더 다루기 쉽습니다.