cv.cm

코딩 · 디자인 패턴

책임 연쇄 패턴 예제로 쉽게 이해하기

7분 분량

핵심 답변

책임 연쇄(Chain of Responsibility)는 요청을 핸들러 객체의 체인을 따라 전달하는 행동 패턴입니다. 각 핸들러는 요청을 직접 처리하거나 체인의 다음 핸들러로 넘깁니다. 요청을 보내는 쪽은 어느 핸들러가 처리할지 알지 못하므로, 보내는 쪽을 바꾸지 않고 핸들러를 추가, 제거, 재배열할 수 있습니다. 웹 미들웨어와 DOM 이벤트 버블링이 대표적인 예입니다.

의도와 해결하는 문제

의도: 요청을 보내는 쪽과 받는 쪽의 결합을 피하기 위해, 요청을 처리할 기회를 둘 이상의 객체에 줍니다. 받는 객체들을 체인으로 연결하고, 어떤 객체가 처리할 때까지 요청을 체인을 따라 전달합니다. 이 패턴은 GoF(Gang of Four) 책에 실린 행동 패턴 중 하나입니다.

온라인 쇼핑몰의 고객 지원 시스템을 생각해 봅시다. 50 미만의 환불 요청은 상담원이, 500까지는 팀장이 승인할 수 있고, 그보다 큰 금액은 매니저가 필요합니다. 첫 버전은 이 모든 것을 긴 if/else 연쇄가 있는 함수 하나에 넣는 경우가 많습니다. 규칙이 늘 때마다 함수는 길어지고, 검사 순서도 코드에 고정됩니다.

책임 연쇄 패턴을 쓰면 각 단계가 규칙 하나만 가진 독립된 핸들러가 됩니다. 핸들러들을 서로 연결하고, 요청은 첫 번째 핸들러로 들어갑니다. 새 승인 단계를 추가하려면 작은 클래스 하나를 작성해 체인에 끼워 넣으면 됩니다.

구조와 참여자

역할책임
핸들러(Handler)요청을 처리하는 메서드를 선언하고 다음 핸들러에 대한 참조를 보관합니다
구체 핸들러(Concrete handler)자신이 맡은 요청을 처리하고, 그렇지 않으면 요청을 다음 핸들러로 전달합니다
클라이언트(Client)체인을 구성하거나 전달받아 첫 번째 핸들러에 요청을 보냅니다

흔한 변형이 두 가지 있습니다. 고전적인 형태에서는 요청을 처리할 수 있는 첫 번째 핸들러가 체인을 끝냅니다. 파이프라인 형태(대부분의 미들웨어)에서는 모든 핸들러가 어느 정도 작업을 수행한 뒤 다음 핸들러를 호출할지 결정합니다.

TypeScript 구현

추상 기반 클래스가 다음 핸들러를 저장하고 기본 전달 동작을 제공합니다. 각 구체 핸들러는 handle을 오버라이드하고, 스스로 결정할 수 없을 때 super.handle을 호출합니다.

type Refund = { amount: number };
abstract class Approver {
  private next?: Approver;
  setNext(next: Approver): Approver {
    this.next = next;
    return next; // allows a.setNext(b).setNext(c)
  }
  handle(req: Refund): string {
    return this.next ? this.next.handle(req) : "rejected: no one can approve " + req.amount;
  }
}
class Agent extends Approver {
  handle(req: Refund): string {
    return req.amount < 50 ? "agent approved " + req.amount : super.handle(req);
  }
}
class TeamLead extends Approver {
  handle(req: Refund): string {
    return req.amount <= 500 ? "team lead approved " + req.amount : super.handle(req);
  }
}
class Manager extends Approver {
  handle(req: Refund): string {
    return req.amount <= 5000 ? "manager approved " + req.amount : super.handle(req);
  }
}
const chain = new Agent();
chain.setNext(new TeamLead()).setNext(new Manager());
for (const amount of [20, 300, 2000, 9000]) console.log(chain.handle({ amount }));
// agent approved 20
// team lead approved 300
// manager approved 2000
// rejected: no one can approve 9000

일반 함수로 만드는 미들웨어 형태

TypeScript에서는 클래스가 꼭 필요하지 않습니다. next 콜백을 받는 함수들의 목록이 곧 체인입니다. Express와 Koa 미들웨어가 이렇게 동작합니다.

type Req = { user?: string; path: string };
type Middleware = (req: Req, next: () => string) => string;
const auth: Middleware = (req, next) => (req.user ? next() : "401 Unauthorized");
const log: Middleware = (req, next) => {
  console.log("request", req.path);
  return next();
};
const route = (req: Req) => "200 OK " + req.path;
function run(chain: Middleware[], req: Req): string {
  const step = (i: number): string =>
    i < chain.length ? chain[i](req, () => step(i + 1)) : route(req);
  return step(0);
}
console.log(run([log, auth], { path: "/admin" }));
console.log(run([log, auth], { user: "ana", path: "/admin" }));
// request /admin
// 401 Unauthorized
// request /admin
// 200 OK /admin

언제 쓰고 언제 쓰지 말아야 할까

  • 요청을 처리할 수 있는 객체가 여럿이고 올바른 객체를 실행 시점에 결정해야 할 때 사용합니다.
  • 검증 단계, 승인 단계, HTTP 미들웨어처럼 핸들러의 구성이나 순서를 설정할 수 있어야 할 때 사용합니다.
  • 각 규칙을 따로 테스트할 수 있는 작은 단위로 분리하고 싶을 때 사용합니다.
  • 바뀌지 않는 두세 개의 고정된 검사에는 사용하지 않습니다. 단순한 if 문이 더 명확합니다.
  • 체인이 길면 호출 지점만 봐서는 어떤 핸들러가 최종적으로 동작했는지 알기 어려워 디버깅이 힘들다는 점에 유의하세요. 핸들러 안에서 로그를 남기면 도움이 됩니다.
  • 체인은 순서에 민감합니다. 예를 들어 인증을 라우트 핸들러 뒤에 두면 인증의 의미가 없어집니다.

실제 사용 사례

  • Express와 Koa 미들웨어: 각 함수가 요청과 next 함수를 받아 계속 진행할지 결정합니다.
  • DOM 이벤트 전파: 이벤트가 대상 요소에서 조상 요소로 버블링되며, 어떤 리스너든 event.stopPropagation()으로 이를 멈출 수 있습니다.
  • Java 서블릿 필터는 필터 체인을 이루며, 각 필터가 chain.doFilter를 호출해 요청을 다음으로 넘깁니다.
  • 로깅 프레임워크는 로그 레코드를 로거에서 부모 로거와 그 핸들러로 전달합니다.

관련 패턴

패턴책임 연쇄 패턴과의 차이
데코레이터데코레이터도 래퍼의 체인을 이루지만 모든 데코레이터가 항상 실행되고 전달합니다. 핸들러는 체인을 중단할 수 있습니다.
커맨드체인을 따라 흐르는 요청은 커맨드 객체인 경우가 많습니다. 커맨드는 요청을 객체로 포장하는 것이 목적이고, 체인은 누가 처리할지 찾는 것이 목적입니다.
컴포지트컴포지트 트리의 부모는 자연스러운 다음 핸들러이며, GUI 이벤트가 컴포넌트 트리를 따라 올라가는 방식이 이것입니다.
중재자중재자는 수신자를 아는 하나의 중앙 객체를 통해 메시지를 라우팅하고, 체인은 요청을 핸들러에서 다음 핸들러로 넘깁니다.

자주 묻는 질문

체인의 모든 핸들러가 요청을 처리해야 하나요?

아니요. 고전적인 형태에서는 요청을 처리할 수 있는 첫 번째 핸들러가 체인을 끝냅니다. 미들웨어 형태에서는 각 핸들러가 어느 정도 작업한 뒤 다음 핸들러를 호출하므로, 하나의 요청에 여러 핸들러가 작용할 수 있습니다.

Express 미들웨어는 책임 연쇄 패턴의 예인가요?

네. 각 미들웨어 함수는 요청과 next 콜백을 받아, 응답을 끝내거나 next를 호출해 다음 미들웨어로 제어를 넘깁니다.

어떤 핸들러도 요청을 처리하지 않으면 어떻게 되나요?

설계에 따라 다릅니다. 체인 끝에 오류 반환, 예외 발생, 로그 기록 같은 기본 동작을 두어 요청이 조용히 사라지지 않게 하는 것이 좋습니다.

if/else 문의 나열과는 어떻게 다른가요?

로직은 같지만 각 조건이 별도의 객체나 함수가 됩니다. 덕분에 체인을 실행 시점에 구성할 수 있고, 큰 함수 하나를 고치지 않고도 단계를 추가하거나 순서를 바꿀 수 있습니다.

코딩 튜토리얼 전체