옵저버·발행-구독 패턴 — 느슨한 결합의 이벤트 설계
옵저버 패턴과 발행-구독 패턴의 차이, JavaScript EventEmitter 기반 구현, 메모리 누수 방지를 위한 구독 해제 전략, 실제 상태 관리 스토어 구현을 다룹니다.
지난 글에서 함수형 에러 설계 패턴을 다뤘습니다. 이번부터는 디자인 패턴 시리즈를 시작합니다. 첫 번째 주제는 느슨한 결합 이벤트 시스템의 기초, 옵저버 패턴 과 발행-구독 패턴 입니다.
옵저버 패턴
옵저버 패턴에서는 Subject(Observable) 가 상태 변화를 직접 Observer 들에게 알립니다. Subject는 Observer 목록을 유지하고, 변화가 있을 때 순회하며 알림을 보냅니다.
class Observable {
#observers = [];
subscribe(observer) {
this.#observers.push(observer);
// 구독 해제 함수 반환 (클린업 패턴)
return () => {
this.#observers = this.#observers.filter(o => o !== observer);
};
}
notify(data) {
this.#observers.forEach(observer => observer(data));
}
}
Subject는 Observer를 직접 참조하므로 강한 결합입니다. Observer를 구독 해제하지 않으면 메모리 누수가 발생합니다.
옵저버로 상태 스토어 만들기
옵저버 패턴으로 간단한 상태 스토어를 구현할 수 있습니다. Zustand, Jotai 같은 라이브러리의 핵심 원리입니다.
class Store extends Observable {
#state;
constructor(initialState) {
super();
this.#state = initialState;
}
setState(updater) {
const next = updater(this.#state);
if (next !== this.#state) { // 참조 변경 시에만 알림
this.#state = next;
this.notify(this.#state);
}
}
getState() {
return this.#state;
}
}
// 사용
const store = new Store({ count: 0, user: null });
const unsubscribe = store.subscribe(state => {
console.log('상태 변경:', state.count);
});
store.setState(s => ({ ...s, count: s.count + 1 })); // "상태 변경: 1"
store.setState(s => ({ ...s, count: s.count + 1 })); // "상태 변경: 2"
// 클린업
unsubscribe();
store.setState(s => ({ ...s, count: 99 })); // 알림 없음
발행-구독(Pub/Sub) 패턴
발행-구독 패턴은 중간 브로커(Message Bus, Event Bus) 를 둬서 발행자(Publisher)와 구독자(Subscriber)가 서로를 전혀 모르게 합니다.
class EventBus {
#handlers = new Map();
on(event, handler) {
if (!this.#handlers.has(event)) {
this.#handlers.set(event, new Set());
}
this.#handlers.get(event).add(handler);
// 구독 해제 함수 반환
return () => this.#handlers.get(event)?.delete(handler);
}
once(event, handler) {
const wrapper = (...args) => {
handler(...args);
off();
};
const off = this.on(event, wrapper);
return off;
}
emit(event, data) {
this.#handlers.get(event)?.forEach(handler => {
try {
handler(data);
} catch (e) {
console.error(`EventBus error in "${event}":`, e);
}
});
}
off(event, handler) {
this.#handlers.get(event)?.delete(handler);
}
}
const bus = new EventBus();
// 구독자들은 서로를 모름
const off1 = bus.on('user:login', user => console.log(`${user.name} 로그인`));
const off2 = bus.on('user:login', user => updateLastLogin(user.id));
const off3 = bus.on('user:login', user => sendWelcomeNotification(user));
// 발행자는 구독자를 모름
bus.emit('user:login', { id: 1, name: 'Alice' });
// 클린업
off1(); off2(); off3();
옵저버 vs Pub/Sub 차이
| 항목 | 옵저버 | Pub/Sub |
|---|---|---|
| 결합도 | Subject → Observer 직접 참조 | 브로커로 완전 분리 |
| 통신 방식 | 동기 | 동기 또는 비동기 |
| 스코프 | 단일 앱 내 | 크로스 모듈, 마이크로서비스 |
| 예시 | DOM 이벤트, RxJS Subject | Redis Pub/Sub, Kafka |
메모리 누수 방지
옵저버 패턴의 가장 흔한 실수는 구독 해제를 빠트리는 것입니다.
// React 컴포넌트에서의 클린업 패턴
function UserStatus({ userId }) {
const [status, setStatus] = useState('offline');
useEffect(() => {
// 구독
const off = store.subscribe(state => {
if (state.userId === userId) {
setStatus(state.status);
}
});
// 언마운트 시 반드시 구독 해제
return off;
}, [userId]);
return <span>{status}</span>;
}
// WeakRef를 활용한 자동 클린업 (참조가 GC되면 자동 제거)
class WeakObservable {
#refs = new Set();
subscribe(observer) {
const ref = new WeakRef(observer);
this.#refs.add(ref);
return () => this.#refs.delete(ref);
}
notify(data) {
for (const ref of this.#refs) {
const observer = ref.deref();
if (observer) {
observer(data);
} else {
this.#refs.delete(ref); // GC된 참조 제거
}
}
}
}
DOM 이벤트는 옵저버 패턴
브라우저의 addEventListener는 옵저버 패턴의 구현입니다.
const button = document.querySelector('#btn');
// subscribe
const handler = e => console.log('클릭!', e.target);
button.addEventListener('click', handler);
// unsubscribe — 반드시 같은 함수 참조로
button.removeEventListener('click', handler);
// AbortController로 일괄 해제
const controller = new AbortController();
button.addEventListener('click', handler, { signal: controller.signal });
input.addEventListener('input', inputHandler, { signal: controller.signal });
// 모든 리스너 한 번에 해제
controller.abort();
비동기 이벤트 처리
이벤트 핸들러가 비동기 함수일 때는 에러를 명시적으로 처리해야 합니다.
class AsyncEventBus extends EventBus {
async emitAsync(event, data) {
const handlers = [...(this.#handlers.get(event) ?? [])];
const results = await Promise.allSettled(
handlers.map(h => Promise.resolve(h(data)))
);
const errors = results.filter(r => r.status === 'rejected');
if (errors.length > 0) {
console.error(`${event}: ${errors.length}개 핸들러 실패`);
}
}
}
정리
옵저버 패턴은 Subject와 Observer가 직접 연결된 동기적이고 단순한 이벤트 시스템입니다. Pub/Sub은 브로커를 통해 완전히 분리된 확장 가능한 이벤트 시스템입니다. 두 패턴 모두 구독 해제(unsubscribe) 가 핵심입니다.
지난 글: 에러를 값으로 — 함수형 에러 설계 패턴
다음 글: 싱글턴·팩토리 패턴 — 객체 생성의 제어와 추상화
읽어주셔서 감사합니다. 😊