
옵저버 패턴: 유튜브 구독의 원리
새 영상이 올라왔는지 매초 확인하는 게 아니라, 구독 버튼 하나로 자동 알림을 받습니다. 1:N 의존 관계를 우아하게 해결하는 디자인 패턴의 핵심.

새 영상이 올라왔는지 매초 확인하는 게 아니라, 구독 버튼 하나로 자동 알림을 받습니다. 1:N 의존 관계를 우아하게 해결하는 디자인 패턴의 핵심.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

React를 처음 배울 때 useState와 useEffect가 신기했습니다. state가 바뀌면 컴포넌트가 자동으로 다시 그려지는 게 마법 같았거든요.
"어떻게 state가 변한 걸 알고 자동으로 업데이트하지?"
선배가 "그게 옵저버 패턴이야"라고 하더라고요. 그때부터 제대로 공부해보기로 했습니다. 그런데 제 서비스에서 실시간 알림 기능을 만들 때도 같은 문제를 마주했습니다. 사용자가 매번 새로고침 해야 하는 게 아니라, 서버에서 변경사항이 있으면 자동으로 화면이 업데이트되어야 했거든요. 이게 바로 옵저버 패턴이 필요한 순간이었습니다.
"주체(Subject)가 변하면 관찰자(Observer)들에게 알린다"
이 설명을 봤을 때 너무 추상적이었습니다.
더 혼란스러웠던 건, 이미 addEventListener를 쓰고 있었다는 점입니다. 그게 옵저버 패턴인 줄 모르고 그냥 "이벤트 리스너는 원래 이렇게 쓰는 거"라고만 생각했거든요. 패턴이라는 건 뭔가 거창한 건 줄 알았는데, 매일 쓰던 코드가 그 패턴이었다니 아이러니했습니다.
처음엔 for문으로 상태를 체크하면 되지 않나 싶었습니다.
// 이게 안 되는 이유를 몰랐습니다
while (true) {
if (dataChanged) {
updateUI();
}
}
이 코드를 실행하면 브라우저가 멈춥니다. 왜냐하면 메인 스레드를 무한 루프가 점유하기 때문이죠. 이때 깨달았습니다. "능동적으로 확인하는 것"과 "수동적으로 알림받는 것"은 완전히 다른 접근이구나 하고요.
그러다가 유튜브 구독 시스템으로 설명을 듣고 완전히 이해했습니다. 이 비유가 정말 와닿았습니다.
// 1초마다 확인
setInterval(() => {
if (hasNewVideo()) {
console.log('새 영상!');
}
}, 1000);
영상 올랐는지 매번 확인합니다. 올라온 게 없어도 계속 물어봅니다. 엄청난 낭비입니다.
내 서비스 초기에 이렇게 만들었습니다. 유저 알림을 1초마다 서버에 요청하는 방식이었죠. 사용자가 100명만 넘어가도 서버가 버벅거렸습니다. 1초에 100번 요청이 들어오는데, 99번은 "알림 없음"이라는 응답이었으니까요. CPU와 네트워크 대역폭만 낭비하고 있었습니다.
// 구독 (한 번만)
youtuber.subscribe(me);
// 유튜버가 영상 올리면, 구독자들에게 자동 알림
youtuber.uploadVideo('새 영상!');
// → me.notify('새 영상!') 자동 호출
새 영상이 올라올 때만 알림이 옵니다. 확인하러 갈 필요가 없습니다.
이 비유를 듣고 "아, 그래서 옵저버(관찰자)구나!" 하고 무릎을 쳤습니다. 결국 이거였습니다. 필요할 때만 연락하는 시스템. 매번 "뭐 있어요?"라고 물어보는 게 아니라, "생기면 연락 주세요"라고 등록해두는 겁니다.
이제 제대로 정리해본다면, 옵저버 패턴은 두 개의 역할로 구성됩니다.
List<Observer>)을 관리notify() 호출update() 또는 notify() 메서드를 구현이렇게 이해했습니다: Subject는 "방송국", Observer는 "시청자"입니다. 방송국은 시청자가 몇 명인지만 알고, 각 시청자가 뭘 하는지는 신경 안 씁니다. 시청자도 방송국이 어떻게 콘텐츠를 만드는지 몰라도 됩니다. 그냥 "방송 시작했어요"라는 신호만 주고받으면 끝입니다.
class YouTuber {
constructor() {
this.subscribers = []; // 구독자 목록
}
subscribe(observer) {
this.subscribers.push(observer);
console.log('구독자 추가!');
}
unsubscribe(observer) {
this.subscribers = this.subscribers.filter(sub => sub !== observer);
console.log('구독 취소');
}
uploadVideo(title) {
console.log(`[유튜버] 새 영상 업로드: ${title}`);
// 모든 구독자에게 알림
this.notifyAll(title);
}
notifyAll(data) {
this.subscribers.forEach(observer => {
observer.update(data);
});
}
}
class Subscriber {
constructor(name) {
this.name = name;
}
update(videoTitle) {
console.log(`[${this.name}] 알림 받음: "${videoTitle}"`);
}
}
const youtuber = new YouTuber();
const alice = new Subscriber('Alice');
const bob = new Subscriber('Bob');
const charlie = new Subscriber('Charlie');
// 구독
youtuber.subscribe(alice);
youtuber.subscribe(bob);
youtuber.subscribe(charlie);
// 영상 업로드
youtuber.uploadVideo('옵저버 패턴 설명');
// 출력:
// [유튜버] 새 영상 업로드: 옵저버 패턴 설명
// [Alice] 알림 받음: "옵저버 패턴 설명"
// [Bob] 알림 받음: "옵저버 패턴 설명"
// [Charlie] 알림 받음: "옵저버 패턴 설명"
// 구독 취소
youtuber.unsubscribe(bob);
youtuber.uploadVideo('두 번째 영상');
// Bob은 알림 안 받음
이 코드를 처음 작성했을 때 신기했습니다. notifyAll 하나만 호출하면 알아서 모든 구독자에게 전파되는 게 마법 같았거든요. 받아들였던 건, "리스트를 순회하면서 각자의 메서드를 호출하는 게 다"라는 단순한 진리였습니다.
Node.js에는 이미 옵저버 패턴이 내장되어 있습니다. EventEmitter가 바로 그겁니다.
const EventEmitter = require('events');
class YouTuber extends EventEmitter {
uploadVideo(title) {
console.log(`[유튜버] 새 영상 업로드: ${title}`);
this.emit('newVideo', title); // 이벤트 발생
}
}
const youtuber = new YouTuber();
// 구독 1
youtuber.on('newVideo', (title) => {
console.log(`[Alice] 알림: ${title}`);
});
// 구독 2
youtuber.on('newVideo', (title) => {
console.log(`[Bob] 알림: ${title}`);
});
youtuber.uploadVideo('옵저버 패턴 실전');
// 출력:
// [유튜버] 새 영상 업로드: 옵저버 패턴 실제
// [Alice] 알림: 옵저버 패턴 실제
// [Bob] 알림: 옵저버 패턴 실제
EventEmitter를 쓰면 subscribe, unsubscribe, notify 같은 걸 직접 구현할 필요가 없습니다. .on()으로 구독하고, .emit()으로 발행하면 끝입니다. 결국 핵심은 동일합니다. "등록하고, 이벤트 발생하면 알림받는다."
이걸 실제로 써먹었습니다. 파일 업로드 진행률을 실시간으로 보여주는 기능을 만들 때, uploadManager를 EventEmitter로 만들었습니다.
class UploadManager extends EventEmitter {
upload(file) {
let progress = 0;
const interval = setInterval(() => {
progress += 10;
this.emit('progress', progress); // 진행률 알림
if (progress >= 100) {
clearInterval(interval);
this.emit('complete', file.name); // 완료 알림
}
}, 500);
}
}
const uploader = new UploadManager();
uploader.on('progress', (percent) => {
console.log(`업로드 중... ${percent}%`);
});
uploader.on('complete', (fileName) => {
console.log(`${fileName} 업로드 완료!`);
});
uploader.upload({ name: 'image.png' });
이렇게 하니까 UI 업데이트 로직이 깔끔하게 분리되었습니다. UploadManager는 파일 업로드만 신경 쓰고, 진행률 표시는 바깥에서 처리하는 구조가 되었죠.
처음엔 Pub/Sub(발행-구독 패턴)과 옵저버 패턴이 같은 건 줄 알았습니다. 둘 다 "구독하고 알림받는다"는 점에서 비슷하니까요. 그런데 미묘하게 다릅니다.
// Observer 패턴
youtuber.subscribe(alice); // youtuber가 alice를 직접 관리
youtuber.uploadVideo('영상');
// youtuber → alice.update() 직접 호출
// Pub/Sub 패턴 (Redis 예시)
publisher.publish('video-channel', '새 영상');
// → Message Broker (Redis) → Subscriber들에게 전달
subscriber.subscribe('video-channel', (msg) => {
console.log(msg);
});
이 차이를 이해했을 때 정리해본다면: Observer는 "직접 전화", Pub/Sub은 "방송국 중계"입니다. Observer는 전화번호를 알고 있어야 하지만, Pub/Sub은 채널만 알면 됩니다.
MSA 환경에서는 Pub/Sub이 서비스 간 통신에 자주 쓰인다고 합니다. 주문 서비스에서 "주문 완료" 이벤트를 발행하면, 결제 서비스, 배송 서비스, 알림 서비스가 각자 구독해서 처리하는 식이죠. 이런 사례를 공부하면서, 만약 Observer 패턴을 직접 썼다면 주문 서비스가 다른 서비스들을 모두 알고 있어야 했을 거라는 점이 이해됐습니다.
옵저버 패턴을 극한까지 밀어붙인 라이브러리가 있습니다. RxJS(Reactive Extensions for JavaScript)입니다.
import { fromEvent } from 'rxjs';
import { map, filter, debounceTime } from 'rxjs/operators';
// 검색창 입력 이벤트를 Observable로 변환
const searchInput = document.querySelector('#search');
const search$ = fromEvent(searchInput, 'input');
// 옵저버들을 파이프라인으로 연결
search$.pipe(
map(event => event.target.value), // 입력값 추출
filter(text => text.length > 2), // 3글자 이상만
debounceTime(300) // 300ms 대기
).subscribe(searchTerm => {
console.log('검색:', searchTerm);
// API 호출
});
처음 RxJS를 봤을 때는 어려웠습니다. "Observable이 뭐고 Operator가 뭐야?" 그런데 옵저버 패턴을 이해하고 나니까 보이기 시작했습니다. Observable은 Subject, subscribe()는 Observer 등록, pipe()는 중간 가공 단계였습니다.
이걸 순수 옵저버 패턴으로 짜면 이렇게 됩니다.
// RxJS 없이 구현하면...
let timeout;
searchInput.addEventListener('input', (event) => {
const value = event.target.value;
if (value.length <= 2) return; // filter
clearTimeout(timeout);
timeout = setTimeout(() => { // debounceTime
console.log('검색:', value);
}, 300);
});
작동은 똑같습니다. 하지만 RxJS를 쓰면 선언적으로 쓸 수 있고, 여러 스트림을 조합하기도 쉽습니다. 이때 받아들였습니다. "옵저버 패턴 = 데이터의 흐름을 다루는 방법"이라고요.
가장 익숙한 예시입니다.
const button = document.querySelector('button');
// 구독 (addEventListener)
button.addEventListener('click', () => {
console.log('버튼 클릭됨!');
});
// Subject는 button
// Observer는 콜백 함수
// 이벤트 발생(클릭) → 모든 리스너에게 notify
매일 쓰던 코드가 옵저버 패턴이었다니, 이제야 이해했습니다.
function Counter() {
const [count, setCount] = useState(0);
// count가 변하면 자동으로 re-render
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
count stateCounter 컴포넌트Counter re-renderReact 내부를 까보면 useState가 컴포넌트를 Observer로 등록합니다. state가 변하면 등록된 컴포넌트들에게 "re-render 하세요" 신호를 보내는 겁니다.
// Store (Subject)
const store = createStore(reducer);
// Component (Observer)
store.subscribe(() => {
console.log('State 변경됨:', store.getState());
});
// Dispatch (상태 변경)
store.dispatch({ type: 'INCREMENT' });
// → 모든 구독자에게 알림
제 서비스에서 Redux를 쓸 때, store.subscribe()가 정확히 옵저버 패턴이라는 걸 몰랐습니다. 그냥 "Redux는 이렇게 쓰는 거"라고만 알고 있었죠. 패턴을 이해하고 나니 왜 이렇게 설계되었는지 보였습니다.
const socket = new WebSocket('ws://chat.server.com');
// Observable: WebSocket
// Observer: 메시지 핸들러
socket.addEventListener('message', (event) => {
console.log('새 메시지:', event.data);
updateChatUI(event.data);
});
// 서버에서 메시지 오면 자동 알림
실시간 채팅을 처음 만들 때, "서버가 언제 메시지를 보낼지 모르는데 어떻게 받지?" 고민했습니다. 옵저버 패턴이 답이었습니다. 서버가 보낼 때까지 기다리지 말고, "보내면 알려주세요" 등록하면 끝이었죠.
Subject와 Observer는 서로를 구체적으로 알 필요가 없습니다. Subject는 "누가 구독했는지"만 알면 되고, Observer의 내부 구현은 몰라도 됩니다.
// Subject는 Observer가 Subscriber인지, Logger인지, Analytics인지 몰라도 됨
youtuber.subscribe(new Subscriber('User'));
youtuber.subscribe(new Logger());
youtuber.subscribe(new Analytics());
이게 왜 중요한지 실감했던 순간이 있습니다. 처음엔 "영상 업로드하면 알림 보내기"만 있었는데, 나중에 "조회수 로깅", "추천 알고리즘 업데이트" 기능을 추가해야 했습니다. 옵저버 패턴 덕분에 YouTuber 클래스는 전혀 안 건드리고 Observer만 추가하면 끝났습니다.
런타임에 Observer를 자유롭게 추가/제거할 수 있습니다.
// 런타임에 구독/구독취소
if (userWantsNotification) {
subject.subscribe(observer);
} else {
subject.unsubscribe(observer);
}
사용자가 알림 설정을 켜고 끌 수 있는 기능을 만들 때 이게 유용했습니다. 서버 재시작 없이 실시간으로 구독을 조정할 수 있었으니까요.
한 번의 이벤트로 여러 Observer에게 동시 알림 가능합니다. 1:N 관계를 우아하게 처리할 수 있죠.
Observer를 제대로 unsubscribe하지 않으면 메모리 누수가 발생합니다.
// ❌ 나쁜 예
function badComponent() {
const observer = new Observer();
subject.subscribe(observer);
// unsubscribe 안 함 → 메모리 누수!
}
// ✅ 좋은 예
function goodComponent() {
const observer = new Observer();
subject.subscribe(observer);
return () => {
subject.unsubscribe(observer); // 정리
};
}
React에서도 마찬가지입니다.
useEffect(() => {
const handleResize = () => console.log('Resized');
window.addEventListener('resize', handleResize);
// Cleanup 필수!
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
이걸 몰라서 메모리 누수를 일으킨 적이 있습니다. 컴포넌트가 unmount되어도 이벤트 리스너가 살아있어서, 시간이 지날수록 메모리 사용량이 계속 늘어났죠. Chrome DevTools의 Memory Profiler로 찾아내고 나서야 cleanup 함수의 중요성을 깨달았습니다.
Observer들이 어떤 순서로 호출될지 보장되지 않습니다.
subject.subscribe(observerA);
subject.subscribe(observerB);
subject.notify(); // A가 먼저? B가 먼저? 보장 안 됨
순서가 중요한 경우 직접 관리해야 합니다. 예를 들어 "로깅은 항상 제일 먼저"라는 요구사항이 있으면, 우선순위 큐를 직접 만들어야 합니다.
"이 함수 누가 호출했지?"를 추적하기 어렵습니다. 호출 스택을 보면 notify() → observer.update()는 보이지만, 왜 notify가 호출되었는지는 안 보입니다.
제가 겪은 버그 중에 "화면이 왜 두 번 깜빡이지?"가 있었습니다. 알고 보니 두 개의 Observer가 같은 DOM을 업데이트하고 있었는데, 코드 어디서 구독했는지 찾는 데 한참 걸렸습니다.
// ❌ 무한 루프!
useEffect(() => {
setCount(count + 1); // count 변경
}, [count]); // count 변하면 다시 실행 → 무한 루프
옵저버 패턴을 이해하면 왜 이게 무한 루프인지 바로 알 수 있습니다.
Observer가 Subject를 다시 변경하면 순환 참조가 발생합니다. 이걸 깨닫고 나서는 useEffect 의존성 배열을 항상 조심스럽게 다루게 되었습니다.
해결: 의존성 배열을 비워두거나, 조건을 추가합니다.
// ✅ 한 번만 실행
useEffect(() => {
setCount(1);
}, []); // 빈 배열 → mount 시 한 번만
// WebSocket 연결을 계속 유지
useEffect(() => {
const ws = new WebSocket('ws://server.com');
ws.addEventListener('message', handleMessage);
// ❌ cleanup 없음 → 메모리 누수
});
컴포넌트가 unmount되어도 WebSocket은 살아있어서 메모리 낭비가 일어났습니다. 더 심각한 건, 서버에서 메시지가 오면 죽은 컴포넌트의 상태를 업데이트하려고 시도하면서 에러가 났습니다.
Warning: Can't perform a React state update on an unmounted component.
이 에러 메시지를 처음 봤을 때 뭔 소린지 몰랐습니다. "unmounted인데 왜 업데이트를 시도하지?" 옵저버 패턴을 이해하고 나서야 "아, WebSocket이 아직 Observer로 등록되어 있었구나" 깨달았습니다.
// ✅ 올바른 방법
useEffect(() => {
const ws = new WebSocket('ws://server.com');
ws.addEventListener('message', handleMessage);
return () => {
ws.close(); // 정리
};
}, []);
cleanup 함수는 선택이 아니라 필수입니다. 이걸 습관화하지 않으면 언젠가 메모리 누수로 고생합니다.
검색창에 입력할 때마다 API를 호출하도록 만들었습니다.
searchInput.addEventListener('input', (e) => {
fetch(`/api/search?q=${e.target.value}`);
});
문제는 한 글자 칠 때마다 요청이 나간다는 겁니다. "옵저버 패턴"을 치면 7번 요청이 날아갑니다. 서버가 터질 뻔했죠.
해결: Debounce를 걸어서 입력이 멈춘 후에만 요청하도록 했습니다.
let timeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
fetch(`/api/search?q=${e.target.value}`);
}, 300); // 300ms 대기
});
옵저버 패턴 자체는 "이벤트 발생 즉시 알림"이 원칙입니다. 하지만 실제로는 너무 자주 알림이 오는 것도 문제입니다. Throttle, Debounce 같은 기법을 추가로 알아야 합니다.
디자인 패턴 책을 보면 장점만 강조하는데, 실제로는 "언제 안 쓸지"도 중요합니다.
// 옵저버 패턴 (과함)
class Button {
constructor() {
this.listeners = [];
}
subscribe(fn) { this.listeners.push(fn); }
click() { this.listeners.forEach(fn => fn()); }
}
// 그냥 콜백 (충분함)
function Button(onClick) {
this.onClick = onClick;
}
button.onClick();
Observer 1개만 있으면 패턴까지 동원할 필요 없습니다. 그냥 함수 하나 받아서 호출하면 끝입니다.
옵저버 패턴은 비동기적입니다. 순서가 중요하거나, 리턴값을 받아야 하는 경우엔 적합하지 않습니다.
// ❌ Observer로는 어려움
const result = subject.notify(); // result를 어떻게 받지?
// ✅ 직접 호출
const result = processData(data);
제가 결제 로직을 만들 때, 처음엔 "결제 완료" 이벤트를 발행하면 여러 Observer가 처리하도록 했습니다. 그런데 결제 검증 → 재고 차감 → 영수증 발행 순서가 보장되어야 했고, 중간에 실패하면 롤백해야 했습니다. 옵저버 패턴으로는 이런 트랜잭션 관리가 어려웠습니다. 결국 직접 함수 호출로 바꿨죠.
옵저버 패턴은 호출 흐름을 추적하기 어렵습니다. 빠르게 프로토타입을 만들고 디버깅해야 하는 상황에서는 오히려 방해가 됩니다.
처음엔 모든 걸 이벤트로 만들고 싶은 유혹이 있습니다. "이게 더 깔끔해 보이니까!" 하지만 과한 추상화는 독입니다. 3개월 뒤에 내가 짠 코드를 봤을 때 "이 이벤트 어디서 발생하지?" 찾느라 시간 낭비하게 됩니다.
옵저버 패턴을 이해하면서 배운 핵심을 정리해본다면:
React의 useState, 이벤트 리스너, WebSocket... 모두 옵저버 패턴입니다. 이제는 "자동으로 업데이트된다"가 마법이 아니라 명확한 패턴으로 보입니다.
결국 이거였습니다. "내가 언제 연락할지 모르니까, 연락처 남겨두면 연락할게." 이 한 문장으로 옵저버 패턴을 이해했다고 받아들였습니다.