C/C++ 디자인 패턴 (13) — 책임 연쇄(Chain of Responsibility)

HTTP 요청 하나를 처리하기 전에 차단 IP 확인, 인증, 정적 파일 응답, 애플리케이션 라우팅을 차례로 거쳐야 한다. 이를 함수 하나의 if-else로 늘어놓으면 단계를 추가하거나 순서를 바꿀 때마다 그 함수를 고쳐야 하고, 각 단계를 따로 테스트하기도 어렵다. 책임 연쇄(Chain of Responsibility)는 요청을 처리할 수 있는 객체들을 사슬로 연결하고, 각 객체가 요청을 직접 처리하거나 다음 객체로 넘기게 해서 보내는 쪽과 받는 쪽의 결합을 끊는다. 이 글에서는 고전형 미들웨어 사슬, next()를 받는 함수 사슬, GoF의 원래 예인 GUI 도움말 버블링, 그리고 C로 만든 리눅스 커널 notifier chain 축소판을 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
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)를 다룬다.

참고

답글 남기기