[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #17] 미디에이터(Mediator) 패턴: 상호작용을 함수 합성과 Concepts로 집중 관리하기

이전 글에서는 이터레이터(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 등을 활용해 상속 없이도 미디에이터 기능을 람다나 함수 객체 형태로 구현 가능하며, 에러 처리, 비동기 처리, 조건부 메시지 필터링, 로깅 등 다양한 요구사항에도 쉽게 대응할 수 있습니다. 이를 통해 코드 가독성과 유지보수성을 크게 개선하고, 전통적 구현 대비 훨씬 유연하고 확장성 높은 미디에이터 패턴을 구현할 수 있습니다.

반응형