지금까지 우리는 생성, 구조적 패턴들을 모던 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++로 이벤트 처리나 상태 머신을 더 단순하고 직관적으로 구성하는 방법을 탐구할 예정입니다.