24. Integration — 피자가게가 열렸습니다

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

24. Integration — 피자가게가 열렸습니다

드디어 에이든 피자가 문을 연다. 1편에서는 메뉴가 조금만 늘어도 if-else가 길어지는 작은 문제에서 출발했고, 23편에서는 no-cheese, extra-sauce, well-done 같은 요청 문자열을 피자 객체에 반영하는 데까지 왔다. 그런데 여기서 마지막 함정이 하나 남는다. 각 편의 코드를 그대로 이어 붙이면 애플리케이션이 되는가?

그렇지는 않다. 앞선 글들의 코드는 패턴을 고립해서 보여주기 위한 학습용 예시였다. 그래서 편마다 Pizza, Order, Kitchen이라는 이름이 다시 등장하고, 그때마다 필요한 책임도 조금씩 달랐다. 최종 앱은 그 조각을 물리적으로 복사해서 붙인 결과가 아니라, 하나의 도메인 모델 위에 각 패턴의 책임을 다시 배치한 결과여야 한다.

패턴은 코드 조각을 모으는 접착제가 아니라, 한 흐름 안에서 책임을 나누는 어휘다.

이 글에서는 src/ 디렉토리를 기준으로 한 통합 설계를 제안한다. 예를 들어 피자는 src/domain/Pizza.ts, 주문은 src/domain/Order.ts, 주문 항목은 src/domain/OrderItem.ts에 놓고 각 패턴 모듈이 이 공통 모델을 사용하도록 구성할 수 있다. 아래 구조와 코드는 그 책임 배치를 설명하는 예시이며, 이 글만으로는 실행 가능한 소스나 저장소를 제공한다고 주장하지 않는다.

최종 앱 구조

아래는 설명을 위한 작은 TypeScript 프로젝트 구조 예시다. src/index.ts를 실행 진입점으로 두고, 필요하다면 루트의 ayden-pizza-application.ts가 같은 앱을 호출하도록 구성할 수 있다.

TXT
src/├── domain/│   ├── Pizza.ts│   ├── Order.ts│   ├── OrderItem.ts│   ├── OrderState.ts│   └── ShoppingCart.ts├── creation/│   ├── MenuRegistry.ts│   ├── PizzaBuilder.ts│   ├── PizzaFactory.ts│   ├── PizzaStore.ts│   ├── PresetPizza.ts│   └── ToppingRegistry.ts├── ordering/│   ├── OrderCommand.ts│   ├── OrderHistory.ts│   ├── PricingStrategy.ts│   └── SpecialRequest.ts├── kitchen/│   ├── KitchenMediator.ts│   ├── KitchenObserver.ts│   ├── KitchenTemplate.ts│   └── OrderChain.ts├── delivery/│   ├── DeliveryIterator.ts│   ├── DeliveryStrategy.ts│   └── OrderDecorator.ts├── external/│   ├── adapters/│   │   ├── BaeminAdapter.ts│   │   ├── CoupangEatsAdapter.ts│   │   └── DeliveryOrder.ts│   ├── OrderFacade.ts│   ├── OrderType.ts│   └── PaymentProxy.ts├── analytics/│   └── OrderVisitor.ts├── AydenPizzaApplication.ts└── index.ts

이 구조에서 중요한 점은 폴더 이름보다 의존 방향이다. domain/은 피자와 주문의 기본 말을 정하고, 나머지 모듈은 그 말을 빌려 자기 역할을 수행한다. 예를 들어 Decorator는 새 피자 클래스를 만들지 않는다. OrderComponent를 감싸 가격과 설명을 덧붙인다. Visitor도 주문 객체를 수정하지 않는다. accept(visitor)라는 통로를 열어두고, 영수증 계산 같은 새 연산은 바깥에 둔다.

TS
export interface OrderComponent {  getName(): string;  getPrice(): number;  describe(): string;  accept(visitor: OrderVisitor): void;}

이 인터페이스 하나 때문에 단품, 피자, 세트, 장바구니, 영수증 계산이 같은 흐름에 들어온다. 8편 Composite와 22편 Visitor가 서로 다른 예시처럼 흩어지지 않고, 같은 주문 모델 위에서 만나는 지점이다.

실행 진입점은 일부러 비어 있다

index.ts는 가게를 운영하는 법을 모른다. 어떤 지점 재료를 쓸지, 배달 주문을 어떤 Adapter로 받을지, 결제 앞에 어떤 Proxy를 둘지, 주방 알림은 누구에게 보낼지 모두 모른다. 그 책임은 AydenPizzaApplication이 가진다.

TS
import { AydenPizzaApplication } from "./AydenPizzaApplication";await new AydenPizzaApplication().main();

이 한 줄은 의도적으로 얇다. 최종 앱에서 확인하고 싶은 것은 index.ts가 똑똑한지가 아니라, 애플리케이션 조립부가 앞선 23개 패턴을 하나의 주문 흐름 안에 배치할 수 있는지다. 진입점은 지휘봉만 들고, 실제 악보는 조립부가 가진다.

TS
export class AydenPizzaApplication {  async main(): Promise<void> {    const container = this.bootstrap();    await this.runDineInScenario(container);    await this.runDeliveryScenario(container);  }}

bootstrap()은 작은 조립대다. OrderStatusSubject에 손님 화면, 주방 대시보드, 배달앱 알림을 붙이고, PizzaShopMediator에 조리 템플릿과 배달 큐를 넘긴다. OrderFacade에는 검증 체인, 가격 전략, 결제 프록시, 중재자, 상태 머신이 들어간다. 이 조립이 끝나면 시나리오는 더 이상 패턴 이름을 줄줄 외울 필요가 없다. 주문을 넣으면 각 객체가 자기 차례에 자기 일을 한다.

시나리오 A: 홀 손님 커스텀 세트 주문

첫 번째 시나리오는 매장 손님이다. 손님은 페퍼로니 피자를 고르고, 큰 사이즈와 얇은 도우를 원한다. 여기에 버섯을 추가하고, no-cheese, extra-sauce, well-done이라는 특수 요청을 남긴다. 이 주문은 세트로 묶이고, 잘못 적용한 쿠폰을 한 번 되돌린 뒤 진짜 쿠폰으로 결제된다.

TS
const basePizza = container.store.orderPizza("pepperoni");const preset = new PresetPizza(basePizza);const cloned = preset.clone("PIZZA-REGULAR-001");const pizza = new PizzaBuilder()  .from(cloned)  .setSize("large")  .setCrust("thin")  .addTopping(container.toppingRegistry.get("mushroom").name)  .build();

이 짧은 흐름 안에 생성 계열 패턴이 몰려 있다. SeoulPizzaStore는 Factory Method로 피자 생성을 맡고, KoreanIngredientFactory는 Abstract Factory로 서울 재료군을 제공한다. PresetPizza는 Prototype으로 단골 주문을 복제하고, PizzaBuilder는 복제된 피자를 손님 취향에 맞게 다시 조립한다. ToppingRegistry는 Flyweight로 버섯 메타데이터를 공유한다.

주문은 바로 결제되지 않는다. 주문 항목은 Command로 장바구니에 들어가고, 쿠폰 적용 전 상태는 Memento로 저장된다. 일부러 잘못된 쿠폰을 적용한 뒤 복원하는 이유는, “되돌리기”가 글 속 설명이 아니라 실제 실행 흐름에 포함되어야 하기 때문이다.

TS
await container.commandBus.run(new AddItemCommand(order, combo));container.history.push(saveOrder(order, "쿠폰 적용 전"));await container.commandBus.run(new ApplyCouponCommand(order, "WRONGCODE"));const snapshot = container.history.pop();if (snapshot) {  restoreOrder(order, snapshot);}await container.commandBus.run(new ApplyCouponCommand(order, "LUNCH10"));

여기서 중요한 것은 패턴을 과시하는 것이 아니다. 잘못된 쿠폰을 되돌릴 수 있어야 한다는 구체적인 사건이 있고, 그 사건 때문에 Command와 Memento가 함께 나온다. 손님이 “아, 그 쿠폰 말고 점심 쿠폰이요”라고 말하는 순간, 패턴은 이름이 아니라 운영 기능이 된다.

시나리오 B: 배달앱 장거리 주문

두 번째 시나리오는 외부 배달앱에서 들어온 주문이다. 배민 payload는 우리 도메인의 Order가 아니다. 필드 이름도 다르고, 메뉴 코드도 다르고, 요청 사항도 외부 형식으로 들어온다. 그래서 먼저 Adapter가 필요하다.

TS
export type NormalizedDeliveryOrder = {  id: string;  customerName: string;  phone: string;  address: string;  pizzaKind: PizzaKind;  request: string;  distanceKm: number;  channel: OrderChannel;};

BaeminAdapterCoupangEatsAdapter는 서로 다른 외부 주문을 이 내부 형식으로 바꾼다. 앱의 나머지 부분은 배민인지 쿠팡이츠인지 신경 쓰지 않는다. 그저 pizzaKind, request, distanceKm, channel을 보고 같은 주문 흐름으로 처리한다.

TS
const normalized = new BaeminAdapter().adapt(payload);const pizza = container.store.orderPizza(normalized.pizzaKind);for (const expression of container.parser.parse(normalized.request)) {  expression.interpret(pizza);}

그 다음에는 거리 기반 배달비 Strategy가 붙고, 배달팁 Decorator가 주문 항목을 감싼다. 결제는 Bridge로 주문 유형과 결제 수단을 분리한다. 배달 주문은 카드 결제를 쓰지만, 홀 주문은 포인트 결제를 쓴다. 결제 수단이 바뀌어도 주문 유형 클래스가 함께 폭발하지 않는 것이 Bridge의 역할이다.

TS
const deliveryFeeStrategy = new DistanceDeliveryFeeStrategy();const deliveryFee = deliveryFeeStrategy.calculate(normalized.distanceKm);await container.commandBus.run(  new AddItemCommand(    order,    new DeliveryTipDecorator(new PizzaMenuItem(pizza), deliveryFee),  ),);new DeliveryOrderType(new CardPayment()).completePayment(  order,  container.pricing.calculate(order),);

배달 주문은 주방에서 끝나지 않는다. PizzaShopMediator가 배달 데스크에 주문을 넘기고, DeliveryQueue는 Iterator로 순회된다. 주문 상태는 draft → confirmed → paid → baking → delivering → delivered로 전이된다. 상태 변경은 Observer를 통해 손님, 주방, 배달앱 화면에 전달된다.

주문 한 건이 통과하는 관문

최종 앱의 중심에는 OrderFacade.placeOrder()가 있다. 이 메서드는 직원이 누르는 “주문 접수” 버튼에 가깝다. 바깥에서는 이 메서드 하나만 호출하지만, 안에서는 검증 체인, 상태 전이, 가격 계산, 결제 프록시, 주방 중재, 배달 배정이 차례로 움직인다.

TS
placeOrder(order: Order, pizza: Pizza): number {  this.validator.validate(order);  this.stateMachine.advance(order);  this.mediator.orderConfirmed(order);  const amount = this.pricing.calculate(order);  if (!this.payment.pay(amount)) {    order.setStatus("cancelled");    this.subject.notify(order);    throw new Error(`결제 실패: ${order.id}`);  }  this.stateMachine.advance(order);  this.subject.notify(order);  this.stateMachine.advance(order);  this.mediator.pizzaBaked(order, pizza);  this.stateMachine.advance(order);  this.mediator.deliveryAssigned(order);  return amount;}

이 메서드가 길어 보일 수 있지만, 일을 직접 떠안지는 않는다. 검증은 Chain of Responsibility가 맡고, 결제 앞 로깅과 이상 거래 검사는 Proxy가 맡는다. 조리 순서는 Template Method가 고정하고, 상태 전이는 State가 담당한다. 알림은 Observer로 퍼지고, 부서 간 조율은 Mediator가 맡는다. Facade는 그 순서를 한곳에 모아 직원이 외워야 할 절차를 줄인다.

23개 패턴이 자리 잡은 곳

이제 각 패턴은 “등장했다”가 아니라 “어디서 필요한가”로 읽어야 한다.

패턴최종 앱에서 맡은 일
1Factory MethodSeoulPizzaStore가 피자 종류별 생성을 맡는다
2BuilderPizzaBuilder가 단골 피자를 손님 옵션으로 다시 조립한다
3Abstract FactoryKoreanIngredientFactory가 지역 재료군을 제공한다
4PrototypePresetPizza가 단골 주문을 복제한다
5SingletonMenuRegistry가 메뉴판을 하나의 레지스트리로 유지한다
6CommandCommandBus가 항목 추가와 쿠폰 적용을 실행 이력으로 남긴다
7DecoratorExtraCheeseDecorator, DeliveryTipDecorator가 주문 항목을 감싼다
8CompositeComboSet이 피자와 콜라를 같은 주문 항목으로 다룬다
9Template MethodPizzaPreparationTemplate이 조리 순서를 고정한다
10ObserverOrderStatusSubject가 손님, 주방, 배달앱에 상태를 알린다
11Chain of ResponsibilityOrderValidator 체인이 재고, 결제 가능성, 배달 주소를 확인한다
12StateOrderStateMachine이 주문 상태 전이를 맡는다
13StrategyPricingContext, DeliveryFeeStrategy가 가격 정책을 교체한다
14MediatorPizzaShopMediator가 POS, 주방, 배달 데스크를 조율한다
15IteratorDeliveryQueue가 배달 대기열을 순회한다
16Adapter배달앱 Adapter가 외부 payload를 내부 주문 형식으로 맞춘다
17ProxyPaymentGatewayProxy가 실제 결제 호출 앞에서 검사한다
18FacadeOrderFacade가 주문 접수 과정을 하나의 창구로 묶는다
19BridgeOrderTypePaymentMethod가 주문 유형과 결제 수단을 분리한다
20FlyweightToppingRegistry가 토핑 메타데이터를 공유한다
21MementoOrderMemento가 쿠폰 적용 전 주문 상태를 저장한다
22VisitorReceiptVisitor가 주문 항목을 바꾸지 않고 영수증 계산을 수행한다
23InterpreterSpecialRequestParser가 요청 문자열을 피자 변경 명령으로 해석한다

이 표는 체크리스트가 아니다. 패턴을 하나씩 “넣었다”는 증거를 만들려고 하면 코드는 금방 어색해진다. 반대로 운영 흐름을 먼저 두고, 그 흐름에서 진짜로 갈라지는 책임을 찾으면 패턴은 자기 자리를 가진다. 이 글의 통합 설계도 그 방향을 제안한다.

실행 전에 확인할 것

이 글에는 위 구조를 실제로 실행할 수 있는 소스나 저장소 링크가 포함되어 있지 않다. 따라서 npm install, npm run dev, npx tsx 같은 명령이 이 글의 코드에 그대로 적용된다고 보장할 수 없다. 구현을 시작할 때는 package.json, TypeScript 실행 도구, 각 모듈의 실제 구현과 import 경로를 준비한 뒤, 홀 주문과 배달 주문 시나리오를 각각 테스트해야 한다.

예시에서 제안한 로그 표식([Factory Method], [Command], [State], [Mediator], [Adapter])은 학습용 추적 장치로 설계할 수 있다. 실제 출력 여부는 구현과 테스트 결과로 확인해야 한다.

마치며: 패턴은 완성품이 아니라 대화 방식이다

처음에는 디자인 패턴이 클래스를 많이 만드는 기술처럼 보일 수 있다. 실제로 그렇게 쓰면 코드가 불편해진다. 피자 한 판을 만들기 위해 패턴 이름을 전부 외우고, 억지로 객체를 늘리고, “여기에도 패턴이 들어갔다”고 표시하는 순간 가게는 손님보다 패턴을 더 열심히 접대하게 된다.

이번 최종 앱에서 중요한 기준은 반대다. 손님 주문이 먼저 있고, 주문이 지나가야 할 현실적인 단계가 있다. 피자를 만들고, 옵션을 바꾸고, 세트로 묶고, 검증하고, 결제하고, 굽고, 알리고, 배달한다. 그 과정에서 책임이 갈라지는 지점에 이름을 붙이면 Factory Method, Command, State, Adapter 같은 단어가 생긴다.

그 이름을 알면 팀의 대화가 짧아진다. “배달앱마다 파싱 코드를 주문 서비스에 넣자”가 아니라 “Adapter를 두자”라고 말할 수 있고, “주문 상태별 if문이 늘어난다”가 아니라 “State로 빼자”라고 말할 수 있다. 패턴은 목적지가 아니라, 복잡해지는 코드를 함께 바라보기 위한 공통 어휘다. 에이든 피자가 오늘 배운 것은 바로 그 점이다.

댓글

댓글을 불러오는 중...