Observer: 상태 변화를 구독자에게 자동으로 알리는 일대다 의존 관계
옵저버(Observer) 패턴은 한 객체(주체, Subject)의 상태가 바뀔 때 이를 구독 중인 여러 객체(관찰자, Observer)에게 자동으로 알려주는 행동 패턴이다. 주체와 관찰자를 인터페이스로만 연결해 느슨한 결합(loose coupling)을 만든다. 이벤트 이미터, 상태 관리 라이브러리, 반응형 프로그래밍의 기반이 되는 구조다.
By 송민성
1. 개념
옵저버(Observer) 패턴은 객체 사이에 일대다(one-to-many) 의존 관계를 정의하는 행동 패턴(behavioral pattern)이다. 한 객체의 상태가 바뀌면 그 객체에 의존하는 모든 객체가 자동으로 통지(notify)를 받고 갱신된다.
구성 요소는 다음과 같다.
- 주체(Subject, Observable): 상태를 가지고, 관찰자 목록을 관리한다. 구독(subscribe), 구독 해지(unsubscribe), 통지(notify) 기능을 제공한다.
- 관찰자(Observer): 통지를 받을 때 호출되는
update같은 메서드를 가진다. - 구체 주체/구체 관찰자: 실제 상태와 반응 로직을 구현한다.
비슷한 용어로 발행-구독(Pub/Sub)이 있다. 옵저버는 주체가 관찰자를 직접 알고 호출하는 반면, Pub/Sub는 중간의 메시지 브로커나 이벤트 버스(event bus)를 거쳐 발행자와 구독자가 서로를 모른다는 차이가 있다.
2. 왜 사용하는가
상태가 바뀔 때 갱신해야 할 대상이 여러 곳이라면, 주체가 그 대상들을 직접 호출하는 코드를 갖게 된다.
// 안티 패턴: 주체가 모든 의존 대상을 직접 안다
class Cart {
items: string[] = [];
add(item: string) {
this.items.push(item);
badge.render(this.items.length); // UI
analytics.track("add", item); // 분석
storage.save(this.items); // 저장
}
}이 구조에서는 새 반응이 필요할 때마다 Cart를 수정해야 한다. 옵저버 패턴을 쓰면 다음 이점이 생긴다.
- 주체가 관찰자의 구체 타입을 몰라도 되어 결합도가 낮아진다.
- 개방-폐쇄 원칙(OCP)에 맞게, 주체 수정 없이 관찰자를 추가·제거할 수 있다.
- 런타임에 구독 관계를 동적으로 바꿀 수 있다.
3. 동작 원리
- 관찰자가 주체에 자신을 등록한다(
subscribe). - 주체의 상태가 바뀐다.
- 주체가 등록된 관찰자 목록을 순회하며 각각의
update를 호출한다(notify). - 관찰자는 필요하면 주체에서 새 상태를 읽거나, 통지에 실린 데이터를 사용한다.
- 더 이상 필요 없으면 구독을 해지한다(
unsubscribe).
통지 방식은 두 가지가 있다.
- 푸시(push) 모델: 변경된 데이터를 통지 인자로 직접 전달한다. 관찰자는 단순하지만 불필요한 데이터가 전달될 수 있다.
- 풀(pull) 모델: 통지만 보내고, 관찰자가 필요한 데이터를 주체에서 직접 가져간다. 유연하지만 주체의 인터페이스에 의존하게 된다.
기본 구현은 동기(synchronous) 호출이다. notify가 반환되는 시점에는 모든 관찰자의 콜백이 실행을 마친 상태다.
4. 코드 예제
타입 안전한 제네릭 주체를 구현하고, 구독 해지 함수를 반환하는 형태로 작성한다. Node.js와 ts-node 또는 tsx로 바로 실행할 수 있다.
type Listener<T> = (value: T) => void;
class Subject<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
protected notify(value: T): void {
// 순회 중 구독/해지가 일어나도 안전하도록 복사본을 순회한다
for (const listener of [...this.listeners]) {
try {
listener(value);
} catch (err) {
console.error("observer error:", err);
}
}
}
}
class Store extends Subject<number> {
private count = 0;
get value(): number {
return this.count;
}
increment(): void {
this.count += 1;
this.notify(this.count);
}
}
const store = new Store();
const unsubscribeLog = store.subscribe((n) => console.log(`[log] count=${n}`));
store.subscribe((n) => {
if (n % 2 === 0) console.log(`[even] ${n}은(는) 짝수`);
});
store.increment(); // [log] count=1
store.increment(); // [log] count=2, [even] 2은(는) 짝수
unsubscribeLog(); // 로그 관찰자 해지
store.increment(); // 3: 아무 출력 없음
store.increment(); // 4: [even] 4은(는) 짝수구독 해지 함수를 반환하는 방식은 removeListener(listener)처럼 같은 함수 참조를 다시 넘겨야 하는 방식보다 실수가 적다. Set을 쓰면 같은 리스너의 중복 등록도 막을 수 있다.
5. 시간 복잡도 또는 성능 특성
관찰자 수를 n이라 할 때 다음과 같다.
| 연산 | 시간 복잡도 (Set 기반 구현) |
|---|---|
| subscribe | 평균 O(1) |
| unsubscribe | 평균 O(1) |
| notify | O(n) (각 콜백 실행 비용 별도) |
notify의 실제 비용은 n에 각 관찰자 콜백의 실행 시간을 곱한 값에 가깝다. 동기 방식에서는 느린 관찰자 하나가 주체의 호출 흐름 전체를 지연시킨다.- 위 예제는 순회마다 배열 복사본을 만들므로 통지 한 번에 O(n)의 추가 메모리가 든다.
- 관찰자가 주체를 참조하는 한 가비지 컬렉션 대상이 되지 않아, 해지를 하지 않으면 메모리가 계속 유지된다.
6. 실무 사용 사례
- DOM 이벤트:
addEventListener는 옵저버 구조다. 요소가 주체, 핸들러가 관찰자다. - Node.js `EventEmitter`:
on,off,emit으로 같은 개념을 제공한다. - 상태 관리: Redux의
store.subscribe, MobX, Vue의 반응형 시스템 등이 상태 변화 통지에 이 구조를 활용한다. - RxJS:
Observable과Observer를 기반으로 비동기 이벤트 스트림을 다룬다. - MVC/MVVM: 모델 변경을 뷰에 알리는 데 쓰인다.
- `MutationObserver`, `IntersectionObserver`, `ResizeObserver`: 브라우저가 제공하는 관찰 API다.
7. 주의할 점
- 메모리 누수(memory leak): 수명이 짧은 관찰자가 수명이 긴 주체에 구독만 하고 해지하지 않으면 해제되지 않는다. 컴포넌트가 사라질 때 반드시
unsubscribe를 호출하도록 구조화한다. - 통지 순서 의존: 관찰자 호출 순서에 의존하는 로직은 깨지기 쉽다. 순서가 보장된다고 가정하지 않는 편이 안전하다.
- 순회 중 변경: 통지 중 구독이나 해지가 일어나면 순회가 꼬일 수 있다. 예제처럼 복사본을 순회한다.
- 예외 전파: 한 관찰자가 던진 예외가 나머지 관찰자의 실행을 막을 수 있다. 개별
try/catch로 격리한다. - 연쇄 갱신과 순환: 관찰자가 다시 주체의 상태를 바꾸면 무한 통지나 예측하기 어려운 갱신 순서가 생긴다.
- 디버깅 난이도: 호출 흐름이 코드상에 드러나지 않아 "누가 이 상태를 바꿨고 누가 반응하는지" 추적하기 어렵다.
- 불필요한 통지: 값이 실제로 바뀌었을 때만 통지하도록 비교한 뒤
notify하면 낭비를 줄일 수 있다.
8. 핵심 정리
- 옵저버 패턴은 주체의 상태 변화를 등록된 모든 관찰자에게 자동 통지하는 일대다 의존 관계다.
- 주체는 관찰자의 인터페이스만 알기 때문에 결합도가 낮고, 관찰자를 코드 수정 없이 추가·제거할 수 있다.
- 기본 구현은 동기 호출이며,
notify는 O(n)에 각 콜백 비용이 더해진다. - Pub/Sub와 달리 주체가 관찰자를 직접 참조한다.
- 구독 해지 누락, 순회 중 변경, 예외 전파, 순환 갱신을 항상 점검해야 한다.