이전 글에서는 상태(State) 패턴을 모던 C++ 관점에서 재해석하며, 상속 기반 상태 클래스와 vtable에 의존하지 않고 std::variant, std::visit, Concepts, std::expected, std::format, Coroutines 등을 활용해 더 선언적이고 유지보수성 높은 상태 전이 로직을 구현할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 책임 연쇄(Chain of Responsibility) 패턴을 다룹니다.
책임 연쇄 패턴은 요청(request)이 처리될 수 있는 핸들러(handler)들을 체인 형태로 연결하고, 각 핸들러가 요청을 처리하거나 다음 핸들러로 넘기는 구조를 제안합니다. 전통적으로는 상속 기반 핸들러 클래스 계층을 정의하고, setNextHandler()로 체인을 연결했지만, 모던 C++20 이상에서는 함수 합성, Ranges, std::function, Concepts, std::expected, 그리고 람다를 활용해 훨씬 더 단순하고 유연한 요청 처리 파이프라인을 구축할 수 있습니다.
패턴 소개: 책임 연쇄(Chain of Responsibility)
의도:
- 요청을 처리할 수 있는 여러 핸들러를 연결한 체인을 구성하고, 요청이 들어오면 체인을 따라가며 처리 가능 여부를 검사.
- 핸들러 중 하나가 요청을 처리하면 체인 종료, 모두 실패 시 적절한 조치.
- 예: GUI 이벤트 처리(마우스 이벤트를 여러 위젯이 순서대로 검사), 로깅 필터 체인, 권한 체크 체인 등.
전통적 구현 문제점:
- 핸들러 인터페이스 클래스 + 구체 핸들러 상속 계층 필요.
- 체인 구성 시 setNextHandler() 호출로 객체 연결.
- 에러 처리나 비동기 처리, 다양한 핸들러 동적 추가가 번거로움.
기존 C++ 스타일 구현 (C++11/14/17)
예를 들어, 단순한 요청을 처리하는 Handler 계층:
#include <iostream>
#include <memory>
#include <string>
struct Handler {
virtual ~Handler() = default;
virtual bool handle(const std::string& req) = 0;
void setNext(std::unique_ptr<Handler> n) { next = std::move(n); }
protected:
std::unique_ptr<Handler> next;
};
struct ConcreteHandlerA : 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 ConcreteHandlerB : 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<ConcreteHandlerA>();
auto h2 = std::make_unique<ConcreteHandlerB>();
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";
}
}
고찰:
- 상속 기반 핸들러 클래스, setNext로 체인 연결.
- 새로운 핸들러 추가 시 클래스와 파일 증가.
- 에러 처리나 다양한 처리 결과 반환 불편.
모던 C++20 이상의 개선: 람다, 함수 합성, Ranges, expected
1. Concepts로 Handler 요구사항 정의
Handler가 (const std::string&)를 인자로 받고 std::expected<bool,std::string>를 반환한다고 가정해봅시다.
std::expected<bool,std::string>는 처리 성공 시 true/false 반환, 실패나 에러 사유 시 std::unexpected(...) 반환 가능.
#include <concepts>
#include <string>
#include <expected>
template<typename H>
concept HandlerConcept = requires(H& h, const std::string& req) {
{ h(req) } -> std::convertible_to<std::expected<bool,std::string>>;
};
2. 람다로 핸들러 정의
상속 없이도 람다로 핸들러 정의 가능:
auto HandlerA = [](const std::string& req) -> std::expected<bool,std::string> {
if (req == "A") {
std::cout << "Handled by A\n";
return true; // 처리 성공
}
return false; // 다음 핸들러로 넘어감
};
auto HandlerB = [](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(HandlerA)>);
3. 체인을 함수 합성으로 구현
체인을 상속과 setNext 호출 대신, 함수 합성을 통해 구축할 수 있습니다.
makeChain 헬퍼를 통해 핸들러 리스트를 받아 하나의 함수로 결합:
#include <functional>
#include <vector>
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 std::unexpected(res.error());
}
if(*res) {
// 처리 성공 시 체인 종료
return true;
}
}
// 아무도 처리 안 함
return false;
};
}
고찰:
- std::function 벡터로 핸들러들을 관리, for 루프로 순회.
- 첫 성공 시 반환, 모두 실패 시 false 반환.
- std::expected로 에러 발생 시 에러 메시지 전달.
4. Ranges로 핸들러 파이프라인 구성
핸들러를 미리 정의한 뒤, Ranges 알고리즘을 통해 필터링하거나 transform 할 수 있습니다. 예를 들어, 특정 조건에 따라 핸들러 목록 생성 가능.
#include <ranges>
#include <algorithm>
std::vector handlers = {
std::function(std::move(HandlerA)),
std::function(std::move(HandlerB))
};
// 바로 chain 생성
auto chain = makeChain(handlers);
나중에 조건에 따라 다른 핸들러를 추가하거나 제거할 때, Ranges로 필터링 가능:
// 예: 특정 조건 충족하는 핸들러만 chain에 넣기
auto filteredHandlers = handlers
| std::views::filter([](auto& h){ /*조건 판단*/ return true; })
| std::views::common; // Ranges에서 std::vector 변환 필요 시 common_view
auto filteredChain = makeChain(std::vector(filteredHandlers.begin(), filteredHandlers.end()));
고찰:
- Ranges로 동적 구성을 파이프라인 형태로 처리.
- 상속 없이 조건부 핸들러 구성 가능.
5. std::format으로 디버그 메시지, std::expected로 에러 처리 개선
디버그나 로깅 시 std::format 사용:
#include <format>
auto LoggingHandler = [](auto baseHandler) {
return [=](const std::string& req) -> std::expected<bool,std::string> {
std::cout << std::format("Handling request: {}\n", req);
auto res = baseHandler(req);
if(!res) {
std::cout << std::format("Error: {}\n", res.error());
} else if(*res) {
std::cout << "Request handled.\n";
} else {
std::cout << "Not handled, move on.\n";
}
return res;
};
};
고찰:
- Handler를 람다로 감싸 로깅 추가 (데코레이터와 유사).
- 상속 없이 기능 추가 가능.
6. Coroutines로 비동기 요청 처리
C++20 coroutine을 사용하면 요청 처리를 비동기적으로 수행하는 핸들러를 구현할 수 있습니다.
예: 네트워크 I/O 기반 핸들러가 co_await를 통해 비동기 처리 후 결과 반환.
// 가상의 예: co_handleRequest(req) -> co_await 비동기 I/O
// chain에서 co_await로 체인 순회, 첫 성공 시 co_return
고찰:
- 비동기 처리 시 coroutine과 Concepts로 비동기 핸들러 제약, 더 복잡한 시나리오에도 대응.
비교 및 분석
- 전통적 구현(C++11 전후):
- Handler 인터페이스 + 구체 핸들러 상속 필요
- setNextHandler()로 체인 연결, 클래스 증가
- 에러 처리, 비동기 처리 복잡, 확장성 한계
- 모던 C++(C++20 이상):
- Concepts로 Handler 조건 명시, 상속 불필요
- 람다, 함수 합성, std::function으로 체인을 쉽게 구성
- std::expected로 에러 처리 명확, std::format으로 로깅 향상
- Ranges로 조건부 핸들러 리스트 구성, coroutine으로 비동기 처리까지 가능
- 클래스 수 감소, 타입 안전성, 유지보수성, 확장성 향상
결국 모던 C++에서는 책임 연쇄 패턴을 함수 파이프라인 형태로 재해석해, 상속 기반 설계 없이도 동적 요청 처리 체인을 간결하고 타입 안전하게 구성할 수 있습니다.
마무리
책임 연쇄 패턴은 요청을 처리할 핸들러들을 체인 형태로 연결하여 유연한 요청 처리를 가능하게 하나, 전통적 구현은 상속과 클래스 증가 문제를 안고 있었습니다. 모던 C++20 이상에서는 함수 합성, 람다, Concepts, std::expected, std::format, Ranges, coroutine 등 다양한 언어 기능을 통해 상속 없이도 유연하고 유지보수성 높은 요청 처리 체인을 구성할 수 있습니다.
다음 글에서는 행동 패턴 중 다른 패턴(예: 커맨드(Command), 인터프리터(Interpreter), 이터레이터(Iterator), 중재자(Mediator), 메멘토(Memento), 템플릿 메서드(Template Method), 방문자(Visitor))를 모던 C++로 어떻게 재구성할 수 있는지 이어서 탐구하겠습니다.