[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #10] 전략(Strategy) 패턴: 함수형 패러다임과 Concepts로 구현하는 유연한 알고리즘 선택

지금까지 우리는 생성, 구조적 패턴들을 모던 C++ 관점에서 재해석하며 상속 계층, 가상 함수 테이블, 복잡한 클래스 증가 대신 Concepts, 람다, 함수 합성, std::expected, std::format, Ranges, std::variant 등을 활용해 더 간결하고 타입 안전하며 유지보수성 높은 코드를 작성할 수 있음을 확인했습니다. 이제는 행동(Behavioral) 패턴으로 넘어가 보겠습니다.

행동 패턴은 객체 간의 상호작용, 책임 분배, 알고리즘 캡슐화를 다룹니다. 그 출발점으로 전략(Strategy) 패턴을 선택합니다. 전략 패턴은 알고리즘을 한 객체에 캡슐화하고, 이를 교체함으로써 런타임에 알고리즘을 선택할 수 있게 하는 패턴입니다. 전통적으로는 상속 기반 인터페이스를 통해 구현했지만, 모던 C++에서는 Concepts, 함수 합성, std::function, 람다 등을 활용해 상속 없이도 전략을 명확하고 유연하게 관리할 수 있습니다.

패턴 소개: 전략(Strategy)

의도:

  • 알고리즘을 클래스 계층으로 캡슐화하고, 런타임에 알고리즘을 교체할 수 있게 하는 패턴.
  • 클라이언트는 알고리즘(전략)에 의존하지만, 구체적인 알고리즘 클래스를 알 필요 없이 런타임에 전략 객체 교체로 동작을 변경.
  • 예: 정렬 알고리즘, 압축 방식, 경로 탐색 알고리즘 등을 상황에 맞게 교체.

전통적 구현 문제점:

  • 인터페이스 클래스(Strategy 인터페이스) + 구체 전략 클래스 상속 계층.
  • 새로운 전략 추가 시 클래스 파일 증가.
  • 다양한 전략 조합, 파라미터 관리, 에러 처리 시 가상 함수 기반 접근 불편.

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

예를 들어, 정렬 알고리즘을 전략으로 사용하는 경우를 들어봅시다.

#include <vector>
#include <iostream>
#include <algorithm>

struct SortStrategy {
    virtual ~SortStrategy() = default;
    virtual void sort(std::vector<int>& data) = 0;
};

struct QuickSortStrategy : SortStrategy {
    void sort(std::vector<int>& data) override {
        std::sort(data.begin(), data.end());
    }
};

struct ReverseSortStrategy : SortStrategy {
    void sort(std::vector<int>& data) override {
        std::sort(data.begin(), data.end(), std::greater<int>());
    }
};

struct DataContext {
    DataContext(std::unique_ptr<SortStrategy> s) : strategy(std::move(s)) {}
    void setStrategy(std::unique_ptr<SortStrategy> s) {
        strategy = std::move(s);
    }
    void doSort() {
        strategy->sort(data);
    }
    void add(int v) { data.push_back(v); }
    void print() {
        for(auto v : data) std::cout << v << " ";
        std::cout << "\n";
    }
private:
    std::unique_ptr<SortStrategy> strategy;
    std::vector<int> data;
};

int main() {
    DataContext ctx(std::make_unique<QuickSortStrategy>());
    ctx.add(3);ctx.add(1);ctx.add(2);
    ctx.doSort();
    ctx.print(); // 1 2 3
    ctx.setStrategy(std::make_unique<ReverseSortStrategy>());
    ctx.doSort();
    ctx.print(); // 3 2 1
}

고찰:

  • 상속 기반 전략 인터페이스, 구체 전략 클래스.
  • 새로운 전략 추가 시 클래스 증가.
  • 에러 처리, 특정 전략 파라미터 전달 번거로움.

모던 C++20 이상의 개선: Concepts, 람다, std::function, expected

1. Concepts로 "정렬 전략" 요구사항 정의

sort() 메서드를 요구하는 인터페이스를 Concepts로 표현할 수 있습니다.

#include <concepts>
#include <vector>
#include <expected>

template<typename S>
concept SortStrategyConcept = requires(S& s, std::vector<int>& data) {
    { s.sort(data) } -> std::same_as<std::expected<void,std::string>>;
};

sort()가 std::expected<void,std::string>를 반환한다고 가정하면, 알고리즘 실패 시 명확한 에러 처리 가능.

2. 람다나 함수 객체로 전략 정의

정렬 알고리즘을 람다로 쉽게 정의할 수 있습니다.

auto QuickSortLambda = [](std::vector<int>& data) -> std::expected<void,std::string> {
    std::sort(data.begin(), data.end());
    return {};
};

auto ReverseSortLambda = [](std::vector<int>& data) -> std::expected<void,std::string> {
    std::sort(data.begin(), data.end(), std::greater<int>());
    return {};
};

static_assert(SortStrategyConcept<decltype(QuickSortLambda)>);
static_assert(SortStrategyConcept<decltype(ReverseSortLambda)>);

고찰:

  • 클래스 없이도 전략 정의 가능, 템플릿 기반으로 정적 타입 검사.

3. DataContext를 템플릿으로 구현

DataContext를 템플릿으로 만들고, 전략 타입을 인자로 받으면 상속 없이도 동적 전략 교체 가능. 혹은 std::function 사용.

#include <functional>

template<SortStrategyConcept Strategy>
struct DataContext2 {
    DataContext2(Strategy s) : strategy(s) {}
    void setStrategy(SortStrategyConcept auto s) {
        // 여기서 strategy 타입 고정 문제 발생 -> std::function 사용
        using StrategyType = std::function<std::expected<void,std::string>(std::vector<int>&)>;
        strategyFunc = StrategyType(s);
    }

    void doSort() {
        if(!strategyFunc) {
            std::cerr << "No strategy set.\n";
            return;
        }
        auto res = strategyFunc(data);
        if(!res) {
            std::cerr << "Sort error: " << res.error() << "\n";
        }
    }

    void add(int v) { data.push_back(v); }
    void print() {
        for(auto v : data) std::cout << v << " ";
        std::cout << "\n";
    }

private:
    std::vector<int> data;
    std::function<std::expected<void,std::string>(std::vector<int>&)> strategyFunc;
};

int main() {
    DataContext2 ctx(QuickSortLambda);
    ctx.setStrategy(QuickSortLambda); // 초기 전략
    ctx.add(3);ctx.add(1);ctx.add(2);
    ctx.doSort();
    ctx.print(); // 1 2 3

    ctx.setStrategy(ReverseSortLambda);
    ctx.doSort();
    ctx.print(); // 3 2 1
}

고찰:

  • DataContext2는 상속 없이도 전략 교체 가능.
  • std::function으로 런타임에 전략 교체.
  • std::expected로 에러 관리.

4. Ranges와 Pipeline

여러 전략을 순차적으로 적용하는 경우, Ranges를 통해 전략 파이프라인 구축 가능. 예를 들어, 첫 번째 정렬 후 두 번째 정렬 알고리즘 적용으로 최종 결과 조정하는 식.

#include <ranges>
#include <algorithm>

auto DoubleSortPipeline = [&](auto& data, auto... strategies) {
    std::array fs = {std::function<std::expected<void,std::string>(std::vector<int>&)>(strategies)...};
    for (auto& f : fs) {
        auto res = f(data);
        if(!res) return res;
    }
    return std::expected<void,std::string>{};
};

고찰:

  • 다수의 전략을 파이프라인으로 연결.
  • 상속 없이 알고리즘 조합, 함수 조합으로 용이.

5. std::format으로 디버그 메시지

#include <format>

auto LoggingStrategy = [&](auto baseStrategy) {
    return [=](std::vector<int>& data) -> std::expected<void,std::string> {
        std::cout << std::format("Applying strategy on {} elements.\n", data.size());
        return baseStrategy(data);
    };
};

고찰:

  • std::format으로 동적 데이터 포함한 로그 깔끔히 출력.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • Strategy 인터페이스 + 구체 전략 클래스 상속
    • 클래스 수 증가, 새로운 전략마다 파일/클래스 추가
    • 에러 처리 단순, 런타임 다형성에 vtable 의존
  • 모던 C++(C++20 이상):
    • Concepts로 전략 요구사항 타입 안전하게 정의
    • 람다, 함수 객체로 전략 구현 -> 상속 불필요
    • std::function으로 런타임 전략 교체 간단
    • std::expected로 에러 처리 명확화, std::format으로 로깅 깔끔
    • Ranges나 함수 합성으로 복수 전략 파이프라인 구성 가능
    • 템플릿 기반, 정적 다형성 + 런타임 다형성 혼합 유연성 확보

결국 모던 C++에서는 전략 패턴도 상속과 vtable로 대표되던 전통적 구현 대신, 함수형 패러다임과 Concepts를 결합해 훨씬 유연하고 확장성 있는 코드를 작성할 수 있습니다.

마무리

전략 패턴은 알고리즘 캡슐화를 통한 런타임 선택 자유도를 제공하지만, 전통적 방식은 상속, 클래스 증가 등으로 비용이 컸습니다. 모던 C++에서는 Concepts, 람다, std::function, std::expected, std::format, Ranges 등을 활용해 상속 없이도 간결하게 전략 교체를 구현할 수 있고, 에러 처리나 로깅, 파이프라인화로 더 안전하고 유지보수성 높은 코드를 작성할 수 있습니다.

다음 글에서는 행동 패턴 중 옵저버(Observer)나 상태(State) 같은 패턴을 살펴보며, 모던 C++로 이벤트 처리나 상태 머신을 더 단순하고 직관적으로 구성하는 방법을 탐구할 예정입니다.

반응형