[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #24] 데코레이터(Decorator) 패턴: 함수 합성과 람다로 기능 동적 추가하기

이전 글에서는 컴포지트(Composite) 패턴을 모던 C++ 관점에서 재해석하며, std::variant와 std::visit를 통해 상속 없이 부분-전체 구조를 값 기반으로 표현하고, Ranges, coroutine, std::expected, std::format 등을 활용해 조건부 처리, 비동기 연산, 로깅 등 다양한 요구사항에도 쉽게 대응할 수 있음을 확인했습니다. 이번에는 구조적 패턴 중 데코레이터(Decorator) 패턴을 다룹니다.

데코레이터 패턴은 객체에 동적으로 새로운 기능을 추가하기 위한 패턴이며, 전통적 구현에서는 컴포넌트를 상속한 데코레이터 클래스 계층을 통해 기능을 장식(Decorate)해야 했습니다. 그러나 이는 클래스 증가와 유지보수 어려움을 초래합니다. C++20 이상에서는 람다, std::function, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용해 함수 합성으로 장식 기능을 추가할 수 있습니다. 상속 없이도 "원본 기능 → 데코레이터 람다 → 최종 기능" 흐름을 파이프라인 형태로 구현 가능하며, 조건부 장식, 에러 처리, 비동기 장식, 로깅 등 다양한 상황에도 쉽게 대응할 수 있습니다.

패턴 소개: 데코레이터(Decorator)

의도:

  • 객체에 동적으로 책임(기능)을 추가하는 패턴.
  • 상속 대신 객체 합성으로 유연하게 기능을 추가.
  • 예: 스트림 I/O에서 압축/암호화 기능을 데코레이터로 추가, GUI 위젯에 스크롤바나 테두리 기능 장식.

전통적 구현 문제점:

  • Component 추상 클래스 + 데코레이터 상속 계층 필요
  • 새로운 데코레이터 기능 추가 시 클래스 증가
  • 조건부 장식, 비동기 기능 추가, 로깅 등 고급 요구사항 구현 시 복잡성 증가

기존 C++ 스타일 구현 (전통적 방식)

간단한 예: Stream 인터페이스와 ConcreteStream, 데코레이터로 압축 기능 추가:

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

struct Stream {
    virtual ~Stream()=default;
    virtual void write(const std::string& data)=0;
};

struct FileStream : Stream {
    void write(const std::string& data) override {
        std::cout << "Writing to file: " << data << "\n";
    }
};

struct StreamDecorator : Stream {
    std::unique_ptr<Stream> inner;
    StreamDecorator(std::unique_ptr<Stream> s):inner(std::move(s)){}
    void write(const std::string& data) override {
        inner->write(data);
    }
};

struct CompressDecorator : StreamDecorator {
    CompressDecorator(std::unique_ptr<Stream> s):StreamDecorator(std::move(s)){}
    void write(const std::string& data) override {
        std::cout << "Compressing data.\n";
        StreamDecorator::write("compressed(" + data + ")");
    }
};

int main() {
    std::unique_ptr<Stream> fs = std::make_unique<FileStream>();
    std::unique_ptr<Stream> compressed = std::make_unique<CompressDecorator>(std::move(fs));
    compressed->write("hello");
}

출력:

Compressing data.
Writing to file: compressed(hello)

문제점:

  • CompressDecorator 클래스 필요, 더 많은 기능 추가 시 데코레이터 클래스 증가
  • 조건부 장식, 비동기 처리, 에러 처리, 로깅 등 구현 복잡

모던 C++20 이상 개선: 함수 합성과 람다로 장식

1. Concepts로 인터페이스 정의

스트림 쓰기 기능을 std::expected<void,std::string> 반환하는 함수로 정의. Concepts로 스트림 요구사항 명시:

template<typename S>
concept StreamConcept = requires(S& s, const std::string& data) {
    { s(data) } -> std::convertible_to<std::expected<void,std::string>>;
};

2. 원본 기능 람다 정의

auto fileStream = [](const std::string& data) -> std::expected<void,std::string> {
    if(data.empty()) return std::unexpected("Empty data");
    std::cout << "Writing to file: " << data << "\n";
    return {};
};

static_assert(StreamConcept<decltype(fileStream)>);

3. 데코레이터 람다로 기능 추가

압축 기능 데코레이터:

auto compressDecorator = [&](auto baseStream) {
    return [=](const std::string& data) -> std::expected<void,std::string> {
        std::cout << "Compressing data.\n";
        return baseStream("compressed(" + data + ")");
    };
};

auto compressedStream = compressDecorator(fileStream);
static_assert(StreamConcept<decltype(compressedStream)>);

사용 예:

int main() {
    auto res = compressedStream("hello");
    if(!res) std::cerr << "Error: " << res.error() << "\n";
}

출력:

Compressing data.
Writing to file: compressed(hello)

비교:

  • 전통적: CompressDecorator 클래스 필요
  • C++20: 람다로 장식 기능 추가, 상속 없음

4. 조건부 장식, 에러 처리, 비동기

조건부 장식(예: 특정 데이터 길이 이상일 때만 압축):

auto conditionalCompress = [&](auto baseStream) {
    return [=](const std::string& data) -> std::expected<void,std::string> {
        if(data.size() > 10) {
            std::cout << "Condition met, compressing.\n";
            return baseStream("compressed(" + data + ")");
        } else {
            return baseStream(data);
        }
    };
};

auto conditionalStream = conditionalCompress(fileStream);
conditionalStream("short"); // no compress
conditionalStream("this is long data"); // compress

비동기 장식: coroutine으로 장식 함수 구현 가능

// co_decorator(...) 가상 예: co_await 비동기 처리, 완료 후 std::expected 반환

5. 로깅, Ranges 파이프라인

로깅 데코레이터:

auto loggingDecorator = [&](auto baseStream) {
    return [=](const std::string& data)->std::expected<void,std::string> {
        std::cout << std::format("[LOG] Writing: {}\n", data);
        auto res = baseStream(data);
        if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
        else std::cout << "[LOG] Success.\n";
        return res;
    };
};

auto loggedCompressedStream = loggingDecorator(compressDecorator(fileStream));
loggedCompressedStream("sample"); 

Rangess로 여러 데이터 파이프라인 처리:

#include <vector>
#include <ranges>

std::vector<std::string> datas = {"hello","world",""};
for (auto res : datas | std::views::transform(loggedCompressedStream)) {
    if(!res) std::cerr << "Error: " << res.error() << "\n";
}

비교:

  • 전통적: 새로운 기능(조건부, 비동기, 로깅) 추가 시 Decorator 클래스 증가
  • C++20: 람다 합성으로 기능 추가, coroutine, Ranges, std::expected, std::format 사용해 다양한 시나리오 처리 용이

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적(C++98/03):

  • Component 추상 클래스 + ConcreteComponent + Decorator 상속 계층 필요
  • 새로운 데코레이터 추가 시 클래스 증가
  • 조건부 데코레이션, 비동기 처리, 로깅, 에러 처리 구현 복잡

C++11/14/17:

  • 람다/std::function으로 일부 개선 가능하지만 Concepts, coroutine, std::expected, Ranges 부재 → 정적 타입 제약, 비동기/에러 처리 한계

C++20 이상(모던 C++):

  • Concepts로 인터페이스 요구사항 정의, 상속 없이 타입 안전한 인터페이스
  • 람다 합성으로 Decorator 기능 추가, std::expected로 에러 처리 명확
  • coroutine으로 비동기 장식, std::format으로 로깅, Ranges로 데이터 파이프라인 처리
  • 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상

결론

데코레이터(Decorator) 패턴은 객체에 동적으로 새로운 기능을 추가하는 강력한 패턴이지만, 전통적 구현은 Decorator 클래스 계층을 필요로 하고, 새로운 기능 추가 시 클래스 증가 및 유지보수 어려움을 야기했습니다.

C++20 이상에서는 Concepts, 람다, std::function, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없는 함수 합성 형태로 기능을 장식할 수 있습니다. 이를 통해 조건부 장식, 비동기 처리, 로깅, 에러 처리 등 다양한 요구사항에도 적은 코드와 높은 타입 안전성으로 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성을 크게 개선할 수 있습니다.

반응형