HTTP 요청 하나를 처리하기 전에 차단 IP 확인, 인증, 정적 파일 응답, 애플리케이션 라우팅을 차례로 거쳐야 한다. 이를 함수 하나의 if-else로 늘어놓으면 단계를 추가하거나 순서를 바꿀 때마다 그 함수를 고쳐야 하고, 각 단계를 따로 테스트하기도 어렵다. 책임 연쇄(Chain of Responsibility)는 요청을 처리할 수 있는 객체들을 사슬로 연결하고, 각 객체가 요청을 직접 처리하거나 다음 객체로 넘기게 해서 보내는 쪽과 받는 쪽의 결합을 끊는다. 이 글에서는 고전형 미들웨어 사슬, next()를 받는 함수 사슬, GoF의 원래 예인 GUI 도움말 버블링, 그리고 C로 만든 리눅스 커널 notifier chain 축소판을 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (11) — 플라이웨이트(Flyweight)
- C/C++ 디자인 패턴 (12) — 프록시(Proxy)
- C/C++ 디자인 패턴 (14) — 커맨드(Command)
- C/C++ 디자인 패턴 (15) — 반복자(Iterator)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Handler | 요청 처리 인터페이스, 다음 처리자 링크 | Handler(setNext, handle) |
| ConcreteHandler | 처리할 수 있으면 처리, 아니면 다음으로 전달 | RateLimit, Auth, StaticFiles, App |
| Client | 사슬의 첫 처리자에게 요청 | main() |
고전형: 다음 처리자 링크
// classic.cpp — 요청을 처리할 수 있는 객체가 나올 때까지 사슬을 따라 넘긴다
#include <iostream>
#include <memory>
#include <set>
#include <string>
struct Request { std::string path, token, ip; };
struct Response { int status; std::string body; };
class Handler {
public:
virtual ~Handler() = default;
Handler &setNext(std::unique_ptr<Handler> n) { next_ = std::move(n); return *next_; }
virtual Response handle(const Request &r)
{
if (next_)
return next_->handle(r); // 내가 처리하지 않으면 다음으로
return {404, "no handler"};
}
private:
std::unique_ptr<Handler> next_;
};
struct Auth : Handler {
Response handle(const Request &r) override
{
if (r.path.starts_with("/admin") && r.token != "secret")
return {401, "unauthorized"}; // 여기서 끊는다
return Handler::handle(r);
}
};
struct RateLimit : Handler {
std::set<std::string> banned = {"10.0.0.66"};
Response handle(const Request &r) override
{
if (banned.count(r.ip))
return {429, "too many requests"};
return Handler::handle(r);
}
};
struct StaticFiles : Handler {
Response handle(const Request &r) override
{
if (r.path.ends_with(".css"))
return {200, "static " + r.path};
return Handler::handle(r);
}
};
struct App : Handler {
Response handle(const Request &r) override
{
if (r.path == "/admin" || r.path == "/")
return {200, "app " + r.path};
return Handler::handle(r);
}
};
int main()
{
auto chain = std::make_unique<RateLimit>();
chain->setNext(std::make_unique<Auth>())
.setNext(std::make_unique<StaticFiles>())
.setNext(std::make_unique<App>());
Request reqs[] = {
{"/", "", "1.2.3.4"}, {"/site.css", "", "1.2.3.4"}, {"/admin", "", "1.2.3.4"},
{"/admin", "secret", "1.2.3.4"}, {"/", "", "10.0.0.66"}, {"/missing", "", "1.2.3.4"},
};
for (const auto &r : reqs) {
auto res = chain->handle(r);
std::cout << r.path << " (" << r.ip << ") -> " << res.status << ' ' << res.body << '\n';
}
}$ g++ -std=c++20 -Wall -Wextra -O2 -o classic classic.cpp && ./classic
/ (1.2.3.4) -> 200 app /
/site.css (1.2.3.4) -> 200 static /site.css
/admin (1.2.3.4) -> 401 unauthorized
/admin (1.2.3.4) -> 200 app /admin
/ (10.0.0.66) -> 429 too many requests
/missing (1.2.3.4) -> 404 no handler
요청마다 멈춘 위치가 다르다. 차단 IP는 첫 단계에서, 토큰 없는 관리자 요청은 두 번째 단계에서 끝나고, 아무도 처리하지 않은 요청은 사슬 끝의 기본 응답(404)으로 떨어진다.
next()를 받는 함수 사슬
Express·Koa 같은 웹 프레임워크의 미들웨어는 처리자에게 “다음을 호출하는 함수”를 넘긴다. 처리자가 next() 전후에 코드를 둘 수 있어서 로깅, 시간 측정, 예외 변환처럼 요청을 감싸는 처리가 자연스럽다.
// middleware.cpp — next()를 인자로 받는 함수 사슬: 앞뒤로 감싸는 처리가 가능하다
#include <chrono>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
struct Ctx { std::string path; int status = 0; std::string body; };
using Next = std::function<void()>;
using Middleware = std::function<void(Ctx &, Next)>;
void run(const std::vector<Middleware> &chain, Ctx &c, std::size_t i = 0)
{
if (i < chain.size())
chain[i](c, [&] { run(chain, c, i + 1); });
}
int main()
{
std::vector<Middleware> chain = {
[](Ctx &c, Next next) { // 로깅: 앞뒤 모두
auto t0 = std::chrono::steady_clock::now();
std::cout << "--> " << c.path << '\n';
next();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::steady_clock::now() - t0).count();
std::cout << "<-- " << c.status << ' ' << c.path << " (" << (us < 1000 ? "<1ms" : ">1ms") << ")\n";
},
[](Ctx &c, Next next) { // 에러를 응답으로 변환
try { next(); }
catch (const std::exception &e) { c.status = 500; c.body = e.what(); }
},
[](Ctx &c, Next next) { // 라우팅: next를 안 부르면 끝
if (c.path == "/health") { c.status = 200; c.body = "ok"; return; }
next();
},
[](Ctx &c, Next) { // 마지막 처리자
if (c.path == "/boom") throw std::runtime_error("db down");
c.status = 404; c.body = "not found";
},
};
for (std::string p : {"/health", "/boom", "/nope"}) {
Ctx c;
c.path = p;
run(chain, c);
std::cout << " body: " << c.body << '\n';
}
}$ g++ -std=c++20 -Wall -Wextra -O2 -o middleware middleware.cpp && ./middleware
--> /health
<-- 200 /health (<1ms)
body: ok
--> /boom
<-- 500 /boom (<1ms)
body: db down
--> /nope
<-- 404 /nope (<1ms)
body: not found
마지막 처리자가 던진 예외를 두 번째 미들웨어가 500 응답으로 바꿨고, 첫 번째 미들웨어는 어느 경우든 응답 로그를 남겼다. /health는 라우팅 단계에서 next()를 부르지 않아 마지막 처리자까지 가지 않았다.
GUI 도움말 버블링
GoF가 든 원래 예다. 도움말이 없는 위젯은 요청을 부모에게 넘기고, 사슬은 객체 트리의 부모 링크를 그대로 쓴다.
// bubbling.cpp — GUI 도움말 요청이 위젯에서 부모로 거슬러 올라간다(GoF 원래 예)
#include <iostream>
#include <optional>
#include <string>
class Widget {
public:
Widget(std::string name, Widget *parent, std::optional<std::string> help = std::nullopt)
: name_(std::move(name)), parent_(parent), help_(std::move(help)) {}
void showHelp(int depth = 0) const
{
std::cout << std::string(depth * 2, ' ') << "ask " << name_ << '\n';
if (help_)
std::cout << std::string(depth * 2, ' ') << "=> " << *help_ << '\n';
else if (parent_)
parent_->showHelp(depth + 1); // 모르면 부모에게
else
std::cout << "=> (no help available)\n";
}
private:
std::string name_;
Widget *parent_;
std::optional<std::string> help_;
};
int main()
{
Widget window("PrintDialog", nullptr, "프린터와 매수를 고른다");
Widget panel("OptionsPanel", &window);
Widget ok("OkButton", &panel, "인쇄를 시작한다");
Widget copies("CopiesSpin", &panel);
ok.showHelp();
copies.showHelp();
Widget orphan("Tooltip", nullptr);
orphan.showHelp();
}$ g++ -std=c++20 -Wall -Wextra -O2 -o bubbling bubbling.cpp && ./bubbling
ask OkButton
=> 인쇄를 시작한다
ask CopiesSpin
ask OptionsPanel
ask PrintDialog
=> 프린터와 매수를 고른다
ask Tooltip
=> (no help available)
브라우저 DOM 이벤트 버블링, Qt의 이벤트 전파도 같은 구조다. 처리 여부를 반환값이나 accept()로 표시해 전파를 멈춘다.
C 구현: 커널 notifier chain 축소판
리눅스 커널의 notifier chain은 C로 된 책임 연쇄다. 콜백 블록을 우선순위 순으로 연결하고, 콜백이 NOTIFY_STOP_MASK 비트가 켜진 값을 반환하면 이후 콜백을 부르지 않는다. 상수 값과 구조는 커널 include/linux/notifier.h와 같게 맞췄다.
/* notifier.c — 리눅스 커널 notifier chain을 축소한 C 책임 연쇄: 우선순위 정렬 + 중단 비트 */
#include <stdio.h>
#define NOTIFY_DONE 0x0000
#define NOTIFY_OK 0x0001
#define NOTIFY_STOP_MASK 0x8000
#define NOTIFY_BAD (NOTIFY_STOP_MASK | 0x0002)
struct notifier_block {
int (*call)(struct notifier_block *nb, unsigned long event, void *data);
struct notifier_block *next;
int priority; /* 높을수록 먼저 */
const char *name;
};
static void chain_register(struct notifier_block **head, struct notifier_block *n)
{
while (*head && (*head)->priority >= n->priority)
head = &(*head)->next;
n->next = *head;
*head = n;
}
static int call_chain(struct notifier_block *nb, unsigned long event, void *data, int *nr_calls)
{
int ret = NOTIFY_DONE;
for (; nb; nb = nb->next) {
ret = nb->call(nb, event, data);
(*nr_calls)++;
if (ret & NOTIFY_STOP_MASK) /* 누군가 거부하면 사슬을 멈춘다 */
break;
}
return ret;
}
enum { EV_SUSPEND = 1 };
static int disk_cb(struct notifier_block *nb, unsigned long ev, void *data)
{
(void)ev; (void)data;
printf(" [%d] %s: flush cache\n", nb->priority, nb->name);
return NOTIFY_OK;
}
static int net_cb(struct notifier_block *nb, unsigned long ev, void *data)
{
int busy = *(int *)data;
(void)ev;
printf(" [%d] %s: %s\n", nb->priority, nb->name, busy ? "transfer in progress, veto" : "down link");
return busy ? NOTIFY_BAD : NOTIFY_OK;
}
static int log_cb(struct notifier_block *nb, unsigned long ev, void *data)
{
(void)ev; (void)data;
printf(" [%d] %s: write suspend marker\n", nb->priority, nb->name);
return NOTIFY_DONE; /* 관심 없음과 같은 의미로 그냥 통과 */
}
int main(void)
{
struct notifier_block *chain = NULL;
struct notifier_block log = { log_cb, NULL, 0, "log" };
struct notifier_block disk = { disk_cb, NULL, 10, "disk" };
struct notifier_block net = { net_cb, NULL, 5, "net" };
chain_register(&chain, &log); /* 등록 순서와 무관하게 우선순위로 정렬 */
chain_register(&chain, &disk);
chain_register(&chain, &net);
for (int busy = 0; busy <= 1; busy++) {
int calls = 0;
printf("suspend (net busy=%d)\n", busy);
int ret = call_chain(chain, EV_SUSPEND, &busy, &calls);
printf(" -> %s after %d callbacks\n", (ret & NOTIFY_STOP_MASK) ? "VETOED" : "ok", calls);
}
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o notifier notifier.c && ./notifier
suspend (net busy=0)
[10] disk: flush cache
[5] net: down link
[0] log: write suspend marker
-> ok after 3 callbacks
suspend (net busy=1)
[10] disk: flush cache
[5] net: transfer in progress, veto
-> VETOED after 2 callbacks
등록 순서(log, disk, net)와 무관하게 우선순위 순으로 호출됐고, 네트워크 전송 중에는 net 콜백이 거부하면서 log 콜백이 호출되지 않았다. 커널에서는 절전, 재부팅, CPU 핫플러그, 네트워크 장치 이벤트가 이 방식으로 전달된다.
변형 비교
| 변형 | 멈추는 조건 | 예 |
|---|---|---|
| 첫 처리자만 처리 | 누군가 처리하면 끝 | GoF 원형, 도움말 버블링 |
| 모두 통과, 누구나 거부 가능 | 거부(STOP)할 때만 끝 | notifier chain, 인증·차단 미들웨어 |
감싸기(next()) | next()를 안 부르면 끝 | 웹 미들웨어, 인터셉터 |
주의사항
- 처리 보장이 없다: 사슬 끝까지 아무도 처리하지 않을 수 있다. 예제처럼 기본 처리자(404, “no help”)를 두거나, 처리 여부를 반환값으로 확인한다.
- 순서가 곧 정책이다: 인증보다 정적 파일을 먼저 두면 보호해야 할 파일이 노출될 수 있다. 사슬 구성을 한 곳에 모으고 순서를 테스트한다.
- 디버깅이 어렵다: 요청이 어디서 멈췄는지 코드만 봐서는 알기 어렵다. 위의 로깅 미들웨어처럼 사슬 맨 앞에서 결과를 기록한다.
- 재귀 깊이:
next()방식은 처리자 수만큼 호출 스택이 깊어진다. 처리자가 수천 개라면 반복문으로 순회하는 고전형이 안전하다. - notifier chain의 동시성: 커널은 호출 문맥에 따라 atomic(RCU, 잠들 수 없음), blocking(rwsem), raw(호출자가 동기화), SRCU 네 종류를 나눠 쓴다. 예제는 동기화가 없는 raw에 해당한다.
마무리
책임 연쇄는 “누가 처리할지 모르는 요청”을 위한 패턴이다. 보내는 쪽은 사슬의 첫 고리만 알고, 처리자는 자기 일만 판단한다. 다음 글에서는 요청 자체를 객체로 만드는 커맨드(Command)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Chain of Responsibility
- Linux — include/linux/notifier.h