18. Facade — 직원은 버튼 하나만 누르면 된다
작성일:2026.05.21|수정일:2026.08.04|조회수:0

에이든 피자의 시스템은 나날이 발전하고 있다. 재고 관리 시스템, 결제 프록시, 주방 대기열, 알림 서비스, 매출 분석 시스템까지. 각 분야의 전문가(클래스)들이 자기 자리를 잡았다. 그런데 기쁜 마음으로 신입 직원을 채용한 첫날, 예상치 못한 장벽에 부딪혔다. 주문 한 건을 처리하기 위해 신입 직원이 알아야 할 절차가 너무 복잡했기 때문이다.
"자, 주문이 들어오면 먼저 재고 시스템에서 재료를 예약하고, 결제 프록시로 결제를 진행한 다음, 주방 시스템에 주문을 넣고, 손님께 알림을 보낸 뒤, 마지막으로 매출 분석 시스템에 기록을 남기면 돼. 순서 바뀌면 안 되는 거 알지?"
신입 직원의 멍한 표정을 보며 깨달았다. 우리 시스템은 개별적으로는 훌륭하지만, 사용하는 사람에게는 꽤 불친절하다는 사실을 말이다.
// 신입 직원이 매번 작성해야 하는 코드async function handleOrder(order: Order) { inventory.reserve(order.menu); try { await payment.charge(order.amount, order.id); } catch (error) { inventory.release(order.menu); // 결제 실패 시 예약 보상 해제 throw error; } kitchen.enqueue(order); notification.notifyCustomer(order); analytics.record(order);}무엇이 불편한가
가장 큰 문제는 '의존성의 폭발'이다. 주문을 처리하고 싶은 클라이언트(직원)가 너무 많은 서브시스템을 직접 알아야 한다. 만약 우리가 매출 분석 시스템을 업그레이드하거나 알림 서비스를 다른 것으로 교체한다면, 주문을 처리하는 모든 곳을 찾아다니며 코드를 수정해야 한다.
또한 '절차의 파편화'도 심각하다. 위 5단계 중 하나라도 빼먹거나 순서를 틀리면 데이터가 꼬여버린다. 결제는 됐는데 주방에 주문이 안 들어간다거나, 재고는 없는데 결제부터 시도하는 사고가 언제든 터질 수 있는 구조다. 시스템이 정교해질수록 바깥에서 그 시스템을 다루는 난이도는 같이 올라가고 있었다.
우리는 복잡한 내부 사정은 감추고, 겉으로는 아주 단순한 버튼 하나만 내어주고 싶었다. "주문하기" 버튼 하나만 누르면, 그 뒤에서 어떤 복잡한 조율이 일어나는지 클라이언트는 몰라도 되게끔 말이다.
Facade 패턴
Facade는 프랑스어로 “건물의 정면” 혹은 “외관”을 뜻한다. 건물의 내부에 얼마나 복잡한 배관과 배선이 얽혀 있든, 우리는 정문을 통해 들어가기만 하면 되는 것과 같은 이치다. GoF의 원래 의도는 다음과 같다.
서브시스템에 있는 여러 인터페이스에 대해 하나의 통합된 인터페이스를 제공하여, 서브시스템을 더 쉽게 사용할 수 있게 한다.
Facade 패턴은 복잡한 서브시스템의 집합 앞에 하나의 통합된 인터페이스를 세운다.
에이든 피자의 OrderFacade는 복잡하게 흩어져 있던 5개의 시스템을 생성자 주입으로 한데 모은다. 그리고 placeOrder()라는 메서드를 제공한다. 이제 직원은 이 메서드 하나만 호출하면 된다. 재고 예약부터 매출 기록까지의 복잡한 순서는 퍼사드 객체가 책임지고 관리한다. 특히 재고를 예약한 뒤 결제가 실패하면 예약을 보상 해제하고, 실패를 호출자에게 전파해야 주문이 성공한 것처럼 보이지 않는다.
중요한 점은 퍼사드가 서브시스템들을 '숨기는' 것이 아니라는 점이다. 퍼사드는 어디까지나 '편의 시설'이다. 만약 숙련된 직원이 매출 분석은 빼고 결제만 테스트하고 싶다면, 여전히 개별 서브시스템에 직접 접근할 수 있다. 퍼사드는 복잡한 길을 안내하는 지름길이지, 다른 길을 모두 막아버리는 장벽이 아니다.
Adapter, Proxy, Facade: 헷갈리는 삼형제
5부 '외부 연동' 편에서 다룬 세 패턴은 모두 무언가를 '감싸는' 구조라 헷갈리기 쉽다. 여기서 한 번 확실히 정리하고 넘어가자.
- Adapter: 인터페이스를 변환한다. (배민의 언어를 우리의 언어로)
- Proxy: 접근을 제어하거나 부가 기능을 추가한다. (결제 전 검증이나 재시도)
- Facade: 인터페이스를 단순화한다. (여러 시스템을 하나로 묶어 제공)
어댑터는 '맞지 않는 조각을 맞추기 위해' 쓰고, 프록시는 '대리인을 세우기 위해' 쓰며, 퍼사드는 '복잡한 것을 쉽게 쓰기 위해' 쓴다. 에이든 피자에서는 이 세 가지가 조화롭게 맞물려 있다. 배민 주문을 어댑터로 받고, 결제 프록시로 안전하게 돈을 받으며, 이 모든 과정을 퍼사드로 묶어 직원의 업무를 돕는다.
TypeScript 포인트: 생성자 주입과 테스트
OrderFacade를 만들 때 모든 서브시스템을 내부에서 직접 new 하지 않고 생성자로 주입받는 이유는 '테스트 용이성' 때문이다.
만약 퍼사드 내부에서 시스템들을 직접 만들면, 퍼사드를 테스트할 때 진짜 결제가 일어나고 진짜 알림이 발송되는 대참사가 벌어질 수 있다. 하지만 생성자로 주입받으면, 테스트 코드에서는 가짜(Mock) 시스템들을 넣어줄 수 있다. "결제가 실패했을 때 퍼사드가 재고 예약을 해제하고 주방 주문을 넣지 않으며, 실패를 호출자에게 전파하는지" 같은 시나리오를 아주 쉽고 안전하게 검증할 수 있게 되는 것이다.
트레이드오프
Facade 패턴의 이득은 명확하다. 클라이언트 코드가 놀라울 정도로 단순해지고, 시스템 간의 결합도가 낮아진다. 서브시스템이 아무리 바뀌어도 퍼사드만 수정하면 되니 유지보수도 편하다. 신입 개발자가 와도 "이 퍼사드 메서드 하나만 쓰면 돼요"라고 말해줄 수 있는 건 엄청난 축복이다.
다만, 퍼사드 클래스 자체가 너무 많은 일을 하게 되어 이른바 '전지전능한 객체(God Object)'가 될 위험이 있다. 모든 로직을 퍼사드에 몰아넣지 않도록 주의해야 한다. 퍼사드의 역할은 어디까지나 '조율(Orchestration)'이지, 각 시스템의 상세 로직을 직접 수행하는 것이 아니라는 점을 잊지 말자.
전체 코드
/** 1. 복잡한 서브시스템들 */class InventorySystem { reserve(menu: string): void { console.log(`[재고] ${menu} 재료 예약 완료`); } release(menu: string): void { console.log(`[재고] ${menu} 재료 예약 해제`); }}interface IPaymentGateway { charge(amount: number, id: string): Promise<void>;}class KitchenSystem { enqueue(menu: string): void { console.log(`[주방] ${menu} 조리 대기열 추가`); }}class NotificationService { notify(message: string): void { console.log(`[알림] ${message}`); }}class AnalyticsSystem { record(menu: string, amount: number): void { console.log(`[분석] 매출 기록: ${menu} (${amount}원)`); }}/** 2. Facade: 복잡한 과정을 단순한 인터페이스로 통합 */class OrderFacade { constructor( private inventory: InventorySystem, private payment: IPaymentGateway, private kitchen: KitchenSystem, private notification: NotificationService, private analytics: AnalyticsSystem, ) {} /** 클라이언트는 이 메서드 하나만 호출하면 된다. */ async placeOrder(menu: string, amount: number, orderId: string): Promise<void> { console.log(`--- ${orderId} 번 주문 처리 시작 ---`); let reserved = false; try { this.inventory.reserve(menu); reserved = true; await this.payment.charge(amount, orderId); this.kitchen.enqueue(menu); this.notification.notify(`주문하신 ${menu} 조리가 시작되었습니다.`); this.analytics.record(menu, amount); console.log(`--- ${orderId} 번 주문 처리 완료 ---`); } catch (error) { if (reserved) this.inventory.release(menu); // 결제 실패의 보상 처리 const message = error instanceof Error ? error.message : String(error); console.error('주문 처리 중 오류 발생:', message); this.notification.notify('죄송합니다. 주문 처리 중 문제가 발생했습니다.'); throw error; // 호출자도 실패를 명확히 처리하게 한다. } }}async function run() { const facade = new OrderFacade( new InventorySystem(), { charge: async () => console.log('[결제] 승인 완료') }, new KitchenSystem(), new NotificationService(), new AnalyticsSystem(), ); await facade.placeOrder('페퍼로니 피자', 22000, 'ORD-2024-005');}run().catch((error: unknown) => console.error('직원 화면에 주문 실패 표시', error));퍼사드 덕분에 에이든 피자의 운영 흐름은 한결 단순해졌다. 이제 어떤 복잡한 시스템이 추가되어도 퍼사드 뒤에서 순서를 맞추면 된다. 그런데 이번엔 다른 차원의 고민이 시작됐다. 결제 수단은 카드, 현금, 포인트로 계속 늘어나는데, 주문 유형도 홀, 포장, 배달로 다양해지고 있다. 이 두 가지를 조합하다 보니 클래스 개수가 폭발하기 시작했다. CardDeliveryOrder, CashTakeoutOrder... 이 조합의 저주를 어떻게 풀어야 할까?
댓글
댓글을 불러오는 중...