이전 글까지 우리는 대부분의 GoF 패턴을 모던 C++ 관점에서 재해석해왔습니다. 이번에는 행동(Behavioral) 패턴 중 아직 다루지 않은 책임 연쇄(Chain of Responsibility) 패턴에 주목합니다.
책임 연쇄 패턴은 한 요청이 처리될 수 있는 핸들러를 체인 형태로 연결하고, 요청이 들어오면 체인을 따라가며 처리 가능 한 핸들러를 찾는 구조를 만듭니다. 전통적으로는 핸들러 인터페이스와, setNextHandler() 메서드를 통해 다음 핸들러를 연결하는 구조를 취했지만, 이는 상속 기반 설계로 인한 클래스 증가와 유지보수 어려움을 야기합니다.
C++20 이상에서는 Concepts로 요청/응답 인터페이스를 제약하고, 람다와 함수 합성으로 핸들러를 정의해 상속 없이 체인을 구성할 수 있습니다. std::expected로 에러 처리 명확화, std::format으로 로깅, coroutine으로 비동기 처리, Ranges로 조건부 처리 등 다양한 요구사항에도 쉽게 대응할 수 있어 전통적 구현 대비 훨씬 유지보수성과 확장성이 뛰어납니다.
패턴 소개: 책임 연쇄(Chain of Responsibility)
의도:
- 요청을 처리할 수 있는 다수의 핸들러를 체인으로 연결하고, 요청이 들어오면 체인 상에서 처리 가능한 핸들러를 찾거나, 처리 가능한 핸들러가 없으면 요청을 처리하지 않고 끝냄.
- 예: GUI 이벤트 처리(마우스 이벤트를 부모 위젯으로 전파), 권한 체크 체인, 로깅 필터 체인 등.
전통적 구현 문제점:
- Handler 인터페이스 + 구체 핸들러 클래스 상속 필요
- setNextHandler() 호출로 체인 연결
- 새로운 핸들러나 조건부 로직, 비동기 처리, 에러 처리, 로깅 등 추가 시 클래스 증가 및 복잡성 증가
기존 C++ 스타일 구현 (전통적 방식)
단순 예: 요청이 "A"일 때 처리하는 핸들러와, "B"일 때 처리하는 핸들러로 구성된 체인:
#include <iostream>
#include <memory>
#include <string>
struct Handler {
virtual ~Handler()=default;
void setNext(std::unique_ptr<Handler> n) { next = std::move(n); }
virtual bool handle(const std::string& req)=0;
protected:
std::unique_ptr<Handler> next;
};
struct AHandler : Handler {
bool handle(const std::string& req) override {
if (req=="A") {
std::cout << "Handled by A\n";
return true;
}
return next ? next->handle(req) : false;
}
};
struct BHandler : Handler {
bool handle(const std::string& req) override {
if (req=="B") {
std::cout << "Handled by B\n";
return true;
}
return next ? next->handle(req) : false;
}
};
int main() {
auto h1 = std::make_unique<AHandler>();
auto h2 = std::make_unique<BHandler>();
h1->setNext(std::move(h2));
if(!h1->handle("A")) std::cout << "No handler for A\n";
if(!h1->handle("C")) std::cout << "No handler for C\n";
}
문제점:
- Handler 인터페이스, AHandler, BHandler 클래스 필요
- 조건부 처리, 비동기 처리, 로깅, 에러 처리 구현 시 클래스 증가 및 복잡
모던 C++20 이상 개선: 람다, Concepts, std::expected
1. Concepts로 핸들러 요구사항 정의
핸들러: (const std::string&) -> std::expected<bool,std::string> 형태로 요청 처리. 성공 처리 시 true 반환, 처리 불가능 시 false, 에러 시 std::unexpected.
template<typename H>
concept HandlerConcept = requires(H& h, const std::string& req) {
{ h(req) } -> std::convertible_to<std::expected<bool,std::string>>;
};
2. 람다로 핸들러 정의
auto AHandlerLambda = [](const std::string& req)->std::expected<bool,std::string> {
if(req=="A") {
std::cout << "Handled by A\n";
return true;
}
return false; // 처리 불가능 시 false
};
auto BHandlerLambda = [](const std::string& req)->std::expected<bool,std::string> {
if(req=="B") {
std::cout << "Handled by B\n";
return true;
}
return false;
};
static_assert(HandlerConcept<decltype(AHandlerLambda)>);
3. 체인을 함수 합성으로 구현
여러 핸들러를 벡터나 배열로 관리 후 순회, 첫 성공 처리 시 반환:
#include <vector>
#include <functional>
auto makeChain = [](std::vector<std::function<std::expected<bool,std::string>(const std::string&)>> handlers) {
return [=](const std::string& req)->std::expected<bool,std::string> {
for (auto& h : handlers) {
auto res = h(req);
if(!res) { // 에러 발생 시 즉시 반환
return res;
}
if(*res) { // true 시 처리 완료
return true;
}
}
// 모두 처리 불가능 시 false
return false;
};
};
// 체인 구성
auto chain = makeChain({
AHandlerLambda,
BHandlerLambda
});
// 사용 예
int main() {
auto resA = chain("A"); // Handled by A
if(!resA) std::cerr << "Error: " << resA.error() << "\n";
else if(!*resA) std::cout << "No handler for A\n";
auto resC = chain("C");
if(!resC) std::cerr << "Error: " << resC.error() << "\n";
else if(!*resC) std::cout << "No handler for C\n";
}
비교:
- 전통적: Handler 클래스 상속 필요, setNextHandler 필요
- C++20: 람다와 벡터로 체인 구성, std::expected로 에러 처리 명확
조건부 처리, 로깅, 비동기, Ranges 적용
조건부 처리(특정 요청 무시):
auto conditionalHandler = [&](auto baseHandler) {
return [=](const std::string& req)->std::expected<bool,std::string> {
if(req.empty()) return std::unexpected("Empty request");
return baseHandler(req);
};
};
auto safeAHandler = conditionalHandler(AHandlerLambda);
auto safeChain = makeChain({safeAHandler, BHandlerLambda});
로깅(std::format):
auto loggingHandler = [&](auto baseHandler) {
return [=](const std::string& req)->std::expected<bool,std::string> {
std::cout << std::format("[LOG] Handling request: {}\n", req);
auto res = baseHandler(req);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else if(*res) std::cout << "[LOG] Handled.\n";
else std::cout << "[LOG] Not handled.\n";
return res;
};
};
auto loggedChain = makeChain({loggingHandler(AHandlerLambda),loggingHandler(BHandlerLambda)});
비동기 처리: coroutine으로 비동기 핸들러 구현 가능(가상의 예):
// co_handler(...) : co_await 비동기 I/O, 완료 후 std::expected<bool,std::string> 반환
Ranges: 여러 요청을 리스트로 받아 파이프라인 처리:
#include <vector>
#include <ranges>
std::vector<std::string> requests = {"A","B","C"};
for (auto res : requests | std::views::transform(loggedChain)) {
if(!res) std::cerr << "Error in one request: " << res.error() << "\n";
}
비교:
- 전통적: 새로운 조건부 로직, 로깅, 비동기 기능 추가 시 클래스로 Decorator 필요
- C++20: 람다 합성으로 기능 추가, coroutine으로 비동기, Ranges로 파이프라인 처리, std::expected로 에러 처리 등 유연성↑
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Handler 인터페이스, 구체 핸들러 클래스로 체인 구성
- setNextHandler()로 체인 연결, 클래스 증가
- 조건부 로직, 비동기 처리, 로깅, 에러 처리 등 추가 시 복잡성 증가
C++11/14/17:
- 람다, std::function으로 일부 개선 가능
- Concepts, std::expected, coroutine, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 어렵
C++20 이상(모던 C++):
- Concepts로 Handler 요구사항 정의, 상속 없이 인터페이스 제약
- 람다와 std::function으로 핸들러 체인 구성, std::expected로 에러 처리, std::format으로 로깅
- coroutine으로 비동기 처리, Ranges로 요청 리스트 파이프라인 처리
- 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상
결론
프록시(Proxy) 패턴처럼, 책임 연쇄(Chain of Responsibility) 패턴도 전통적 구현에서는 핸들러 클래스 상속 계층과 setNextHandler() 호출로 인해 클래스 증가, 유지보수 어려움이 있었습니다. C++20 이상에서는 람다, Concepts, std::expected, coroutine, Ranges, std::format 등을 결합해 상속 없이도 요청 처리 체인을 함수 파이프라인 형태로 구현할 수 있습니다. 조건부 처리, 비동기 처리, 로깅, 에러 처리 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.