C++ 정책 기반 설계: 템플릿 매개변수로 동작을 컴파일 타임에 조합하기
이 글의 핵심
템플릿 매개변수로 동작(정책)을 주입해 컴파일 타임에 조합하는 Policy-Based Design을 정리합니다. 스마트 포인터의 소유권 정책, 로거 출력 정책, 컨테이너 성장 전략 예제와 정책 인터페이스 불일치, 조합 폭발 같은 문제를 다룹니다.
Policy-Based Design이란?
템플릿 매개변수로 정책(Policy) 클래스를 전달하여 동작을 커스터마이징하는 기법입니다.
사실 표준 라이브러리를 쓰는 사람은 이미 매일 정책 기반 설계를 쓰고 있습니다. std::map<K, V, Compare, Allocator>의 비교 함수와 할당자, std::unordered_map의 Hash와 KeyEqual, std::basic_string<CharT, Traits, Allocator>의 char_traits, std::unique_ptr<T, Deleter>의 삭제자가 모두 “동작 한 축을 템플릿 매개변수로 빼 둔 정책”입니다. 대부분의 사용자는 기본값만 쓰고, 필요한 사람만 대소문자를 무시하는 비교나 메모리 풀 할당자를 끼워 넣습니다. 이 글의 예제들은 그 설계를 직접 만들어 보는 연습이라고 보면 됩니다.
전통적인 상속으로 “잠금이 필요한 카운터”와 “잠금이 필요 없는 카운터”를 둘 다 지원하려면, 가상 함수로 lock/unlock을 추상화하고 런타임에 구현체를 주입받는 방식을 쓰게 됩니다 — 하지만 이 경우 단일 스레드에서만 쓰이는 Counter조차 가상 함수 호출 오버헤드와 간접 호출을 피할 수 없습니다. Policy-Based Design은 이 결정을 런타임에서 컴파일 타임으로 옮깁니다 — LockingPolicy를 템플릿 매개변수로 받으면, Counter<NoLocking>은 컴파일 시점에 lockPolicy.lock()이 아무 일도 하지 않는 빈 함수라는 것을 알 수 있어 컴파일러가 그 호출 자체를 완전히 제거(인라인 후 소거)할 수 있고, Counter<MutexLocking>은 실제 뮤텍스 락을 거는 코드로 구체화됩니다. 두 버전 모두 같은 클래스 템플릿에서 나왔지만, 런타임에는 그 선택의 흔적조차 남지 않는다는 것이 이 기법의 핵심 가치입니다.
// 정책 클래스
struct NoLocking {
void lock() {}
void unlock() {}
};
struct MutexLocking {
mutex mtx;
void lock() { mtx.lock(); }
void unlock() { mtx.unlock(); }
};
// 정책 기반 클래스
template<typename LockingPolicy>
class Counter {
private:
int value = 0;
LockingPolicy lockPolicy;
public:
void increment() {
lockPolicy.lock();
value++;
lockPolicy.unlock();
}
int get() {
lockPolicy.lock();
int result = value;
lockPolicy.unlock();
return result;
}
};
int main() {
Counter<NoLocking> singleThreaded;
Counter<MutexLocking> multiThreaded;
singleThreaded.increment();
multiThreaded.increment();
}
여러 정책 조합
한 클래스가 여러 독립적인 축(스레딩 방식, 메모리 할당 방식)을 동시에 정책으로 받으면, 그 조합의 수가 곱셈으로 늘어나는 유연성을 얻습니다 — Singleton<T, ThreadingPolicy, AllocPolicy>는 스레딩 정책 2가지와 할당 정책 2가지만 있어도 이론적으로 4가지 조합을 지원하며, 각 축이 독립적이므로 새 스레딩 정책 하나를 추가해도 기존 할당 정책들과 자동으로 조합됩니다(전통적 상속이었다면 조합마다 별도 클래스가 필요했을 조합 폭발 문제와 정반대 방향입니다). typename ThreadingPolicy::Lock처럼 정책 안에 중첩 타입(Lock)을 두는 것도 흔한 패턴인데, 이는 정책 하나가 “어떻게 락을 걸 것인가”뿐 아니라 “락을 표현하는 타입 자체”까지 함께 제공하도록 해, SingleThreaded::Lock은 아무 일도 안 하는 더미 타입으로, MultiThreaded::Lock은 실제 뮤텍스를 감싼 타입으로 서로 다르게 정의될 수 있게 합니다.
// 스레딩 정책
struct SingleThreaded {
struct Lock {
void lock() {}
void unlock() {}
};
};
struct MultiThreaded {
struct Lock {
mutex mtx;
void lock() { mtx.lock(); }
void unlock() { mtx.unlock(); }
};
};
// 할당 정책
struct NewAllocator {
template<typename T>
static T* allocate() {
return new T();
}
template<typename T>
static void deallocate(T* ptr) {
delete ptr;
}
};
struct PoolAllocator {
// 메모리 풀 구현
};
// 정책 기반 싱글톤
template<typename T, typename ThreadingPolicy, typename AllocPolicy>
class Singleton {
private:
static T* instance;
static typename ThreadingPolicy::Lock lock;
public:
static T& getInstance() {
if (!instance) {
lock.lock();
if (!instance) {
instance = AllocPolicy::template allocate<T>();
}
lock.unlock();
}
return *instance;
}
};
template<typename T, typename TP, typename AP>
T* Singleton<T, TP, AP>::instance = nullptr;
// 사용
using MySingleton = Singleton<Config, MultiThreaded, NewAllocator>;
실전 예시
예시 1: 스마트 포인터
이 예시는 Alexandrescu의 Modern C++ Design에서 정책 기반 설계를 소개할 때 쓴 대표적인 사례이자, 실제 std::shared_ptr와 std::unique_ptr가 왜 서로 다른 타입으로 나뉘어 있는지를 이해하는 데 도움이 됩니다 — 이 두 표준 스마트 포인터의 근본적인 차이(참조 카운팅 vs 배타적 소유)를 하나의 SmartPtr 템플릿에서 OwnershipPolicy만 바꿔 표현한 것입니다. RefCounted는 복사될 때마다 카운트를 늘리고 마지막 소유자가 사라질 때 실제로 해제하는 공유 소유권을, DestructiveCopy는 복사 시 자원 이전을 흉내 내는(실제로는 이 최소 구현이 진짜 이전을 하진 않지만) 배타적 소유권을 표현합니다. CheckingPolicy가 별도 축으로 분리되어 있는 것도 의미가 있습니다 — 디버그 빌드에서는 AssertCheck로 널 포인터 역참조를 즉시 잡아내고, 릴리스 빌드에서는 NoCheck로 그 오버헤드를 완전히 제거하는 식으로, 소유권 정책과는 완전히 독립적인 축을 자유롭게 조합할 수 있습니다.
// 소유권 정책
struct RefCounted {
int* count;
RefCounted() : count(new int(1)) {}
void acquire() { (*count)++; }
void release() {
if (--(*count) == 0) {
delete count;
}
}
int getCount() { return *count; }
};
struct DestructiveCopy {
void acquire() {}
void release() {}
int getCount() { return 1; }
};
// 체크 정책
struct NoCheck {
template<typename T>
static void check(T*) {}
};
struct AssertCheck {
template<typename T>
static void check(T* ptr) {
assert(ptr != nullptr);
}
};
// 정책 기반 스마트 포인터
template<typename T, typename OwnershipPolicy, typename CheckingPolicy>
class SmartPtr {
private:
T* ptr;
OwnershipPolicy ownership;
public:
SmartPtr(T* p) : ptr(p) {}
T& operator*() {
CheckingPolicy::check(ptr);
return *ptr;
}
T* operator->() {
CheckingPolicy::check(ptr);
return ptr;
}
SmartPtr(const SmartPtr& other) : ptr(other.ptr) {
ownership.acquire();
}
~SmartPtr() {
ownership.release();
}
};
using SharedPtr = SmartPtr<Widget, RefCounted, AssertCheck>;
using UniquePtr = SmartPtr<Widget, DestructiveCopy, NoCheck>;
예시 2: 로거
Logger가 OutputPolicy와 FormatPolicy를 (멤버가 아니라) private 상속한다는 점이 눈에 띕니다 — 이는 Empty Base Optimization(빈 베이스 클래스가 객체 크기를 늘리지 않는 최적화)을 활용하려는 의도적 선택으로, ConsoleOutput이나 SimpleFormat처럼 데이터 멤버가 전혀 없는 정책이라면 이를 멤버 변수로 두는 것보다 상속으로 포함하는 것이 Logger 객체의 크기를 늘리지 않을 수 있습니다(멤버로 두면 빈 클래스라도 최소 1바이트를 차지하는 것이 일반적이지만, 베이스 클래스는 이 규칙에서 예외적으로 최적화될 수 있습니다). “출력 방식”(콘솔 vs 파일)과 “포맷”(단순 vs 타임스탬프)이라는 두 완전히 독립적인 관심사를 별도 축으로 분리했기 때문에, Logger<FileOutput, SimpleFormat>이나 Logger<ConsoleOutput, TimestampFormat>처럼 서로 다른 조합도 코드 한 줄 추가 없이 자동으로 지원됩니다.
// 출력 정책
struct ConsoleOutput {
void write(const string& msg) {
cout << msg << endl;
}
};
struct FileOutput {
ofstream file;
FileOutput() : file("log.txt", ios::app) {}
void write(const string& msg) {
file << msg << endl;
}
};
// 포맷 정책
struct SimpleFormat {
string format(const string& msg) {
return msg;
}
};
struct TimestampFormat {
string format(const string& msg) {
auto now = chrono::system_clock::now();
auto time = chrono::system_clock::to_time_t(now);
return string(ctime(&time)) + ": " + msg;
}
};
// 정책 기반 로거
template<typename OutputPolicy, typename FormatPolicy>
class Logger : private OutputPolicy, private FormatPolicy {
public:
void log(const string& msg) {
string formatted = FormatPolicy::format(msg);
OutputPolicy::write(formatted);
}
};
int main() {
Logger<ConsoleOutput, SimpleFormat> console;
console.log("콘솔 로그");
Logger<FileOutput, TimestampFormat> file;
file.log("파일 로그");
}
예시 3: 컨테이너
실제 std::vector의 용량 증가 전략(대개 1.5배 또는 2배)은 표준에 의해 강제되지 않고 구현체마다 다를 수 있는데, 이 예시는 그 “성장 전략”이라는 하나의 세부 결정을 정책으로 분리해 사용자가 직접 선택할 수 있게 만든 것입니다. v1.getCapacity()가 128인 것과 v2.getCapacity()가 정확히 100인 것을 비교해 보면 두 정책의 실질적 차이가 드러납니다 — DoubleGrowth는 필요 용량을 넘어설 때까지 용량을 두 배씩 늘리므로(1→2→4→…→128) 재할당 횟수가 로그 스케일로 적지만 순간적으로 과할당된 미사용 공간이 생길 수 있고, LinearGrowth는 10씩 늘리므로(10→20→…→100) 메모리 낭비는 적지만 100개를 채우기까지 10번의 재할당이 필요합니다 — 이는 “시간 대 공간”이라는 고전적인 트레이드오프를 사용자가 직접 선택하게 열어둔 것입니다.
// 성장 정책
struct DoubleGrowth {
static size_t grow(size_t current) {
return current * 2;
}
};
struct LinearGrowth {
static size_t grow(size_t current) {
return current + 10;
}
};
// 정책 기반 벡터
template<typename T, typename GrowthPolicy>
class Vector {
private:
T* data;
size_t size;
size_t capacity;
public:
Vector() : data(nullptr), size(0), capacity(0) {}
void push_back(const T& value) {
if (size == capacity) {
size_t newCapacity = capacity == 0 ? 1 : GrowthPolicy::grow(capacity);
T* newData = new T[newCapacity];
for (size_t i = 0; i < size; i++) {
newData[i] = data[i];
}
delete[] data;
data = newData;
capacity = newCapacity;
}
data[size++] = value;
}
size_t getCapacity() const { return capacity; }
};
int main() {
Vector<int, DoubleGrowth> v1;
Vector<int, LinearGrowth> v2;
for (int i = 0; i < 100; i++) {
v1.push_back(i);
v2.push_back(i);
}
cout << "Double: " << v1.getCapacity() << endl; // 128
cout << "Linear: " << v2.getCapacity() << endl; // 100
}
정책 조합
정책 매개변수에 기본값(= SingleThreaded, = NewAllocator)을 지정해 두면, 대부분의 사용자는 정책을 하나도 명시하지 않고도 합리적인 기본 동작(SmartContainer<int> simple)을 얻으면서, 필요한 사람만 원하는 축만 골라 오버라이드(SmartContainer<int, MultiThreaded>)할 수 있습니다. 이는 정책 기반 설계가 라이브러리 설계에 특히 잘 맞는 이유이기도 합니다 — 라이브러리 저자는 합리적인 기본 정책 세트로 대부분의 사용자를 만족시키면서도, 특수한 요구사항(스레드 안전성, 커스텀 할당자)이 있는 소수의 사용자에게는 코드를 포크하거나 클래스를 재작성하지 않고도 정책만 바꿔 끼워 대응할 수 있는 확장점을 열어둘 수 있습니다.
template<
typename T,
typename ThreadingPolicy = SingleThreaded,
typename AllocPolicy = NewAllocator,
typename CheckingPolicy = NoCheck
>
class SmartContainer {
// 모든 정책 조합 가능
};
// 사용
SmartContainer<int> simple;
SmartContainer<int, MultiThreaded> threadSafe;
SmartContainer<int, MultiThreaded, PoolAllocator> optimized;
정책이 다르면 타입이 다르다
정책 기반 설계에서 가장 크게 체감되는 비용은 컴파일 시간보다 타입이 갈라진다는 점입니다. SmartPtr<Widget, RefCounted>와 SmartPtr<Widget, DeepCopy>는 이름이 비슷할 뿐 서로 아무 관계 없는 두 타입이라, 한쪽을 받는 함수에 다른 쪽을 넘길 수 없고, 같은 std::vector에 담을 수도 없습니다. 이 제약 때문에 정책을 받는 클래스를 인터페이스 경계(함수 인자, 공개 API 반환형)에 그대로 노출하면, 그 정책이 호출하는 모든 코드로 전염됩니다. 결국 모든 함수가 템플릿이 되거나, 조합마다 변환 생성자를 만들어야 합니다.
표준 라이브러리도 이 트레이드오프를 서로 다르게 선택했습니다. std::unique_ptr<T, Deleter>는 삭제자를 타입에 넣어 추가 비용이 없는 대신 삭제자가 다르면 다른 타입이고, std::shared_ptr<T>는 삭제자를 제어 블록 안에 타입 소거로 숨겨서 삭제자가 달라도 모두 같은 shared_ptr<T>로 다룰 수 있습니다. std::pmr::vector가 C++17에 추가된 것도 같은 이유입니다. 할당자를 템플릿 매개변수로 두는 기존 방식은 할당자마다 컨테이너 타입이 달라져 불편했기 때문에, 할당자를 런타임 포인터로 들고 다니는 다형 할당자를 따로 만든 것입니다. 정책을 설계할 때 “이 축이 타입 경계를 넘어 다녀야 하는가”를 먼저 묻고, 그렇다면 런타임 다형성이나 타입 소거가 더 나은 선택일 수 있습니다.
자주 발생하는 문제
문제 1: 정책 인터페이스 불일치
정책 기반 설계는 명시적인 인터페이스(순수 가상 함수 목록 같은)를 강제하지 않고 “덕 타이핑”에 의존합니다 — 클래스 템플릿은 그저 정책 타입이 특정 이름의 멤버 함수를 갖고 있다고 가정하고 호출할 뿐이며, 그 가정이 맞는지는 실제로 그 코드가 인스턴스화되는 순간에야 컴파일러가 확인합니다. 만약 어떤 정책이 execute()가 아니라 doSomething()이라는 다른 이름으로 같은 역할을 구현했다면, 그 정책을 넣는 순간 “멤버를 찾을 수 없다”는 컴파일 에러가 발생하는데, 이 에러 메시지가 인스턴스화 체인을 따라 여러 겹으로 나타나 실제 원인(이름 불일치)을 찾기 어렵게 만드는 경우가 흔합니다. 이런 문제를 예방하려면 정책들이 지켜야 할 인터페이스를 문서(또는 C++20이라면 concept)로 명확히 못 박아 두고, 팀 안에서 일관된 네이밍 컨벤션을 지키는 것이 실질적인 방어책입니다.
// ❌ 인터페이스 불일치
struct BadPolicy {
void doSomething() {} // 다른 이름
};
// ✅ 명확한 인터페이스
struct GoodPolicy {
void execute() {} // 통일된 이름
};
C++20부터는 이 문제를 concept으로 상당 부분 해결할 수 있습니다. 정책이 갖춰야 할 멤버를 concept으로 적어 두고 템플릿 매개변수를 template <ThreadingPolicy P>처럼 제약하면, 잘못된 정책을 넘겼을 때 수백 줄짜리 인스턴스화 오류 대신 “제약 조건 ThreadingPolicy<BadPolicy>를 만족하지 않음”과 어떤 요구 사항이 빠졌는지가 바로 나옵니다. concept은 동시에 정책 작성자를 위한 문서 역할도 합니다. C++17 이하라면 클래스 본문 첫 줄에 static_assert와 감지 관용구로 같은 효과를 흉내 낼 수 있습니다.
문제 2: 정책 상태
정책이 데이터 멤버(int state)를 가지면, 그 정책을 상속(또는 멤버로 포함)하는 클래스의 여러 인스턴스가 그 상태를 어떻게 공유하거나 분리해야 하는지가 곧바로 복잡한 문제가 됩니다 — 정책을 상속받은 클래스를 복사하면 그 상태도 함께 복사되어야 하는지, 여러 스레드가 같은 정책 인스턴스를 공유한다면 그 상태에 대한 동시 접근을 누가 보호해야 하는지 같은 질문들이 뒤따릅니다. StatelessPolicy처럼 정책을 순수하게 정적 멤버 함수(static void execute())로만 구성하면 이런 질문 자체가 사라집니다 — 상태가 없으므로 복사·공유·동시성 문제가 원천적으로 발생하지 않고, 정책은 순수하게 “어떤 알고리즘을 쓸 것인가”라는 선택만 표현하게 됩니다. 앞서 예시들에서 RefCounted처럼 예외적으로 상태(카운터)를 가진 정책이 필요한 경우도 있지만, 그런 경우는 그 상태 관리 책임을 명확히 문서화해야 한다는 부담이 함께 따라온다는 것을 이 대조가 보여줍니다.
// ❌ 정책에 상태 저장
struct StatefulPolicy {
int state; // 문제 발생 가능
};
// ✅ 상태 없는 정책
struct StatelessPolicy {
static void execute() {}
};
상태 없는 정책을 private 상속하는 이유는 빈 베이스 최적화(EBO) 때문입니다. 멤버로 두면 빈 구조체라도 최소 1바이트와 정렬 패딩을 차지하지만, 빈 베이스 클래스는 크기 0으로 합쳐질 수 있습니다. 다만 상속은 정책의 멤버 이름이 클래스 안으로 들어와 이름 충돌이나 의도치 않은 공개가 생길 수 있다는 부작용이 있습니다. C++20의 [[no_unique_address]] 속성을 멤버에 붙이면 상속 없이도 같은 크기 절약을 얻을 수 있어서, 새 코드에서는 이 방식을 쓰는 편이 이름 관리가 깔끔합니다(MSVC는 [[msvc::no_unique_address]]를 써야 실제로 적용되는 점에 주의하세요).
문제 3: 복잡도 증가
정책의 축이 하나씩 늘어날 때마다 지원해야 하는 조합의 수는 곱셈으로 늘어납니다 — 축 3개가 각각 2가지 선택지만 있어도 이미 8가지 조합이고, 그 모든 조합이 실제로 의미 있고 테스트되었는지 검증하는 비용도 함께 늘어납니다. OverEngineered<P1, P2, P3, P4, P5>처럼 다섯 개의 독립적인 정책 축을 두면, 이론적으로는 매우 유연하지만 실제로는 대부분의 조합이 한 번도 쓰이지 않거나 심지어 무의미한(서로 호환되지 않는) 조합일 수 있습니다. 실무에서는 “실제로 필요한 조합이 몇 가지인가”를 먼저 파악하고, 그 조합들을 표현하는 데 꼭 필요한 축(보통 2~3개)만 정책으로 남기며, 자주 쓰이는 조합은 using 별칭으로 이름을 붙여 두는 것이 유연성과 복잡도 사이의 균형을 잡는 실용적인 접근입니다.
// 너무 많은 정책은 복잡도 증가
template<typename P1, typename P2, typename P3, typename P4, typename P5>
class OverEngineered {
// 유지보수 어려움
};
// 적절한 수준 유지
template<typename ThreadingPolicy, typename AllocPolicy>
class Reasonable {
// 관리 가능
};
FAQ
Q1: Policy-Based Design은 언제 사용하나요?
A: 동작의 한 축(잠금 방식, 할당 방식, 오류 처리 방식 등)을 사용하는 쪽이 컴파일 시점에 고를 수 있어야 하고, 그 선택이 런타임에 바뀌지 않으며, 가상 호출이나 분기 비용을 없애는 것이 의미가 있을 때입니다. 라이브러리처럼 사용자가 다양한 환경에서 쓰는 코드에 잘 맞고, 애플리케이션 내부 코드에서 조합이 한두 가지뿐이라면 평범한 클래스나 if constexpr 한 번으로 충분한 경우가 많습니다.
Q2: Strategy 패턴과 차이는?
A:
- Policy: 컴파일 타임, 오버헤드 없음
- Strategy: 런타임, 가상 함수 오버헤드
Q3: 단점은?
A:
- 코드 복잡도 증가
- 컴파일 시간 증가
- 에러 메시지 복잡
Q4: 정책은 몇 개가 적당한가요?
A: 2-4개 정도가 적당합니다. 너무 많으면 복잡해집니다.
Q5: 정책 변경은 런타임에 가능한가요?
A: 아니요. 컴파일 타임에 결정됩니다. 런타임 변경이 필요하면 Strategy 패턴을 사용하세요.
Q6: 정책을 받는 클래스끼리 서로 대입하거나 비교하려면 어떻게 하나요?
A: 정책이 다르면 다른 타입이라 기본적으로는 불가능합니다. 의미가 맞는 경우에만 template <typename OtherPolicy> SmartPtr(const SmartPtr<T, OtherPolicy>&) 같은 변환 생성자를 명시적으로 제공합니다. 이런 변환이 여기저기 필요해진다면 그 축은 타입이 아니라 런타임 값이나 타입 소거로 다루는 것이 맞다는 신호입니다. 원래 개념은 Andrei Alexandrescu의 Modern C++ Design에서 체계적으로 소개되었습니다.