[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #9] 프록시(Proxy) 패턴: 접근 제어, 지연 로딩, 원격 호출을 모던 C++로 더 깔끔하게

지난 글에서는 플라이웨이트(Flyweight) 패턴을 모던 C++ 관점에서 재해석하며, 대량의 유사 객체 공유를 std::expected, std::string_view, Ranges, Concepts 등을 사용해 더 안전하고 유지보수성 높은 방식으로 구현할 수 있음을 살펴봤습니다. 이번 글에서는 구조적 패턴의 또 다른 핵심, 프록시(Proxy) 패턴을 다룹니다.

프록시 패턴은 특정 객체(Real Subject)에 대한 접근을 제어하거나, 지연 로딩(Lazy Loading), 원격 호출(Remote Proxy), 캐싱(Caching) 등의 부가 기능을 중간에 삽입하는 기법을 제안합니다. 전통적 C++ 구현은 가상 함수를 통한 상속 기반 대리 객체로 접근했지만, 모던 C++20 이상의 기능을 활용하면 함수 합성, Concepts, std::expected, std::function, coroutines를 통한 비동기 처리까지 동원해 더 유연하고 간결한 프록시 구조를 만들 수 있습니다.

패턴 소개: 프록시(Proxy)

의도:

  • 다른 객체(Real Subject)에 대한 대리자(Proxy)를 제공하여, 직접 접근을 제어하거나 부가 기능(로깅, 권한 체크, 지연 로딩) 수행.
  • 예: 원격 서버에 있는 객체를 로컬 Proxy로 대체, 사용자가 필요할 때만 실 객체 생성(Lazy Initialization), 호출 전후 로깅 등.

전통적 구현 문제점:

  • 상속 기반으로 Real Subject와 Subject 인터페이스를 정의, Proxy는 이를 구현해 동일 인터페이스를 제공.
  • 기능 추가 시 상속 계층 복잡화, 다양한 Proxy 변형(가상 함수 기반)이 늘어남.
  • 동적 다형성, 예외 처리, 에러 반환 단순.

기존 C++ 스타일 구현 (C++11/14/17)

예를 들어, 원격 서비스를 호출하는 RealSubject를 RemoteProxy로 감싸 상태 체크나 캐싱을 수행할 수 있습니다.

#include <iostream>
#include <memory>
#include <string>

struct Subject {
    virtual ~Subject() = default;
    virtual std::string request() = 0;
};

struct RealSubject : Subject {
    std::string request() override {
        std::cout << "RealSubject: Handling request.\n";
        return "Data";
    }
};

struct RemoteProxy : Subject {
    RemoteProxy(std::unique_ptr<Subject> real) : real(std::move(real)) {}

    std::string request() override {
        std::cout << "Proxy: Checking access...\n";
        // 권한 체크 가정
        return real->request();
    }

private:
    std::unique_ptr<Subject> real;
};

int main() {
    auto real = std::make_unique<RealSubject>();
    RemoteProxy proxy(std::move(real));
    std::cout << proxy.request() << "\n";
}

고찰:

  • 상속 기반 인터페이스(Subject) + Proxy 클래스.
  • 기능 추가 시마다 클래스 증가.
  • 에러 처리나 비동기 처리, Concepts 활용 불가.

모던 C++20 이상의 개선: Concepts, 함수 합성, std::function, std::expected

1. Concepts로 Subject 인터페이스 제약

Subject가 request() 메서드를 제공한다는 것을 Concepts로 표현할 수 있습니다.

#include <concepts>
#include <string>
#include <expected>

template<typename S>
concept SubjectConcept = requires(S& s) {
    { s.request() } -> std::convertible_to<std::expected<std::string,std::string>>;
};

여기서 request()가 std::expected<std::string,std::string>를 반환한다고 가정하면, 실패 상황도 명확히 처리 가능.

고찰:

  • 상속 없이도 Subject 개념을 정적 타입 검사로 표현.
  • std::expected로 에러 상황 명확화.

2. RealSubject를 함수나 람다로 표현

RealSubject를 꼭 클래스 상속으로 표현할 필요 없이, request를 수행하는 함수 객체로 정의할 수 있습니다.

#include <expected>
#include <iostream>

struct RealSubjectLambda {
    std::expected<std::string,std::string> request() {
        std::cout << "RealSubject: Handling request.\n";
        return "Data";
    }
};

static_assert(SubjectConcept<RealSubjectLambda>);

고찰:

  • 클래스 상속 없이도 Concepts 만족.
  • Templates와 Concepts로 확장성 높임.

3. Proxy를 함수 합성으로 구현

Proxy 패턴의 핵심은 호출 전후에 부가 작업을 삽입하는 것. 상속 대신 함수 합성, 람다로 간단히 구현 가능.

#include <functional>

template<SubjectConcept S>
auto makeProxy(S subject) {
    return [subject]() -> std::expected<std::string,std::string> {
        std::cout << "Proxy: Checking access...\n";
        auto res = subject.request();
        if(!res) {
            return std::unexpected("Proxy detected failure: " + res.error());
        }
        return *res;
    };
}

이제 makeProxy는 어떤 SubjectConcept를 만족하는 객체든 람다 형태의 Proxy로 감싸 기능 추가 가능.

고찰:

  • 상속 없이도 Proxy 기능 추가.
  • 람다 합성, 함수 객체 조합으로 호출 전후 작업 삽입.

4. std::function이나 std::bind_front로 다형성 확보

여러 다른 Subject 타입에 대해 Proxy를 동일하게 적용하려면 std::function 사용 가능.

#include <functional>

std::function<std::expected<std::string,std::string>()> makeGenericProxy(auto subject) {
    return [subject]() {
        std::cout << "Generic Proxy: Logging...\n";
        return subject.request();
    };
}

고찰:

  • std::function으로 런타임 다형성 확보.
  • Concepts로 컴파일 타임 제약 검사 후, 런타임 선택 가능.

5. 비동기 호출: Coroutines 사용

C++20 코루틴을 통해 비동기 프록시 구현도 가능. request()를 coroutine으로 만들고, Proxy에서 co_await로 비동기 제어 흐름 추가 가능.

// 가상 예: co_request() 반환이 coroutine handle
// Proxy에서 권한 체크 후 co_await realSubject.co_request()

고찰:

  • 비동기 원격 호출(원격 프록시) 구현에 coroutines 유용.
  • Concepts로 비동기 Subject 조건 정의 가능(예: co_request 반환 타입 제약).

6. std::format으로 로깅 개선

#include <format>

auto loggingProxy = [&](auto subj) {
    return [subj]() {
        std::cout << std::format("Proxy: Calling subject at time {}\n", /*timestamp*/ 123);
        return subj.request();
    };
};

고찰:

  • std::format으로 동적 데이터 포함한 로깅 깔끔히 처리.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 상속 기반 인터페이스 + Proxy 클래스 구현
    • 부가 기능 추가 시 클래스 계층 증가
    • 예외나 nullptr 반환 등 단순한 에러 처리, 비동기 처리 어려움
  • 모던 C++(C++20 이상):
    • Concepts로 Subject 인터페이스 요구사항 명확화, 상속 불필요
    • 람다, 함수 객체 합성으로 호출 전후 부가 작업 간단히 삽입
    • std::expected로 에러 처리 명확화, std::format으로 로깅 정돈
    • std::function, std::variant, coroutines로 런타임 다형성, 비동기 처리, 원격 호출 등 다양한 시나리오 지원
    • 템플릿 기반 접근으로 성능 및 유지보수성 향상, 클래스 수 감소

결국 모던 C++에서는 프록시 패턴도 상속 계층과 가상 함수 테이블에서 벗어나, 함수 합성, Concepts, coroutine, std::expected 등을 사용해 더 간결하고 타입 안전하며 유연한 구현이 가능해집니다.

마무리

프록시 패턴은 객체 접근을 제어하고 부가 기능(로깅, 캐싱, 인증, 원격 호출) 삽입에 강력한 패턴이지만, 전통적 구현 방식은 상속과 클래스 증가 문제를 안고 있었습니다. 모던 C++20 이상에서는 Concepts, 람다, 함수 합성, std::expected, std::format, coroutines 등의 기능을 활용해 상속 없이도 다형성과 부가 기능을 유연하게 구현할 수 있습니다.

다음 글에서는 행동 패턴(Behavioral Patterns)로 넘어가, 전략(Strategy), 상태(State), 옵저버(Observer) 등의 패턴을 모던 C++ 관점에서 재구성하며, 타입 안전성과 가독성, 유지보수성을 극대화하는 방법을 탐구해볼 것입니다.

반응형