[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #6] 데코레이터(Decorator) 패턴: 상속 대신 함수 합성, Ranges 파이프라인으로 기능 확장하기

앞선 글에서는 브리지(Bridge) 패턴을 통해 모던 C++에서 상속 기반의 전통적 설계를 어떻게 Concepts, 함수 객체, std::variant 등으로 대체하며 더 유연한 구조를 만들 수 있는지 살펴보았습니다. 이번에는 데코레이터(Decorator) 패턴을 재조명합니다.

데코레이터 패턴은 객체에 기능을 동적으로 추가하는 방법을 제시합니다. 전통적으로는 상속이나 래핑(wrapper) 클래스를 사용해 "장식(Decoration)"을 더했지만, 모던 C++에서는 람다, std::function, Ranges, Concepts 등의 기능을 활용해 상속 계층 폭발을 줄이고, 훨씬 간결하고 가독성 높은 코드로 기능을 확장할 수 있습니다.

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

의도:

  • 객체에 추가 책임(기능)을 동적으로 부여하는 패턴.
  • 상속 대신 객체 합성을 사용해 다양한 장식을 유연하게 적용.
  • 예: 그래픽 객체에 스크롤바 기능 추가, I/O 스트림에 압축/암호화 기능 추가 등.

전통적 구현 문제점:

  • 상속 기반 데코레이터는 "기능 A + B + C" 조합을 할 때마다 새 클래스 정의 필요.
  • 상속 계층이 커지고 복잡해지며, 클래스 수 증가.

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

예를 들어, Shape 인터페이스를 가진 객체를 데코레이터로 감싸서 색칠, 테두리 그리기 등의 기능을 붙여보겠습니다.

#include <iostream>
#include <memory>

struct Shape {
    virtual ~Shape() = default;
    virtual void draw() const = 0;
};

struct Circle : Shape {
    void draw() const override {
        std::cout << "Drawing circle\n";
    }
};

// 데코레이터 기반 상속
struct ShapeDecorator : Shape {
    ShapeDecorator(std::unique_ptr<Shape> s) : shape(std::move(s)) {}
    void draw() const override {
        shape->draw();
    }
protected:
    std::unique_ptr<Shape> shape;
};

struct ColorDecorator : ShapeDecorator {
    ColorDecorator(std::unique_ptr<Shape> s, std::string c) : ShapeDecorator(std::move(s)), color(std::move(c)) {}
    void draw() const override {
        ShapeDecorator::draw();
        std::cout << "Coloring with " << color << "\n";
    }
private:
    std::string color;
};

struct BorderDecorator : ShapeDecorator {
    BorderDecorator(std::unique_ptr<Shape> s, int thickness) : ShapeDecorator(std::move(s)), thickness(thickness) {}
    void draw() const override {
        ShapeDecorator::draw();
        std::cout << "Border thickness: " << thickness << "\n";
    }
private:
    int thickness;
};

int main() {
    auto shape = std::make_unique<Circle>();
    // Color + Border 장식
    auto decorated = std::make_unique<BorderDecorator>(
        std::make_unique<ColorDecorator>(
            std::move(shape), "Red"
        ), 3
    );
    decorated->draw();
}

고찰:

  • 상속 기반으로 데코레이터를 구현하면 클래스 수가 늘어남.
  • 새로운 장식 기능 추가 시 새 데코레이터 클래스 필요.
  • 다중 기능 조합 시 중첩된 생성 호출 복잡.

모던 C++20 이상의 개선: 함수 합성과 파이프라인

1. Concept로 "그릴 수 있는(Drawable)" 타입 제약

Shape 인터페이스를 가상 함수 대신 Concepts로 정의할 수 있습니다. "그릴 수 있는 객체"는 draw() const 메서드를 제공해야 한다고 제약할 수 있습니다.

#include <concepts>
#include <string>
#include <iostream>

template<typename S>
concept Drawable = requires(const S& s) {
    { s.draw() } -> std::same_as<void>;
};

struct Circle2 {
    void draw() const {
        std::cout << "Drawing circle\n";
    }
};

static_assert(Drawable<Circle2>);

이제 Decorator를 상속하지 않고, "장식 기능"을 함수로 표현할 수 있습니다.

2. 함수 합성을 통한 데코레이터 적용

데코레이터가 하는 일은 draw() 호출 전후에 추가 기능을 실행하는 것입니다. 이를 람다나 함수 객체로 쉽게 구현할 수 있습니다.

// Draw 함수를 인자로 받아 장식 후 새로운 Draw 함수를 반환하는 헬퍼
auto makeColorDecorator(std::string color) {
    return [color](auto drawFunc) {
        return [=](auto&& obj) {
            drawFunc(obj);
            std::cout << "Coloring with " << color << "\n";
        };
    };
}

auto makeBorderDecorator(int thickness) {
    return [thickness](auto drawFunc) {
        return [=](auto&& obj) {
            drawFunc(obj);
            std::cout << "Border thickness: " << thickness << "\n";
        };
    };
}

고찰:

  • makeColorDecorator와 makeBorderDecorator는 "데코레이터 제조 함수".
  • 입력: 기존 drawFunc (draw를 호출하는 함수)
  • 출력: 새로운 drawFunc(장식된 draw 함수)를 반환.
  • 람다로 기능을 합성하므로, 클래스 상속 필요 없음.

3. Pipeline(파이프라인)으로 장식 적용

std::function이나 람다 파이프라인을 통해 원본 그리기 함수를 차례로 장식할 수 있습니다.

#include <functional>

template<Drawable D>
void drawObject(const D& obj, std::function<void(const D&)> drawFunc) {
    drawFunc(obj);
}

int main() {
    Circle2 c;
    // 원본 draw 함수
    auto baseDraw = [](const auto& obj) { obj.draw(); };

    // 파이프라인으로 데코레이션 적용
    // baseDraw -> ColorDecorator(red) -> BorderDecorator(3)
    auto decoratedDraw = makeBorderDecorator(3)(
                           makeColorDecorator("Red")(
                               baseDraw
                           )
                        );

    drawObject(c, decoratedDraw);
}

고찰:

  • 데코레이터를 단순히 함수 조합으로 구현, 상속 없이 다양한 기능 조합 가능.
  • 함수 합성으로 다양한 장식 조합을 동적으로 구성.
  • 새로운 데코레이터 추가 시 함수 하나 추가하면 됨, 클래스 수 증가 없음.

4. Ranges나 Concepts와 연계

만약 장식 과정이 복잡한 변환 시퀀스로 구성된다면, Ranges를 통해 파이프라인 구성 가능. 예를 들어, 여러 장식을 벡터에 담고 fold 작업을 통해 하나의 drawFunc로 축약할 수 있습니다.

#include <vector>
#include <ranges>

int main() {
    Circle2 c;
    auto baseDraw = [](const auto& o) { o.draw(); };

    std::vector decorators = {
        makeColorDecorator("Blue"),
        makeBorderDecorator(5)
    };

    // fold 계열 연산 (accumulate)으로 파이프라인 결합
    auto combinedDraw = std::accumulate(decorators.begin(), decorators.end(), baseDraw,
        [](auto acc, auto decorator) {
            return decorator(acc); // acc를 decorator로 장식
        }
    );

    drawObject(c, combinedDraw);
}

고찰:

  • 함수 파이프라인을 표준 알고리즘으로 처리, 동적 조합 가능.
  • Concepts, 람다, STL 알고리즘을 통해 정적/동적 다형성 혼합 가능.
  • 장식 기능 추가 시 상속 필요 없이 함수 하나 추가하고 벡터에 넣으면 끝.

5. std::expected, std::format으로 개선

만약 장식 과정에서 에러가 발생 가능하다면 std::expected로 처리할 수 있습니다. 또, std::format으로 디버깅 메시지를 더 명확히 출력할 수도 있습니다.

#include <expected>
#include <format>

auto makeSafeBorderDecorator(int thickness) {
    return [thickness](auto drawFunc) {
        return [=](auto&& obj) -> std::expected<void,std::string> {
            auto res = drawFunc(obj);
            if (!res) return res; // drawFunc가 expected 반환한다 가정
            if (thickness < 0) return std::unexpected("Negative thickness");
            std::cout << std::format("Border thickness: {}\n", thickness);
            return {};
        };
    };
}

고찰:

  • decorator 체인을 expected 기반으로 설계해, 실패 발생 시 즉시 propagate.
  • std::format으로 메시지 포맷 일관성 유지.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 상속 기반 데코레이터 계층, 여러 기능 조합 시 클래스 폭발.
    • 기능 추가 시 새 클래스 필요, 코드량 증가.
    • 런타임 다형성, vtable로 인한 비용과 복잡성.
  • 모던 C++(C++20 이상):
    • 상속 대신 함수 합성, 람다로 데코레이터 기능 표현.
    • Concepts로 "그릴 수 있는(Drawable)" 타입을 제약, 타입 안전성 향상.
    • std::function, 람다 파이프라인, Ranges 알고리즘으로 동적/정적 조합 쉽게 처리.
    • std::expected로 에러 처리 명확화, std::format으로 디버깅 개선.
    • 클래스 수 감소, 코드 가독성 및 유지보수성 상승.

결국 모던 C++에서 데코레이터 패턴은 상속 계층 생성 대신 함수 합성과 파이프라인을 통해 구현하는 방향으로 진화할 수 있습니다. 이는 패턴의 핵심 의도(동적 기능 추가)를 더 간결하고 확장 가능하게 해줍니다.

마무리

데코레이터 패턴은 객체에 런타임에 새로운 기능을 추가하는 강력한 방법을 제시하지만, 전통적 구현에서는 상속과 클래스 증가 문제로 번거로웠습니다. 모던 C++20 시대에는 Concepts, 람다, 함수 합성, Ranges 등을 활용해 상속 없이도 유연하게 기능을 "쌓아올릴" 수 있습니다. 이렇게 하면 코드가 더욱 모듈화되고, 필요에 따라 쉽게 기능을 추가하거나 제거할 수 있습니다.

다음 글에서는 구조적 패턴 중 또 다른 패턴인 퍼사드(Facade)를 다루며, 복잡한 서브시스템을 단일 인터페이스로 감싸는 고전적 아이디어가 모던 C++에서 어떻게 단순화될 수 있는지 살펴보겠습니다.

반응형