10. Observer — 주문 완료됐다고 다섯 군데에 직접 전화할 수 없다

작성일:2026.05.21|수정일:2026.08.04|조회수:0

10. Observer — 주문 완료됐다고 다섯 군데에 직접 전화할 수 없다

에이든 피자의 주방 자동화가 꽤 진척되었다. 템플릿 메서드 덕분에 요리사들은 레시피만 있으면 척척 피자를 만들어낸다. 하지만 피자가 오븐에서 나오는 순간, 다른 종류의 일이 몰려왔다. 피자가 완성되었다는 사실을 필요한 곳에 알려야 했기 때문이다.

처음에는 간단했다. 요리사가 주방 화면을 업데이트하는 함수를 하나 호출하면 끝이었다. 하지만 시범 운영이 커지면서 일이 늘어났다. 손님에게 카카오톡 알림도 보내야 하고, 배달 기사님도 불러야 하고, 오늘의 매출을 분석하는 시스템에도 기록을 남겨야 했다. 주방 클래스의 completePizza() 메서드는 순식간에 에이든 피자의 정보 통신 센터가 되어버렸다.

TS
class Kitchen {  completePizza(order: Order) {    // 피자 완성 처리 로직...    console.log(`${order.id} 피자가 완성되었습니다.`);    this.display.updateStatus(order);          // 1. 진열 화면 업데이트    this.notifier.sendToCustomer(order);       // 2. 손님에게 알림 전송    this.delivery.assignDriver(order);         // 3. 배달 기사 배정    this.analytics.recordCompletion(order);    // 4. 매출 분석 시스템 기록    // 5. 무언가 또 추가된다면...?  }}

이 코드를 보고 있으면 주방장이 요리는 안 하고 하루 종일 전화기만 붙들고 있는 장면이 떠오른다. 주방은 피자를 만드는 곳이지, 알림 서비스가 아니다. 그런데 지금 주방은 알림을 받아야 하는 모든 대상을 직접 알고(import) 있어야 한다. 손님 알림 서비스에 장애가 생기거나 배달 배정 시스템의 인터페이스가 바뀌면, 아무 잘못 없는 주방 코드도 함께 망가질 위기에 처했다.

무엇이 불편한가

가장 큰 문제는 결합도다. Kitchen 클래스가 너무 많은 것을 알고 있다. 진열 화면, 알림 서비스, 배달 시스템, 분석 엔진까지. 이 중 하나만 바뀌어도 주방 코드를 열어야 한다. 이건 마치 피자 조리법이 바뀌지 않았는데도 문자 메시지 발송 업체를 바꿨다고 요리 도구를 교체해야 하는 상황과 같다.

또한 알림 대상이 늘어날 때마다 completePizza() 함수는 점점 더 길어질 것이다. 나중에는 설문조사 시스템, 단골 손님 포인트 적립 시스템 등이 추가될지도 모른다. 그때마다 우리는 "아, 또 주방 코드를 건드려야 하나?"라는 자괴감에 빠지게 된다.

우리가 원하는 건 주방이 "피자 다 됐어!"라고 허공에 외치는 것이다. 그러면 그 소식을 듣고 싶어 하는 사람들이 알아서 자기 할 일을 하는 구조 말이다. 주방은 누가 자기 말을 듣고 있는지 알 필요가 없고, 듣는 사람들은 주방이 언제 외칠지만 기다리면 된다.

Observer 패턴

Observer 패턴은 객체의 상태 변화를 관찰하는 관찰자(Observer)들에게 변화가 생겼음을 자동으로 통지하는 패턴이다. GoF의 원래 의도는 다음과 같다.

객체 사이에 일대다 의존 관계를 정의하여, 어떤 객체의 상태가 변할 때 그 객체에 의존성을 가진 다른 객체들이 그 변화를 통지받고 자동으로 갱신될 수 있게 만든다.

에이든 피자에 적용해보면, 주방은 주체(Subject)가 되고 알림을 받고 싶어 하는 시스템들은 관찰자(Observer)가 된다. 주방은 관찰자들의 명단을 리스트로 들고 있다가, 피자가 완성되면 리스트에 있는 모두에게 "이 피자 다 됐어요"라고 신호를 보낸다.

이 패턴의 등장인물은 크게 넷이다. SubjectObserver 인터페이스를 통해 관찰자들을 등록·해제하고, 변화가 생기면 통지한다. Observer는 변화를 통지받을 때 호출될 인터페이스다. 즉 Subject는 구체적인 알림 서비스가 아니라 Observer 추상화만 알고, Observer는 필요할 때 Subject에 자신을 등록하거나 해제한다. ConcreteSubject인 우리 주방은 실제 상태 변화를 일으키고, ConcreteObserver인 알림 서비스들은 통지를 받으면 각자의 로직을 실행한다.

구독하고 통지하기

리팩토링을 시작해보자. 먼저 모든 관찰자가 따라야 하는 인터페이스를 만든다.

TS
interface OrderObserver {  onOrderCompleted(order: Order): void;}

이제 주방 클래스를 주체로 만든다. 주방은 이제 특정 시스템을 직접 부르지 않고, 관찰자 리스트를 관리한다.

TS
class Kitchen {  private observers: OrderObserver[] = [];  // 구독 신청  public subscribe(observer: OrderObserver): void {    if (!this.observers.includes(observer)) {      this.observers.push(observer);    }  }  // 구독 해지  public unsubscribe(observer: OrderObserver): void {    this.observers = this.observers.filter(o => o !== observer);  }  // 모두에게 알림  private notify(order: Order): void {    this.observers.forEach(observer => observer.onOrderCompleted(order));  }  public completePizza(order: Order): void {    console.log(`주방: ${order.pizzaName} 조리가 완료되었습니다.`);    // 완성됐다는 사실만 알린다. 누가 무엇을 할지는 관심 없다.    this.notify(order);  }}

이제 주방 코드는 조금 조용해진다. 주방은 OrderObserver라는 추상적인 존재들만 알면 된다. 구체적으로 그게 화면인지, 카카오톡인지, 배달 기사님인지는 주방의 관심사가 아니다.

이제 관찰자들을 구현한다.

TS
class KitchenDisplay implements OrderObserver {  onOrderCompleted(order: Order) {    console.log(`[진열 화면] ${order.id}번 주문: 조리 완료!`);  }}class CustomerNotifier implements OrderObserver {  onOrderCompleted(order: Order) {    console.log(`[알림톡] ${order.customerName}님, 피자가 맛있게 구워졌습니다!`);  }}class DeliveryDispatcher implements OrderObserver {  onOrderCompleted(order: Order) {    if (order.type === 'delivery') {      console.log(`[배달 시스템] 기사님을 호출합니다.`);    }  }}

사용법은 간단하다. 주방 객체에 필요한 관찰자들을 등록해주기만 하면 된다.

TS
const kitchen = new Kitchen();kitchen.subscribe(new KitchenDisplay());kitchen.subscribe(new CustomerNotifier());kitchen.subscribe(new DeliveryDispatcher());// 피자가 완성되면 등록된 모든 곳에 자동으로 알림이 간다kitchen.completePizza(someOrder);

TypeScript 포인트

TypeScript와 Node.js 환경에서 Observer 패턴을 만날 때 가장 먼저 떠오르는 것은 EventEmitter다. 사실 Node.js의 많은 부분이 이 Observer 패턴(또는 Pub-Sub 패턴)으로 이루어져 있다.

TS
import { EventEmitter } from 'events';class Kitchen extends EventEmitter {  completePizza(order: Order) {    this.emit('orderCompleted', order);  }}kitchen.on('orderCompleted', (order) => { /* 처리 */ });

EventEmitter를 쓰면 코드가 훨씬 짧아지고 강력해지지만, 주의할 점이 있다. 타입 안정성이다. emiton에 들어가는 이벤트 이름이 단순 문자열이면 오타가 나기 쉽고, 전달되는 인자의 타입도 보장받기 어렵다. 이럴 때는 인터페이스를 사용하여 이벤트 타입을 정의해주는 것이 좋다.

또한 메모리 누수도 조심해야 한다. 관찰자를 등록만 하고 해제(offunsubscribe)하지 않으면, 객체가 더 이상 필요하지 않은 상태에서도 주체가 관찰자를 참조하고 있어 가비지 컬렉션이 일어나지 않는다. 시범 운영 중에 서버가 가끔 뻗는다면, 누군가 구독만 하고 해지를 안 해서 주방에 유령 관찰자들이 가득 차 있는 것은 아닌지 의심해봐야 한다.

트레이드오프

Observer 패턴의 장점은 주체와 관찰자 사이의 결합을 느슨하게 만든다는 점이다. 주방 코드를 수정하지 않고도 새로운 알림 기능을 붙일 수 있고, 그런 면에서 개방-폐쇄 원칙(OCP)과도 잘 맞는다.

하지만 통지 순서를 제어하기 어렵다는 점은 단점이 될 수 있다. 등록된 순서대로 호출된다고 가정하고 로직을 짜면 나중에 예기치 못한 버그를 만날 수 있다. 또한 관찰자가 통지를 받았을 때 주체의 상태를 다시 변경하고, 그 변화가 또 다른 통지를 일으키는 "연쇄 반응"이 생기면 디버깅은 미궁 속으로 빠진다.

무엇보다도 시스템이 복잡해지면 "어디서 이 알림이 날아왔지?"를 추적하기가 힘들어진다. 코드를 읽을 때 흐름이 명확하게 보이지 않고 여기저기 튀어 다니기 때문이다. 하지만 피자 하나 완성됐다고 전 직원이 주방으로 달려와서 "다 됐나요?"라고 물어보는 비효율보다는, 조용히 알림을 기다리는 지금의 방식이 내가 보기에는 훨씬 덜 시끄럽다.

전체 코드

TS
/** * 전달할 주문 정보 데이터 */interface Order {  id: string;  customerName: string;  pizzaName: string;  type: 'dine-in' | 'delivery';}/** * Observer: 관찰자 인터페이스 */interface OrderObserver {  onOrderCompleted(order: Order): void;}/** * Subject: 주방 (관찰 대상) */class Kitchen {  private observers: OrderObserver[] = [];  /**   * 관찰자를 등록한다.   */  public subscribe(observer: OrderObserver): void {    const isExist = this.observers.includes(observer);    if (isExist) {      return console.log('이미 등록된 관찰자입니다.');    }    this.observers.push(observer);  }  /**   * 관찰자를 제거한다.   */  public unsubscribe(observer: OrderObserver): void {    const observerIndex = this.observers.indexOf(observer);    if (observerIndex === -1) {      return console.log('존재하지 않는 관찰자입니다.');    }    this.observers.splice(observerIndex, 1);  }  /**   * 모든 관찰자에게 통지한다.   */  private notify(order: Order): void {    for (const observer of this.observers) {      observer.onOrderCompleted(order);    }  }  /**   * 피자 조리를 완료하고 알림을 보낸다.   */  public completePizza(order: Order): void {    console.log(`\n[주방] ${order.id}번 주문(${order.pizzaName}) 조리가 끝났습니다!`);    this.notify(order);  }}/** * ConcreteObserver: 매장 내 진열 화면 */class KitchenDisplay implements OrderObserver {  onOrderCompleted(order: Order) {    console.log(`[SCREEN] ${order.id}번 고객님, 피자 받아가세요!`);  }}/** * ConcreteObserver: 손님용 SMS 서비스 */class SMSNotifier implements OrderObserver {  onOrderCompleted(order: Order) {    console.log(`[SMS] ${order.customerName}님, 주문하신 ${order.pizzaName}가 준비되었습니다.`);  }}/** * ConcreteObserver: 배달 배정 시스템 */class DeliveryService implements OrderObserver {  onOrderCompleted(order: Order) {    if (order.type === 'delivery') {      console.log(`[DELIVERY] 배달 주문 감지: 배달 기사님을 배정합니다.`);    }  }}/** * ConcreteObserver: 매출 통계 기록 */class AnalyticsRecorder implements OrderObserver {  onOrderCompleted(order: Order) {    console.log(`[STAT] 데이터 기록: ${order.pizzaName} 판매 완료.`);  }}// 에이든 피자 운영 테스트const aydenKitchen = new Kitchen();const kitchenDisplay = new KitchenDisplay();const sms = new SMSNotifier();const delivery = new DeliveryService();const stats = new AnalyticsRecorder();aydenKitchen.subscribe(kitchenDisplay);aydenKitchen.subscribe(sms);aydenKitchen.subscribe(delivery);aydenKitchen.subscribe(stats);const myOrder: Order = {  id: "P-001",  customerName: "에이든",  pizzaName: "페퍼로니 피자",  type: "delivery"};aydenKitchen.completePizza(myOrder);// 가령 통계 기록이 더 이상 필요 없다면 언제든 구독 해제 가능aydenKitchen.unsubscribe(stats);console.log("\n--- 통계 시스템 구독 해제 후 ---");aydenKitchen.completePizza({ ...myOrder, id: "P-002" });

알림 시스템을 Observer 패턴으로 정리하고 나니 주방은 다시 요리에 집중할 수 있게 되었다. "피자 다 됐어!"라는 주방장의 외침은 이제 필요한 곳으로 흘러간다. 주방이 그 경로를 하나하나 외울 필요는 없다.

그런데 알림은 피자가 다 된 다음에나 하는 걱정이다. 사실 주문이 주방으로 들어오기까지는 꽤 험난한 과정이 기다리고 있다. 재료가 남아 있는지 확인해야 하고, 결제가 제대로 됐는지 검증해야 하고, 주방에 빈 자리가 있는지 체크해야 하고, 배달 주소는 정확한지 따져봐야 한다. 이 관문들을 하나라도 통과하지 못하면 주문은 주방 근처에도 못 가고 튕겨 나가야 한다. 이 수많은 관문을 어떻게 하면 순서대로, 그리고 유연하게 관리할 수 있을까?

댓글

댓글을 불러오는 중...