14. Mediator — 모두가 POS를 통해서만 대화한다
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 매장이 북적이기 시작하면서, 직원들 사이의 소통이 심상치 않게 꼬이기 시작했다. 처음에는 캐셔 한 명과 요리사 한 명뿐이라 서로 눈빛만 봐도 통했다. 하지만 배달 기사님이 합류하고, 홀 진열 화면을 담당하는 시스템까지 들어오면서 상황이 달라졌다. 이제 한 명의 행동이 다른 모든 이들에게 영향을 주는 복잡한 거미줄이 만들어진 것이다.
캐셔는 주문을 받으면 주방에 알리고 화면을 갱신해야 한다. 주방은 피자를 다 만들면 기사님을 부르고 캐셔에게 보고해야 한다. 배달 기사님은 피자를 픽업하면 다시 캐셔에게 알리고 화면 상태를 '배달 중'으로 바꿔야 한다. 우리는 이 모든 과정을 각 클래스의 생성자에 서로를 주입하는 방식으로 해결하려 했다. 하지만 결과는 좋지 않았다.
class Cashier { constructor( private kitchen: Kitchen, private display: OrderDisplay, private driver: DeliveryDriver ) {} receiveOrder(order: Order) { this.kitchen.startCooking(order); this.display.show(order); }}class Kitchen { constructor( private cashier: Cashier, private driver: DeliveryDriver ) {} completeOrder(order: Order) { this.driver.pickup(order); this.cashier.notifyOrderReady(order); }}직원이 늘어날수록 생성자는 점점 길어졌고, 코드는 서로가 서로를 참조하는 거대한 순환 의존성의 늪에 빠졌다. 캐셔 클래스 하나를 고치려 해도 주방, 화면, 기사 클래스를 모두 살펴봐야 했다. 직원들이 서로의 연락처를 일일이 저장하고 직접 전화를 돌리는 바람에, 매장은 업무보다 통화 소리로 더 시끄러워진 꼴이다.
무엇이 불편한가
지금의 가장 큰 불편함은 객체 간의 관계가 'N:N'으로 얽혀 있다는 점이다. 모든 구성원이 서로의 존재를 너무 자세히 알고 있다. 이를 '강한 결합(Tight Coupling)'이라고 부른다. 이런 구조에서는 새로운 구성원, 예를 들어 '재고 관리 시스템'이 추가되기라도 하면 기존 직원 클래스들을 열어서 재고 관리 시스템을 주입해줘야 한다.
또한, 업무의 흐름(Workflow)이 여러 클래스에 파편화되어 흩어져 있다. "주문이 들어오면 주방이 일을 시작하고 화면을 바꾼다"는 비즈니스 규칙이 캐셔 클래스 안에 숨어 있고, "조리가 끝나면 배달을 요청한다"는 규칙은 주방 클래스 안에 숨어 있다. 전체적인 매장 운영 흐름을 한눈에 파악하기가 불가능에 가깝다.
결국 이 모든 소통을 중앙에서 관리해 줄 '관제탑'이 필요했다. 직원들은 서로의 연락처를 지우고 오직 관제탑에만 상황을 보고한다. "주문 들어왔어요", "피자 다 됐어요"라고 관제탑에 외치기만 하면, 누구에게 어떤 지시를 내릴지는 관제탑이 결정하는 구조. 그것이 우리가 바라는 평화로운 매장의 모습이었다.
Mediator 패턴
Mediator 패턴은 객체들이 서로 직접 통신하는 대신, 중재자(Mediator) 객체를 통해 상호작용하도록 캡슐화하는 패턴이다. GoF의 원래 의도는 다음과 같다.
객체들의 상호작용 방식을 캡슐화하는 객체를 정의하여, 객체들이 서로 직접 참조하지 않게 하고 결합도를 낮춘다.
에이든 피자에서는 매장의 중심인 'POS(Point of Sale)' 시스템이 이 중재자 역할을 맡기에 딱 좋다. 캐셔, 요리사, 기사, 화면 장치는 서로를 모른 채 오직 POS만을 바라본다. 무언가 일이 생기면 POS에게 알리고, POS는 미리 정의된 규칙에 따라 다른 구성원들에게 명령을 내린다.
항공 관제탑을 떠올려보자. 수십 대의 비행기가 하늘에서 서로 직접 무전하며 "제가 먼저 내려갈게요", "아뇨 제가 먼저요"라고 다투지 않는다. 모든 비행기는 관제탑과만 대화하고, 관제탑이 고도와 순서를 조율한다. Mediator 패턴도 이 관제탑에 가깝다.
관제탑 POS 세우기
먼저 중재자의 인터페이스를 정의한다. 중재자는 '누가', '어떤 사건'을 일으켰는지 들을 수 있어야 한다.
type ShopEvent = | { type: 'ORDER_RECEIVED'; order: Order } | { type: 'COOKING_COMPLETED'; order: Order } | { type: 'PICKUP_COMPLETED'; order: Order };interface ShopMediator { notify(sender: object, event: ShopEvent): void;}이제 각 구성원(Colleague)들은 중재자만을 알고 지낸다. 서로의 존재는 잊어도 좋다.
abstract class Colleague { constructor(protected mediator: ShopMediator) {}}class Cashier extends Colleague { takeOrder(order: Order) { console.log(`[캐셔] ${order.id}번 주문을 받았습니다.`); this.mediator.notify(this, { type: 'ORDER_RECEIVED', order }); }}class Kitchen extends Colleague { cook(order: Order) { console.log(`[주방] ${order.id}번 피자 조리를 시작합니다.`); // 조리 완료 후... this.mediator.notify(this, { type: 'COOKING_COMPLETED', order }); }}마지막으로 실제 중재자인 POS 클래스를 구현한다. 매장의 모든 비즈니스 흐름이 이곳에 모인다.
class POS implements ShopMediator { private cashier!: Cashier; private kitchen!: Kitchen; private driver!: DeliveryDriver; private display!: OrderDisplay; // 모든 구성원을 등록한다 setColleagues(cashier: Cashier, kitchen: Kitchen, driver: DeliveryDriver, display: OrderDisplay) { this.cashier = cashier; this.kitchen = kitchen; this.driver = driver; this.display = display; } notify(sender: object, event: ShopEvent) { switch (event.type) { case 'ORDER_RECEIVED': this.kitchen.cook(event.order); this.display.update(event.order, '조리 중'); break; case 'COOKING_COMPLETED': this.driver.dispatch(event.order); this.display.update(event.order, '배달 준비 완료'); break; case 'PICKUP_COMPLETED': this.display.update(event.order, '배달 중'); break; } }}이제 Kitchen 클래스는 DeliveryDriver나 OrderDisplay가 어떻게 생겼는지 몰라도 된다. 조리가 끝났다고 POS에게 보고하면, POS가 기사님을 부르고 화면을 바꾼다. 객체 간의 관계가 N:N에서 N:1로 단순해진 셈이다.
Observer vs Mediator
10편에서 배운 Observer 패턴과 Mediator 패턴은 둘 다 '객체 간의 상호작용'을 다룬다는 점에서 혼동하기 쉽다. 하지만 의도는 꽤 다르다.
| 비교 항목 | Observer 패턴 | Mediator 패턴 |
|---|---|---|
| 통신 방식 | 1:N (일방향 브로드캐스트) | N:N (중앙 집중식 조율) |
| 목적 | 상태 변경을 여러 관찰자에게 알림 | 복잡한 협력을 하나의 객체로 캡슐화 |
| 지식 수준 | 관찰 대상은 관찰자를 모름 | 구성원은 중재자만 알고 서로 모름 |
| 비유 | 잡지 구독 서비스 | 항공 관제탑 |
Observer는 어떤 일이 일어났을 때 "관심 있는 사람들 다 모여봐!"라고 외치는 느낌이라면, Mediator는 "A가 보고했으니 B는 준비하고 C는 대기해"라고 진두지휘하는 느낌이다. 에이든 피자에서는 단순한 알림 전파는 Observer로, 복잡한 업무 흐름의 조율은 Mediator로 해결하고 있다.
TypeScript 포인트
Mediator 패턴을 구현하다 보면 notify 메서드 안에서 any나 위험한 타입 캐스팅을 남발하기 쉽다. TypeScript에서는 '식별 가능한 유니온(Discriminated Unions)'을 쓰면 이 문제를 꽤 깔끔하게 줄일 수 있다.
앞서 정의한 ShopEvent 타입을 다시 보자. type이라는 공통 필드를 기준으로 각 이벤트마다 필요한 데이터를 다르게 정의했다. 이렇게 하면 switch 문 안에서 event.type을 체크하는 순간, 해당 블록 안의 event 객체는 자동으로 정확한 타입으로 좁혀진다(Narrowing). data?: unknown 같은 모호한 필드 대신 명확한 속성을 사용할 수 있어, 오타나 잘못된 데이터 접근을 컴파일 단계에서 잡아낼 수 있다.
또한, POS는 매장에서 하나만 존재해야 하므로 5편에서 배운 Singleton 패턴과 결합하거나, 의존성 주입(DI) 컨테이너를 통해 싱글톤으로 관리하는 것이 일반적이다.
트레이드오프
Mediator 패턴의 가장 큰 장점은 구성원 간의 결합도를 낮춰서 재사용성과 유지보수성을 높인다는 것이다. 구성원을 추가하거나 비즈니스 로직을 변경할 때, 각 직원 클래스를 건드리지 않고 중재자 클래스만 수정하면 된다. 매장의 운영 매뉴얼이 POS 한곳에 집중되어 있어 흐름을 파악하기도 쉽다.
하지만 주의할 점도 있다. 모든 로직이 중재자에게 집중되다 보니, 중재자 클래스가 지나치게 거대해지고 복잡해지는 '전지전능한 객체(God Object)'가 될 위험이 있다. POS 클래스가 수천 줄이 넘어간다면, 그것은 중재자가 아니라 또 다른 괴물을 만든 셈이다. 이럴 때는 중재자의 책임을 여러 개로 쪼개거나, 업무 단위별로 중재자를 분리하는 전략이 필요하다.
전체 코드
type OrderStatus = '접수' | '조리 중' | '배달 준비 완료' | '배달 중';interface Order { id: string; menu: string;}/** * 식별 가능한 유니온을 활용한 이벤트 타입 정의 */type ShopEvent = | { type: 'ORDER_RECEIVED'; order: Order } | { type: 'COOKING_COMPLETED'; order: Order } | { type: 'PICKUP_COMPLETED'; order: Order };/** * Mediator Interface */interface ShopMediator { notify(sender: object, event: ShopEvent): void;}/** * Base Colleague */abstract class Colleague { constructor(protected mediator: ShopMediator) {}}/** * Concrete Colleague: 캐셔 */class Cashier extends Colleague { public takeOrder(id: string, menu: string): void { const order: Order = { id, menu }; console.log(`[캐셔] ${order.id}번 주문(${menu}) 접수 완료.`); this.mediator.notify(this, { type: 'ORDER_RECEIVED', order }); } public updateStatus(orderId: string, status: string): void { console.log(`[캐셔] 주문 ${orderId}의 상태를 '${status}'로 업데이트합니다.`); }}/** * Concrete Colleague: 주방 */class Kitchen extends Colleague { public startCooking(order: Order): void { console.log(`[주방] ${order.id}번 피자(${order.menu}) 조리를 시작합니다.`); // 조리 시뮬레이션 setTimeout(() => { console.log(`[주방] ${order.id}번 피자 조리 완료!`); this.mediator.notify(this, { type: 'COOKING_COMPLETED', order }); }, 100); }}/** * Concrete Colleague: 배달 기사 */class DeliveryDriver extends Colleague { public pickup(order: Order): void { console.log(`[배달] ${order.id}번 피자를 픽업했습니다.`); this.mediator.notify(this, { type: 'PICKUP_COMPLETED', order }); }}/** * Concrete Colleague: 매장 디스플레이 */class OrderDisplay extends Colleague { public update(orderId: string, status: OrderStatus): void { console.log(`[화면] 주문 ${orderId}: ${status}`); }}/** * Concrete Mediator: POS 시스템 */class POS implements ShopMediator { private cashier?: Cashier; private kitchen?: Kitchen; private driver?: DeliveryDriver; private display?: OrderDisplay; public setColleagues(cols: { cashier: Cashier; kitchen: Kitchen; driver: DeliveryDriver; display: OrderDisplay; }): void { this.cashier = cols.cashier; this.kitchen = cols.kitchen; this.driver = cols.driver; this.display = cols.display; } public notify(sender: object, event: ShopEvent): void { if (!this.cashier || !this.kitchen || !this.driver || !this.display) return; switch (event.type) { case 'ORDER_RECEIVED': this.kitchen.startCooking(event.order); this.display.update(event.order.id, '조리 중'); break; case 'COOKING_COMPLETED': // 픽업 완료 알림은 동기적으로 다시 들어올 수 있으므로, 준비 완료를 먼저 표시한다. this.display.update(event.order.id, '배달 준비 완료'); this.driver.pickup(event.order); break; case 'PICKUP_COMPLETED': this.cashier.updateStatus(event.order.id, '배달 중'); this.display.update(event.order.id, '배달 중'); break; } }}// 매장 시스템 가동const pos = new POS();const cashier = new Cashier(pos);const kitchen = new Kitchen(pos);const driver = new DeliveryDriver(pos);const display = new OrderDisplay(pos);pos.setColleagues({ cashier, kitchen, driver, display });// 시나리오 시작cashier.takeOrder("P-001", "치즈 피자");POS가 중심을 잡아주니 매장의 소통은 거짓말처럼 조용해졌다. 직원들은 각자 자기 일만 묵묵히 하고, POS가 건네는 지시에 따라 움직인다. 새로운 기능이 필요하면 우선 POS의 switch 문에서 흐름을 확인하면 된다. 물론 이 switch가 너무 커지지 않게 감시하는 일도 같이 필요하다.
그런데 POS가 주문을 조율하다 보니 또 다른 문제가 생겼다. 들어오는 주문을 순서대로 쌓아두고 하나씩 꺼내 처리해야 하는데, 이 주문 대기열(Queue)을 다루는 방식이 제각각이다. 어떤 직원은 대기열이 배열인 줄 알고 인덱스로 접근하려 하고, 어떤 시스템은 우선순위 큐로 동작하기를 원한다. 대기열 내부가 어떻게 생겼든 상관없이, 모든 주문을 '하나씩, 순서대로' 훑어볼 수 있는 표준화된 방법이 필요해졌다.
댓글
댓글을 불러오는 중...