[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #30] 전략(Strategy) 패턴: 람다와 Concepts로 알고리즘 동적 선택하기

이전 글에서는 상태(State) 패턴을 모던 C++ 관점에서 재해석하며, std::variant, std::visit, Concepts, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이 값 기반 상태 머신을 구현하고, 조건부 전이, Undo/Redo, 비동기 처리, 로깅 등 다양한 요구사항에도 대응할 수 있음을 확인했습니다. 이제는 행동(Behavioral) 패턴 중 전략(Strategy) 패턴을 다룹니다.

전략 패턴은 알고리즘 패밀리를 정의하고, 이를 교체 가능하게 하여 런타임에 다른 알고리즘을 선택할 수 있도록 하는 패턴입니다. 전통적으로는 Strategy 인터페이스와 ConcreteStrategy 클래스를 상속해 구현했지만, 이는 알고리즘 추가 시 클래스 증가, 유지보수 어려움 등 문제를 야기합니다.

C++20 이상에서는 람다, Concepts, std::function, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없는 알고리즘 교체 로직을 구현할 수 있습니다. 이를 통해 조건부 선택, 비동기 알고리즘, 에러 처리, 로깅, 파이프라인 실행 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 향상됩니다.

패턴 소개: 전략(Strategy)

의도:

  • 알고리즘(전략)을 하나의 객체로 캡슐화하고, 이를 런타임에 교체 가능하게 하여 유연한 알고리즘 선택 구현.
  • 예: 정렬 알고리즘, 압축 알고리즘, 경로 탐색 알고리즘 등을 상황에 따라 교체 가능.

전통적 구현 문제점:

  • Strategy 인터페이스 + ConcreteStrategy 상속으로 알고리즘 추가 시 클래스 증가
  • 조건부 선택, 비동기 알고리즘, 로깅, 에러 처리 등 추가 기능 구현 시 복잡성 증가

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

예를 들어, 정렬 알고리즘 선택:

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

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

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

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

struct DataContext {
    std::unique_ptr<SortStrategy> strategy;
    void setStrategy(std::unique_ptr<SortStrategy> s) { strategy=std::move(s); }
    void doSort(std::vector<int>& data) {
        strategy->sort(data);
    }
};

int main() {
    DataContext ctx;
    ctx.setStrategy(std::make_unique<QuickSort>());
    std::vector<int> arr={3,1,2};
    ctx.doSort(arr); // 정렬
    for(auto v:arr) std::cout<<v<<" ";
    std::cout<<"\n";
}

문제점:

  • Strategy 인터페이스와 구체 전략 클래스 필요
  • 알고리즘 추가 시 클래스 증가
  • 에러 처리, 비동기 알고리즘, 조건부 선택, 로깅 등 구현 시 복잡

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

1. Concepts로 알고리즘 인터페이스 정의

정렬 알고리즘: (std::vector<int>&) -> std::expected<void,std::string> 형태로 가정.

template<typename S>
concept SortStrategyConcept = requires(S& s, std::vector<int>& data) {
    { s(data) } -> std::convertible_to<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)>);

3. DataContext 없이도 함수 합성

Strategy를 std::function으로 관리, setStrategy로 교체:

#include <functional>

struct DataContext2 {
    std::function<std::expected<void,std::string>(std::vector<int>&)> strategy;

    void setStrategy(SortStrategyConcept auto s) {
        strategy = s;
    }

    std::expected<void,std::string> doSort(std::vector<int>& data) {
        if(!strategy) return std::unexpected("No strategy set");
        return strategy(data);
    }
};

int main() {
    DataContext2 ctx;
    ctx.setStrategy(QuickSortLambda);
    std::vector<int> arr={3,1,2};
    auto res = ctx.doSort(arr);
    if(!res) std::cerr<<"Error:"<<res.error()<<"\n";
    else {
        for(auto v:arr) std::cout<<v<<" "; // 1 2 3
        std::cout<<"\n";
    }
}

비교:

  • 전통적: Strategy 클래스 필요
  • C++20: 람다로 알고리즘 정의, 상속 없음, std::expected로 에러 처리

조건부 선택, 로깅, 비동기, Ranges 적용

조건부 선택(예: 배열 크기에 따라 다른 알고리즘):

auto conditionalStrategy = [&](auto baseStrategy1, auto baseStrategy2) {
    return [=](std::vector<int>& data)->std::expected<void,std::string> {
        if(data.size()>5) return baseStrategy1(data); 
        else return baseStrategy2(data);
    };
};

auto hybridStrategy = conditionalStrategy(QuickSortLambda,ReverseSortLambda);

로깅(std::format):

auto loggingStrategy = [&](auto baseStrategy) {
    return [=](std::vector<int>& data)->std::expected<void,std::string> {
        std::cout << "[LOG] Sorting " << data.size() << " elements\n";
        auto res = baseStrategy(data);
        if(!res) std::cout << "[LOG] Error: " << res.error() << "\n";
        else std::cout << "[LOG] Success.\n";
        return res;
    };
};

auto loggedHybrid = loggingStrategy(hybridStrategy);

비동기 전략: coroutine으로 비동기 정렬 구현 가능(가상의 예):

// co_quickSort(...) : co_await I/O, 완료 후 std::expected 반환

Ranges: 여러 데이터셋 파이프라인 처리:

#include <ranges>
std::vector<std::vector<int>> datasets = {{3,2,1},{10,9},{1}};
for (auto res : datasets | std::views::transform([&](auto& ds){return loggedHybrid(ds);} )) {
    if(!res) std::cerr<<"One dataset sort error: "<<res.error()<<"\n";
}

비교:

  • 전통적: 조건부 선택, 비동기, 로깅 추가 시 Decorator나 새 Strategy 필요
  • C++20: 람다 합성으로 쉽게 기능 추가, coroutine으로 비동기, Ranges로 파이프라인 처리, std::expected로 에러 처리

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

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

  • Strategy 인터페이스 + ConcreteStrategy 클래스 필요
  • 알고리즘 추가/교체 시 클래스 증가
  • 조건부 선택, 비동기 처리, 로깅, 에러 처리 구현 시 복잡성 증가

C++11/14/17:

  • 람다, std::function으로 일부 단순화 가능
  • Concepts, std::expected, coroutine, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 간결 표현 한계

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

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

결론

전략(Strategy) 패턴은 알고리즘 선택을 유연하게 하는 핵심 패턴이지만, 전통적 구현은 Strategy 인터페이스와 구체 전략 클래스로 인한 클래스 증가, 유지보수 어려움을 야기했습니다. C++20 이상에서는 Concepts, 람다, std::function, std::expected, std::format, coroutine, Ranges 등을 결합해 상속 없이 알고리즘 교체 로직을 간결하고 타입 안전하게 구현할 수 있습니다. 이로써 조건부 선택, 비동기 알고리즘, 에러 처리, 로깅, 파이프라인 실행 등 다양한 요구사항에도 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.

반응형