이전 글에서는 이터레이터(Iterator) 패턴을 모던 C++ 관점에서 재해석하며, C++20 Ranges와 Concepts를 통해 상속 없이도 선언적이고 타입 안전한 순회 방식을 구현할 수 있음을 확인했습니다. 이번에는 행동(Behavioral) 패턴 중 미디에이터(Mediator) 패턴을 다룹니다.
미디에이터 패턴은 객체들이 서로 직접 통신하는 대신, 미디에이터(중재자)를 통해 상호작용하도록 하여 객체 간 결합을 느슨하게 만드는 패턴입니다. 전통적으로는 미디에이터 인터페이스와 구체 미디에이터를 상속 기반으로 정의하고, 각 객체가 미디에이터를 참조해 메시지나 이벤트를 중개하는 구조를 사용했습니다. 그러나 모던 C++20 이상에서는 Concepts, 람다, std::function, std::expected, coroutine, Ranges 등을 활용해 상속 없이도 객체 간 상호작용을 선언적으로 구성하고, 에러 처리나 비동기 처리를 쉽게 할 수 있습니다.
패턴 소개: 미디에이터(Mediator)
의도:
- 객체들이 서로 직접 연결되지 않고, 미디에이터(중재자)를 통해 상호작용하게 함으로써 상호 결합을 줄이고, 상호작용 로직을 한 곳에 집중 관리.
- 예: 채팅방에서 참가자 간 메시지를 주고받을 때, 채팅방 객체(미디에이터)가 모든 메시지를 중개.
- 전통적 구현에서 미디에이터 인터페이스와 구체 미디에이터 클래스, 그리고 각 참가자 객체가 미디에이터 참조.
전통적 구현 문제점:
- 미디에이터 인터페이스, 구체 미디에이터 클래스, 그리고 상호작용할 객체 모두 상속 기반으로 구조화.
- 새로운 상호작용 규칙 추가 시 클래스나 메서드 증가, 에러 처리나 비동기 처리 어려움.
기존 C++ 스타일 구현 (전통적 방식)
예를 들어, 채팅방(ChatRoom) 미디에이터와 User 객체:
#include <iostream>
#include <string>
#include <vector>
struct User;
struct Mediator {
virtual ~Mediator() = default;
virtual void sendMessage(const std::string& msg, User* sender) = 0;
};
struct User {
std::string name;
Mediator* mediator;
User(std::string n, Mediator* m) : name(std::move(n)), mediator(m) {}
void send(const std::string& msg) {
mediator->sendMessage(msg, this);
}
virtual void receive(const std::string& msg) {
std::cout << name << " received: " << msg << "\n";
}
};
struct ChatRoom : Mediator {
std::vector<User*> users;
void addUser(User* u) { users.push_back(u); }
void sendMessage(const std::string& msg, User* sender) override {
for (auto u : users) {
if (u != sender) u->receive(msg);
}
}
};
int main() {
ChatRoom room;
User alice("Alice", &room);
User bob("Bob", &room);
room.addUser(&alice);
room.addUser(&bob);
alice.send("Hello");
bob.send("Hi Alice");
}
문제점:
- Mediator 인터페이스, ChatRoom 클래스 필요
- User는 Mediator 의존, 상속 구조로 확장하면 클래스 증가
- 에러 처리나 비동기 메시지 전송, 조건부 필터링, 로깅 등 기능 추가 시 복잡성 증가
모던 C++20 이상의 개선: 람다, Concepts, std::function, std::expected, coroutine
1. Concepts로 Mediator 요구사항 정의
미디에이터가 sendMessage(msg, senderName) 형태로 메시지 전달하고 std::expected<void,std::string> 반환한다고 가정:
template<typename M>
concept MediatorConcept = requires(M& m, const std::string& msg, const std::string& sender) {
{ m.sendMessage(msg, sender) } -> std::convertible_to<std::expected<void,std::string>>;
};
2. 람다나 함수 객체로 Mediator 구현
채팅방을 상속 없이 람다나 함수 객체로 구현 가능. Users 리스트를 vector로 관리하고, 메시지 전송 시 모든 유저에게 전달:
#include <map>
#include <functional>
struct User2 {
std::string name;
std::function<std::expected<void,std::string>(const std::string&)> receiveFunc;
};
// Mediator를 lambda로 구현: users를 map으로 관리, sendMessage 호출 시 해당 map으로 브로드캐스트
auto ChatRoomLambda = [usersMap = std::map<std::string, User2>()]
(auto action) mutable {
return [&](const std::string& msg, const std::string& sender) -> std::expected<void,std::string> {
// action: 사용자 정의 람다로 메시지 전송 로직
return action(usersMap, msg, sender);
};
};
이건 조금 복잡하니, 조금 더 단순화하자. Mediator를 함수 하나로만 구현해도 된다.
struct ChatContext {
std::map<std::string, User2> users;
};
auto ChatRoomMediator = [](ChatContext& ctx) {
return [&](const std::string& msg, const std::string& sender) -> std::expected<void,std::string> {
if (ctx.users.find(sender) == ctx.users.end()) return std::unexpected("Sender not in chat");
for (auto& [name, user] : ctx.users) {
if (name != sender) {
auto res = user.receiveFunc(msg);
if(!res) return res; // 첫 에러 시 중단
}
}
return {};
};
};
static_assert(MediatorConcept<decltype(ChatRoomMediator(std::declval<ChatContext&>()))>);
설명:
- ChatContext로 유저 관리, ChatRoomMediator 람다로 메시지 전송 로직 구현
- 상속 없이 Mediator 기능 표현 가능
- std::expected로 에러 처리 명확화(예: sender 없는 경우 에러 반환)
3. User 정의: receiveFunc로 메시지 처리
auto makeUser = [](std::string name) {
return User2{name, [name](const std::string& msg) -> std::expected<void,std::string> {
std::cout << std::format("{} received: {}\n", name, msg);
return {};
}};
};
비교:
- 전통적: User는 Mediator* 포인터로 Mediator에 의존, receive() 가상 함수 오버라이드
- C++20: User는 단순히 receiveFunc 람다로 메시지 처리 로직 보유, Mediator는 별개로 관리
4. 사용 예
int main() {
ChatContext ctx;
ctx.users["Alice"] = makeUser("Alice");
ctx.users["Bob"] = makeUser("Bob");
auto mediator = ChatRoomMediator(ctx);
// Alice가 "Hello" 전송
auto res = mediator("Hello", "Alice");
if(!res) std::cerr << "Error: " << res.error() << "\n";
// 존재하지 않는 사용자 "Charlie"가 전송
res = mediator("Hi", "Charlie");
if(!res) std::cerr << "Error: " << res.error() << "\n"; // "Sender not in chat"
}
설명:
- mediator 호출 시 sender와 msg 전달, std::expected로 에러 보고
- 상속 없이도 Mediator 기능 구현, User 이름-객체 매핑으로 동적 관리 용이
5. 조건부 필터링, 비동기 처리, Ranges
메시지를 조건부 필터링하거나, 비동기로 처리하려면 Ranges나 coroutine 사용 가능. 예를 들어, 특정 단어 포함 시 필터링:
auto filteredMediator = [&](const std::string& msg, const std::string& sender) -> std::expected<void,std::string> {
if (msg.find("secret") != std::string::npos) {
return std::unexpected("Filtered message: contains 'secret'");
}
// 나머지는 기존 mediator 호출
return mediator(msg, sender);
};
비동기 처리 시 coroutine 사용 예(가상의 예):
// co_mediator를 coroutine으로 구현, co_await 비동기 I/O로 외부 로깅
로깅 추가:
auto loggingMediator = [&](auto baseMediator) {
return [=](const std::string& msg, const std::string& sender) -> std::expected<void,std::string> {
std::cout << std::format("[LOG] Sender: {}, Msg: {}\n", sender, msg);
auto res = baseMediator(msg, sender);
if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
else std::cout << "[LOG] Message delivered.\n";
return res;
};
};
비교:
- 전통적: Mediator 기능 확장(필터, 비동기, 로깅) 시 상속 기반 Decorator 필요, 클래스 증가
- C++20: 람다 장식, coroutine, std::expected, std::format, Ranges 등으로 적은 코드로 기능 추가 가능
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Mediator 인터페이스, 구체 Mediator 클래스, 각 Colleague(참여자) 객체 상속 필요
- 클래스 수 증가, 새로운 상호작용 규칙 추가 시 유지보수 어려움
- 에러 처리나 비동기 처리, 로깅, 필터링 등 고급 요구사항 구현 어려움
C++11/14/17:
- 람다나 std::function 활용 가능하지만, Concepts, Ranges, coroutine, std::expected 미지원
- 일부 개선 가능하나 정적 타입 제약, 타입 안전성, 선언적 파이프라인 구성 어려움
C++20 이상(모던 C++):
- Concepts로 Mediator 요구사항 명시, 상속 없이 타입 안전한 인터페이스
- 람다, std::function, std::expected로 에러 처리 명확, 비동기 시 coroutine 활용
- std::format으로 로깅, Ranges로 메시지 처리 파이프라인 구성 가능
- 클래스 증가 문제 해결, 유지보수성과 확장성, 타입 안전성 대폭 향상
결론
미디에이터(Mediator) 패턴은 객체 간 상호작용을 한 곳(미디에이터)에서 집중 관리함으로써 결합도를 낮추는 중요한 패턴입니다. 전통적 구현은 Mediator 인터페이스와 구체 Mediator 클래스, 그리고 상호작용하는 객체들 간의 상속 기반 구조로 인해 클래스 증가와 복잡도 상승을 야기했습니다.
C++20 이상에서는 Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없이도 미디에이터 기능을 람다나 함수 객체 형태로 구현 가능하며, 에러 처리, 비동기 처리, 조건부 메시지 필터링, 로깅 등 다양한 요구사항에도 쉽게 대응할 수 있습니다. 이를 통해 코드 가독성과 유지보수성을 크게 개선하고, 전통적 구현 대비 훨씬 유연하고 확장성 높은 미디에이터 패턴을 구현할 수 있습니다.