11. Chain of Responsibility — 주문이 통과해야 하는 관문들

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

11. Chain of Responsibility — 주문이 통과해야 하는 관문들

에이든 피자의 인기가 날로 치솟고 있다. 주방 조리법도 잡혔고 알림 시스템도 완벽하다. 하지만 인기가 많아진 만큼 사고도 늘었다. 재고가 없는데 주문이 들어오기도 하고, 결제가 안 됐는데 피자가 구워지기도 한다. 주방은 한계인데 주문은 계속 밀려들어 와서 주방장이 뒷목을 잡는 일도 생겼다.

그래서 우리는 주문이 주방으로 들어가기 전, 통과해야 하는 '검증의 문'들을 만들기로 했다. 처음에는 열의에 넘쳐서 모든 검증 로직을 하나의 큰 함수에 때려 넣었다.

TS
function processOrder(order: Order) {  // 1. 재고 확인  if (!inventory.hasIngredients(order)) {    throw new Error('재료가 부족합니다.');  }  // 2. 결제 검증  if (!payment.validate(order)) {    throw new Error('결제가 승인되지 않았습니다.');  }  // 3. 주방 용량 체크  if (!kitchen.canAcceptMoreOrders()) {    throw new Error('주방이 포화 상태입니다. 잠시 후 다시 주문해주세요.');  }  // 4. 배달 주소 확인 (배달 주문일 때만)  if (order.type === 'delivery' && !delivery.isValidAddress(order.address)) {    throw new Error('배달 불가능한 지역입니다.');  }  // 모든 관문을 통과하면 주방으로!  kitchen.startCooking(order);}

이 함수는 에이든 피자의 수문장 같은 존재다. 하지만 수문장이 너무 바쁘다. 검증 단계가 하나 추가될 때마다 이 함수를 열어서 코드를 끼워 넣어야 한다. 더 큰 문제는 주문의 종류에 따라 통과해야 하는 문이 다르다는 점이다. 예를 들어 배달 앱을 통해 들어온 주문은 앱에서 이미 결제와 주소 확인이 끝났으니 결제 검증 단계를 건너뛰고 싶지만, 지금의 거대한 함수 구조로는 그런 유연함을 발휘하기 어렵다.

무엇이 불편한가

가장 큰 불편함은 "책임의 비대함"이다. processOrder() 함수는 재고, 결제, 주방 상황, 배달 주소까지 온 세상의 걱정을 혼자 다 하고 있다. 코드가 길어지는 건 둘째 치고, 특정 단계의 로직을 고치다가 전혀 상관없는 다른 단계의 코드를 건드릴 위험이 항상 도사리고 있다.

또한 "순서와 조합"을 바꾸기가 너무 힘들다. 매장 주문, 배달 주문, 전화 주문마다 필요한 검증 단계가 조금씩 다른데, 이걸 if-else로 처리하기 시작하면 함수는 금세 누더기가 된다. 검증 단계 하나하나를 독립적으로 테스트하기도 어렵다. 재고 확인 로직만 따로 떼어내서 확인하고 싶은데, 매번 결제와 주방 체크 로직까지 줄줄이 사탕처럼 따라오기 때문이다.

우리가 원하는 건 각각의 검증 단계를 독립적인 객체로 만드는 것이다. 그리고 이 객체들을 사슬(Chain)처럼 연결해서, 주문이라는 요청이 이 사슬을 타고 흘러가게 하고 싶다. 어떤 문은 열려 있고 어떤 문은 닫혀 있을 수도 있지만, 주문은 그저 사슬을 따라갈 뿐이다.

Chain of Responsibility 패턴

Chain of Responsibility(책임 연쇄) 패턴은 요청을 처리할 수 있는 기회를 여러 객체에 부여하여, 요청을 보내는 쪽과 받는 쪽의 결합을 피하는 패턴이다. GoF의 원래 의도는 다음과 같다.

요청을 처리할 수 있는 객체들이 사슬처럼 연결되어 있어, 요청을 받으면 사슬을 따라가며 처리할 수 있는 객체를 찾는다.

에이든 피자에 대입해보면, 각 검증 단계(재고, 결제, 주방 용량 등)가 하나의 핸들러(Handler)가 된다. 주문이 들어오면 첫 번째 핸들러에게 전달된다. 첫 번째 핸들러는 자기가 할 일을 하고, 문제가 없으면 다음 핸들러에게 주문을 넘긴다. 만약 문제가 생기면 그 자리에서 사슬을 끊고 "주문 실패!"를 외친다.

이 패턴의 핵심은 각 핸들러가 "다음 핸들러가 누구인지"는 알지만, "전체 사슬이 어떻게 구성되어 있는지"는 모른다는 것이다. 덕분에 우리는 핸들러들의 순서를 바꾸거나, 중간에 새로운 핸들러를 끼워 넣거나, 특정 핸들러를 빼버리는 일을 주방 코드 수정 없이 자유롭게 할 수 있다. 다만 GoF의 책임 연쇄는 각 핸들러가 요청을 처리할지 다음으로 넘길지를 선택하므로, 모든 핸들러가 반드시 실행되거나 요청이 반드시 처리된다고 보장하지 않는다. 여기의 주문 검증 예제는 모든 관문을 정해진 순서로 통과해야 하는 검증 파이프라인으로 적용한 변형이다.

검증의 사슬 만들기

리팩토링을 시작해보자. 먼저 모든 핸들러의 부모가 될 추상 클래스를 만든다.

TS
abstract class OrderHandler {  protected next: OrderHandler | null = null;  // 다음 핸들러를 설정하고, 체이닝을 위해 자기 자신을 반환한다  public setNext(handler: OrderHandler): OrderHandler {    this.next = handler;    return handler;  }  // 실제 처리를 수행할 추상 메서드  public abstract handle(order: Order): void;  // 다음 핸들러에게 요청을 넘긴다  protected passToNext(order: Order): void {    if (this.next) {      this.next.handle(order);    } else {      console.log('--- 모든 검증 완료: 주방으로 전송 ---');    }  }}

이제 구체적인 핸들러들을 만든다. 각 핸들러는 오직 자기 책임만 진다.

TS
class InventoryHandler extends OrderHandler {  public override handle(order: Order) {    console.log('[검증 1] 재고를 확인합니다...');    if (order.pizzaName === '솔드아웃피자') { // 예시용      throw new Error('재료가 부족합니다.');    }    this.passToNext(order);  }}class PaymentHandler extends OrderHandler {  public override handle(order: Order) {    console.log('[검증 2] 결제 상태를 확인합니다...');    if (!order.isPaid) {      throw new Error('결제가 완료되지 않았습니다.');    }    this.passToNext(order);  }}class KitchenCapacityHandler extends OrderHandler {  public override handle(order: Order) {    console.log('[검증 3] 주방 용량을 확인합니다...');    // 주방이 너무 바쁘면 실패 처리    this.passToNext(order);  }}

이제 이 핸들러들을 조립하기만 하면 된다. 이 조립 과정이 바로 에이든 피자의 '비즈니스 정책'이 된다.

TS
const inventory = new InventoryHandler();const payment = new PaymentHandler();const capacity = new KitchenCapacityHandler();// 사슬 조립: 재고 -> 결제 -> 주방용량inventory.setNext(payment).setNext(capacity);// 주문 실행try {  inventory.handle({ id: '1', pizzaName: '페퍼로니', isPaid: true });} catch (e) {  const message = e instanceof Error ? e.message : String(e);  console.error(`주문 거부: ${message}`);}

조립 지점이 별도로 생기니 정책 변경도 조금 덜 무섭다. 만약 배달 앱 주문이라서 결제 확인이 필요 없다면, inventory.setNext(capacity)로 사슬을 다시 묶어주면 된다. 기존 핸들러 코드는 건드릴 필요가 없다.

TypeScript 포인트

TypeScript로 Chain of Responsibility를 구현할 때 setNext 메서드가 자기 자신, 정확히는 다음에 이어질 OrderHandler를 반환하게 두면 꽤 편하다. 덕분에 .setNext(a).setNext(b).setNext(c) 같은 유연한 빌더 패턴 스타일의 체이닝이 가능해진다.

또한 이 패턴은 웹 개발자들에게 매우 친숙한 "미들웨어(Middleware)" 패턴의 원형이기도 하다. Express.js에서 (req, res, next) => { ... next(); }를 본 적이 있다면, 당신은 이미 이 패턴을 사용하고 있었던 셈이다. 차이점이 있다면, GoF의 정통적인 방식은 클래스 상속과 객체 참조를 이용한다는 점이고, 현대적인 미들웨어 방식은 함수형 구성을 더 선호한다는 점이다.

TypeScript의 강력한 타입 시스템을 사용하면 요청 객체(Order)에 제네릭을 적용해 더 재사용성 높은 핸들러 체인을 만들 수도 있다. 시범 운영 중인 에이든 피자에서는 일단 클래스 기반의 정석적인 방식을 유지하기로 했다. "누가 이 요청을 처리할 것인가"라는 질문에 답하기에는 명시적인 클래스 구조가 실수를 줄여주기 때문이다.

트레이드오프

Chain of Responsibility 패턴의 가장 큰 장점은 유연성이다. 요청을 보내는 쪽은 사슬 내부가 어떻게 생겼는지 몰라도 되고, 사슬의 각 고리는 자기 일만 하면 된다. 새로운 검증 로직을 추가하는 것은 그저 새로운 클래스를 하나 만들고 사슬에 끼워 넣는 일일 뿐이다.

하지만 사슬이 너무 길어지면 요청이 어디까지 갔는지 추적하기가 힘들어질 수 있다. 디버깅을 하다가 호출 스택을 따라가다 보면 "내가 지금 어느 핸들러에 와 있지?"라며 길을 잃기 십상이다. 또한 사슬의 끝까지 갔는데도 아무도 요청을 처리하지 않는(또는 다음으로 넘기기만 하는) 상황이 생길 수 있으므로, 반드시 마지막 고리나 기본 처리에 대한 고려가 필요하다.

무엇보다도, 이 패턴은 요청이 반드시 처리된다는 보장을 하지 않는다. 중간에 누군가 사슬을 끊으면 그대로 끝이다. 하지만 바로 그 "끊을 수 있다"는 특징 덕분에, 결제가 안 된 주문이 오븐으로 직행하는 불상사를 막을 수 있다. 에이든 피자 입장에서는 꽤 든든한 파수꾼인 셈이다.

전체 코드

TS
/** * 주문 정보 인터페이스 */interface Order {  id: string;  pizzaName: string;  isPaid: boolean;  type: 'dine-in' | 'delivery';  address?: string;}/** * Handler: 모든 핸들러의 뼈대가 되는 추상 클래스 */abstract class OrderHandler {  protected nextHandler: OrderHandler | null = null;  /**   * 다음 처리기를 연결한다. (빌더 패턴 스타일)   */  public setNext(handler: OrderHandler): OrderHandler {    this.nextHandler = handler;    return handler;  }  /**   * 요청을 처리하거나 다음 처리기로 넘긴다.   */  public abstract handle(order: Order): void;  /**   * 다음 처리기가 있다면 실행한다.   */  protected passToNext(order: Order): void {    if (this.nextHandler) {      this.nextHandler.handle(order);    } else {      console.log("✅ [최종] 모든 관문을 통과했습니다. 주방에 주문을 전달합니다.");    }  }}/** * ConcreteHandler 1: 재고 확인 */class InventoryCheckHandler extends OrderHandler {  public override handle(order: Order): void {    console.log("--- 재고 확인 단계 ---");    if (order.pizzaName === "파인애플피자") {      throw new Error(`죄송합니다. ${order.pizzaName} 재료가 다 떨어졌습니다.`);    }    this.passToNext(order);  }}/** * ConcreteHandler 2: 결제 확인 */class PaymentCheckHandler extends OrderHandler {  public override handle(order: Order): void {    console.log("--- 결제 확인 단계 ---");    if (!order.isPaid) {      throw new Error("결제가 완료되지 않은 주문입니다.");    }    this.passToNext(order);  }}/** * ConcreteHandler 3: 배달 주소 확인 */class AddressCheckHandler extends OrderHandler {  public override handle(order: Order): void {    if (order.type === 'delivery') {      console.log("--- 배달 주소 확인 단계 ---");      if (!order.address) {        throw new Error("배달 주문인데 주소가 없습니다.");      }    }    this.passToNext(order);  }}/** * ConcreteHandler 4: 주방 용량 확인 */class KitchenCapacityHandler extends OrderHandler {  public override handle(order: Order): void {    console.log("--- 주방 용량 확인 단계 ---");    // 실제로는 현재 진행 중인 주문 수를 체크하겠지만, 여기선 통과로 가정    this.passToNext(order);  }}// 에이든 피자 주문 처리 시스템 가동const inventory = new InventoryCheckHandler();const payment = new PaymentCheckHandler();const address = new AddressCheckHandler();const kitchen = new KitchenCapacityHandler();// 매장 주문용 체인 구성: 재고 -> 결제 -> 주방용량 (주소 확인 제외)const dineInChain = inventory;inventory.setNext(payment).setNext(kitchen);// 배달 주문용 체인 구성: 재고 -> 결제 -> 주소 -> 주방용량const deliveryChain = new InventoryCheckHandler();deliveryChain  .setNext(new PaymentCheckHandler())  .setNext(new AddressCheckHandler())  .setNext(new KitchenCapacityHandler());function getErrorMessage(error: unknown): string {  return error instanceof Error ? error.message : String(error);}console.log("=== 매장 주문 테스트 ===");try {  dineInChain.handle({    id: "D-01",    pizzaName: "페퍼로니",    isPaid: true,    type: "dine-in"  });} catch (error) {  console.log(`[주문 취소] ${getErrorMessage(error)}`);}console.log("\n=== 배달 주문 테스트 (결제 누락) ===");try {  deliveryChain.handle({    id: "V-02",    pizzaName: "마르게리타",    isPaid: false, // 결제 안 됨    type: "delivery",    address: "서울시 강남구..."  });} catch (error) {  console.log(`[주문 취소] ${getErrorMessage(error)}`);}console.log("\n=== 배달 주문 테스트 (정상) ===");try {  deliveryChain.handle({    id: "V-03",    pizzaName: "마르게리타",    isPaid: true,    type: "delivery",    address: "서울시 강남구..."  });} catch (error) {  console.log(`[주문 취소] ${getErrorMessage(error)}`);}

검증의 사슬이 완성되면서 에이든 피자의 주문은 이제 훨씬 안전하게 주방으로 전달된다. 까다로운 조건이 추가되더라도 사슬의 고리를 하나 더 만들어 끼워 넣으면 된다. 적어도 거대한 processOrder() 함수를 다시 파헤치는 일은 줄어든다.

그런데 주문이 사슬을 통과해 주방으로 들어갔다고 해서 끝이 아니다. 이제 주문은 자신의 생애주기를 시작한다. 접수됨, 조리 중, 완료, 배달 중, 배달 완료. 주문은 시간에 따라 자신의 '상태'를 바꾼다. 그런데 조리 중인 피자를 갑자기 취소하겠다고 하면 어떻게 해야 할까? 이미 배달 나간 피자의 토핑을 바꿔달라고 하면? 주문의 상태에 따라 할 수 있는 일과 없는 일이 복잡하게 얽히기 시작했다.

댓글

댓글을 불러오는 중...