옵서버 패턴, 상태 변화를 구독으로 통지하는 설계
옵서버 패턴은 주체의 상태가 바뀌면 등록된 옵서버들에게 자동으로 알리는 GoF 행동 패턴이다. 폴링을 구독으로 뒤집는 원리와 푸시·풀 모델의 차이, 자바 9에서 폐기된 Observable부터 Node.js EventEmitter까지 실제 구현을 정리했다.
목차
옵서버 패턴은 한 객체의 상태가 바뀌면 그 객체를 구독하고 있는 여러 객체에게 자동으로 알림을 보내는 설계 방식이다. 상태를 가진 쪽을 주체(subject), 알림을 받는 쪽을 옵서버(observer)라 부르고, 주체 하나에 옵서버 여럿이 붙는 일대다 관계를 만든다. 데이터가 필요한 쪽이 계속 되묻는(polling) 대신 바뀐 쪽이 먼저 알려 주는 구조로 뒤집는 것이 핵심이다. 이 글은 특정 언어나 프레임워크가 아니라 GoF(Gang of Four)가 정리한 옵서버 패턴의 개념과 구조, 그리고 자바·Node.js·RxJS 같은 실제 런타임이 이를 어떻게 구현했는지를 개발자 눈높이에서 다룬다.
옵서버 패턴이란 무엇인가
옵서버 패턴은 GoF(Gang of Four)가 1994년 저서 『디자인 패턴』에서 정리한 행동 패턴(객체 사이의 책임 분배와 상호작용을 다루는 패턴)의 하나다. 핵심은 일대다 의존 관계다. 주체 하나의 상태가 바뀌면 그 주체에 등록된 옵서버 전부가 통지를 받고 각자 반응한다.
용어를 풀면 이렇다.
- 주체(subject): 상태를 들고 있고, 그 상태가 바뀌면 등록된 옵서버 목록에 알리는 쪽.
- 옵서버(observer): 주체의 변화에 관심 있는 쪽. 주체에 자신을 등록(구독)해 두면 변화가 생길 때 호출된다.
발행-구독(publish-subscribe) 개념의 한 형태이기도 하다. 다만 옵서버 패턴에서는 주체와 옵서버가 서로를 직접 참조하는 반면, 넓은 의미의 pub/sub는 중간에 메시지 브로커를 두어 둘을 완전히 떼어 놓는 경우가 많다.
폴링을 통지로 뒤집는 발상
옵서버 패턴이 없다면 가장 단순한 대안은 폴링(polling)이다. 값이 필요한 쪽이 '바뀌었어?'를 주기적으로 되묻는 방식이다. 1초에 한 번 확인하면 대부분의 확인은 헛일이 되고, 간격을 늘리면 변화를 늦게 알아챈다. 옵서버 패턴은 이 관계를 뒤집어, 변화가 생긴 순간 주체가 옵서버를 직접 호출한다. 반복 확인 비용이 사라지고 반응 지연도 줄어든다.
결합도(coupling, 두 코드가 서로에게 얼마나 의존하는지)를 낮추는 것도 큰 이유다. 주체는 옵서버가 구체적으로 무슨 일을 하는지 몰라도 된다. '변화가 생기면 등록된 대상들의 통지 메서드를 부른다'는 약속만 지키면 옵서버는 얼마든지 더하고 빼고 바꿀 수 있다. 예컨대 설정 값 하나가 바뀔 때 화면 갱신, 로그 기록, 캐시 무효화를 함께 처리하려면 세 동작을 각각 옵서버로 등록해 두기만 하면 된다. 주체 코드는 그대로다.
주체와 옵서버의 연결 구조
주체가 갖춰야 하는 동작은 세 가지다. 옵서버 등록(subscribe), 등록 해제(unsubscribe), 그리고 통지(notify)다. 주체는 옵서버들의 참조를 배열이나 연결 리스트 같은 컬렉션에 담아 두고, notify가 호출되면 그 목록을 순회하며 각 옵서버의 정해진 메서드(흔히 update)를 부른다. 자바스크립트로 뼈대만 옮기면 다음과 같다.
class Subject {
constructor() { this.observers = []; }
subscribe(observer) { this.observers.push(observer); }
unsubscribe(observer) {
this.observers = this.observers.filter(o => o !== observer);
}
notify(data) {
this.observers.forEach(o => o.update(data));
}
}옵서버 쪽은 update(data) 메서드 하나만 갖추면 된다. 이 구조 덕분에 주체 코드에는 특정 옵서버 클래스 이름이 전혀 등장하지 않는다. 새 옵서버가 생겨도 주체는 손대지 않는다.

푸시 모델과 풀 모델은 어떻게 다른가
통지 방식은 크게 두 갈래다. 주체가 바뀐 데이터를 통지에 실어 보내면 푸시 모델, 변화가 생겼다는 신호만 보내고 세부 데이터는 옵서버가 되물으면 풀 모델이다.
푸시 모델(push)에서는 주체가 update(data)처럼 바뀐 값을 인자로 직접 넘긴다. 옵서버는 받은 값을 바로 쓰면 되니 왕복이 없다. 대신 옵서버마다 필요한 데이터가 달라도 주체가 한 벌로 밀어 넣으므로, 쓰지 않는 데이터까지 실려 가기 쉽다. 풀 모델(pull)에서는 주체가 '뭔가 바뀌었다'는 최소한의 신호만 보내고, 옵서버가 필요할 때 주체에게 되물어 값을 가져온다. 옵서버가 자기에게 필요한 것만 골라 읽을 수 있지만, 통지 뒤 조회하는 왕복이 한 번 더 생긴다.
| 구분 | 푸시 모델 | 풀 모델 |
|---|---|---|
| 통지 내용 | 바뀐 데이터를 인자로 전달 | 변화 신호만 전달 |
| 추가 조회 | 없음 | 옵서버가 주체에 되물음 |
| 유리한 경우 | 옵서버 대부분이 같은 데이터 사용 | 옵서버마다 필요 데이터가 다름 |
표준 라이브러리 속 옵서버 패턴
옵서버 패턴은 이름을 내세우지 않아도 표준 라이브러리와 프레임워크 곳곳에 이미 들어가 있다.
자바는 초창기부터 java.util.Observer 인터페이스와 java.util.Observable 클래스를 제공했다. 다만 오라클 자바 문서는 이 둘을 자바 9부터 deprecated(사용 자제)로 지정했다. 통지 순서가 정해져 있지 않고, Observable이 인터페이스가 아니라 클래스여서 상속하면 스레드 안전성이 깨질 수 있다는 점을 이유로 들었다. 오라클은 대안으로 java.beans 패키지의 PropertyChangeListener를 권한다.
Node.js에서는 EventEmitter가 같은 역할을 한다. Node.js 공식 문서에 따르면 on(event, listener)으로 리스너를 등록하고 emit(event)으로 통지하며, 한 이벤트에 리스너가 기본 상한인 10개를 넘으면 메모리 누수 가능성을 알리는 MaxListenersExceededWarning 경고를 낸다.
const EventEmitter = require('events');
const bus = new EventEmitter();
bus.on('login', user => console.log(user, '로그인'));
bus.emit('login', 'alice');ReactiveX 계열도 뿌리는 같다. ReactiveX 공식 문서는 옵서버가 Observable을 subscribe하면 Observable이 방출하는 값에 옵서버가 반응하는 구조로 설명하고, subscribe가 돌려주는 객체의 unsubscribe로 언제든 구독을 끊을 수 있다고 밝힌다. 브라우저의 addEventListener, Vue·MobX 같은 반응형 상태 관리도 근본 뼈대는 이와 같다.
구독 해제와 통지 순서에서 흔한 함정
가장 흔한 실수는 구독 해제를 잊는 것이다. 옵서버가 더 필요 없어졌는데도 unsubscribe를 부르지 않으면, 주체가 옵서버 참조를 계속 쥐고 있어 가비지 컬렉터가 그 옵서버를 회수하지 못한다. 이벤트 리스너에서 흔히 보는 메모리 누수가 바로 이것이다. Node.js가 리스너 10개 초과에서 경고를 띄우는 것도 이 실수를 일찍 잡으려는 장치다. 화면에서 사라진 컴포넌트나 닫힌 연결에 붙어 있던 옵서버는 반드시 등록을 해제해야 한다.
통지 순서에 로직을 기대지 않는 것도 중요하다. 앞서 오라클이 java.util.Observable의 통지 순서가 정해져 있지 않다는 점을 폐기 사유로 든 것처럼, 옵서버들이 서로의 실행 순서에 의존하기 시작하면 눈에 보이지 않는 결합이 생긴다. 특정 옵서버가 먼저 돌아야 한다면 그 의존을 코드로 드러내는 편이 낫다.
재진입도 조심해야 한다. 옵서버의 update 안에서 다시 주체의 상태를 바꾸면 통지가 통지를 부르는 연쇄가 생겨, 무한 루프나 예측하지 못한 재진입에 빠질 수 있다. 통지 도중 옵서버 목록 자체를 수정하는 경우도 순회 오류의 원인이 된다. 이런 상황이 잦다면 통지 전에 목록을 복사해 두고 순회하는 방어가 도움이 된다.





