C++ 템플릿 메타프로그래밍 심화 — SFINAE·타입 특성·태그 디스패치
이 글의 핵심
템플릿 메타프로그래밍(TMP)의 내부 동작(SFINAE와 즉시 맥락), 타입 특성의 구현 원리, 태그 디스패치와 if constexpr의 차이, 컴파일 타임 계산 전략, 실제 코드베이스에서 쓰이는 패턴을 정리합니다.
템플릿 메타프로그래밍(TMP)은 타입과 정수 템플릿 인자, 그리고 특수화와 재귀를 이용해 컴파일 타임에 타입을 계산하고 코드를 선택하는 C++ 기법입니다. 이 글은 문법 요약보다 네 가지 질문에 초점을 둡니다. 컴파일러는 어떤 규칙으로 템플릿 후보를 거르는가(SFINAE), <type_traits>는 어떤 원리로 쌓여 있는가, 타입별 분기를 오버로드로 둘 것인가 if constexpr로 둘 것인가, 그리고 값 계산은 어디에 두는 것이 좋은가입니다. 먼저 C++ 템플릿 기초: 함수·클래스 템플릿, 가변 인자, SFINAE에서 Concepts까지, C++ type traits 활용: 표준 traits, std::enable_if, 커스텀 traits 작성, SFINAE 패턴 정리: enable_if, void_t, Detection Idiom과 Concepts로 옮길 시점를 읽어 두면 흐름을 따라가기 쉽습니다.
SFINAE: 치환 실패는 왜 에러가 아닌가
SFINAE(Substitution Failure Is Not An Error)는 함수 템플릿의 오버로드 해석 단계에서 적용되는 규칙입니다. 컴파일러는 호출 인자로부터 템플릿 인자를 추론한 뒤, 그 인자를 함수 템플릿의 선언에 치환합니다. 이때 선언이 형식상 성립하지 않으면 그 후보는 에러 없이 버려지고, 남은 후보 중에서 선택이 이루어집니다. 반면 이미 선택된 함수의 본문을 인스턴스화하다가 규칙을 어기면 그것은 SFINAE가 아니라 하드 에러입니다.
치환이 일어나는 곳
SFINAE가 적용되는 곳은 함수 템플릿의 반환 타입, 매개변수 타입, 템플릿 매개변수 목록(기본 템플릿 인자 포함), 그리고 C++11부터는 그 안에 쓰인 decltype 표현식입니다. 클래스 템플릿의 부분 특수화를 고를 때도 같은 원리가 적용되어, 특수화의 템플릿 인자 치환이 실패하면 그 특수화는 후보에서 빠집니다.
다음 예는 std::enable_if_t로 반환 타입을 조건부로 만듭니다. 조건이 거짓이면 enable_if에 type 멤버가 없으므로 치환이 실패하고, 그 오버로드는 후보에서 제거됩니다.
#include <type_traits>
template <class T>
std::enable_if_t<std::is_integral_v<T>, T> f(T x) { return x; }
template <class T>
std::enable_if_t<std::is_floating_point_v<T>, T> f(T x) { return x * 2; }
T = int이면 첫 번째만, T = double이면 두 번째만 치환에 성공합니다. 둘 다 실패하는 타입(예: std::string)이 들어오면 후보가 하나도 남지 않아 “no matching function” 에러가 납니다.
표현식 SFINAE
C++11부터는 임의의 표현식이 유효한지에 따라 치환 성공과 실패를 나눌 수 있습니다. C++17의 std::void_t와 함께 쓰면 “타입에 멤버 함수 foo()가 있는가” 같은 질문에 답하는 탐지(detection) 관용구가 됩니다.
#include <type_traits>
#include <utility>
template <class, class = void>
struct has_foo : std::false_type {};
template <class T>
struct has_foo<T, std::void_t<decltype(std::declval<T&>().foo())>>
: std::true_type {};
template <class T>
inline constexpr bool has_foo_v = has_foo<T>::value;
decltype(std::declval<T&>().foo())가 성립하지 않으면 부분 특수화의 치환이 실패하고, 기본 템플릿(false_type)이 선택됩니다. 요점은 호출 표현식을 decltype 안, 즉 타입이 필요한 자리에 두어서 실제로 호출하지 않고 유효성만 검사한다는 것입니다. std::declval은 평가되지 않는 문맥에서만 쓸 수 있습니다.
SFINAE가 적용되지 않는 경우
선택된 함수의 본문에서만 드러나는 오류, 예를 들어 본문에서 그 타입이 지원하지 않는 연산을 쓰는 경우는 하드 에러입니다. static_assert로 의도적으로 막는 경우도 후보 제거가 아니라 즉시 실패입니다. 또 여러 후보가 똑같이 잘 맞아서 모호해지는 것은 SFINAE와 관계없는 오버로드 해석 에러입니다.
C++20 Concepts로 제약을 선언부에 옮기면 같은 논리를 훨씬 읽기 쉽게 표현할 수 있습니다. 하지만 표준 라이브러리 구현과 레거시 코드는 여전히 SFINAE 기반이 많아서, 에러 메시지를 해석하려면 이 규칙을 알아야 합니다.
즉시 맥락과 하드 에러
표준은 치환 실패 중에서도 함수 타입과 템플릿 매개변수 타입의 “즉시 맥락(immediate context)” 안에서 일어난 것만 SFINAE로 처리합니다. 치환 과정에서 다른 클래스 템플릿을 인스턴스화해야 하고, 그 클래스의 정의 안에서 오류가 나면 그것은 즉시 맥락 밖이므로 하드 에러입니다.
template <class T>
struct Wrapper {
using type = typename T::value_type; // T에 value_type이 없으면 여기서 하드 에러
};
template <class T>
typename Wrapper<T>::type g(T); // 즉시 맥락: Wrapper<T>::type 조회
int g(...);
// g(42); // Wrapper<int>를 인스턴스화하다가 클래스 정의 안에서 실패 → 하드 에러
g(42)는 가변 인자 오버로드로 떨어질 것 같지만, Wrapper<int>의 정의를 인스턴스화하는 도중에 오류가 나므로 컴파일 에러입니다. 그래서 라이브러리를 설계할 때는 조건을 반환 타입, 템플릿 매개변수, decltype, requires 표현식처럼 후보 선택 단계에서 직접 검사되는 위치에 두고, 중간 클래스 템플릿의 정의 안에서야 실패하는 구조는 피해야 합니다. 저도 SFINAE 기반 코드를 디버깅하면서 “왜 이 후보가 조용히 빠지지 않고 에러가 나지?”라는 상황을 여러 번 만났는데, 대부분 이 즉시 맥락 규칙 때문이었습니다.
Concepts와 오버로드
template <class T> requires std::integral<T> R f(T);는 제약이 만족되지 않으면 후보를 제거한다는 점에서 SFINAE와 같은 효과를 냅니다. 차이는 에러 메시지(어떤 제약이 실패했는지 직접 보고), 이름 붙은 콘셉트의 재사용성, 그리고 콘셉트 간 포함(subsumption)에 따른 오버로드 우선순위에 있습니다. 새 코드는 Concepts로 쓰고, C++17 이하를 지원해야 하는 라이브러리나 서드파티 코드에서는 SFINAE 관용구를 읽을 수 있어야 합니다.
포함 관계에 의한 우선순위는 생각보다 좁게 동작합니다. 아래처럼 std::integral 버전과 직접 만든 Addable 버전을 같은 이름으로 두면, int는 두 제약을 모두 만족하지만 두 concept 사이에 포함 관계가 없으므로 add(1, 2)는 모호한 호출 에러가 됩니다.
#include <concepts>
template <class T>
concept Addable = requires(T a, T b) { { a + b } -> std::convertible_to<T>; };
template <std::integral T> T add(T a, T b) { return a + b; }
template <Addable T> T add(T a, T b) { return a + b; }
// add(1, 2); // 에러: 두 후보 중 어느 쪽도 더 제약되지 않음
컴파일러가 “더 구체적인 쪽”을 고르려면 한 concept이 다른 concept을 이름으로 && 조합해 포함해야 합니다. concept SignedIntegral = std::integral<T> && std::is_signed_v<T>;는 std::integral을 포함하므로 둘이 경쟁하면 SignedIntegral 쪽이 선택됩니다. 같은 조건을 requires 식이나 std::is_integral_v<T>로 다시 써 넣으면 내용이 같아도 포함 관계로 인정되지 않습니다. 또 concept은 전방 선언하거나 재귀적으로 정의할 수 없으므로, 서로를 참조하는 두 concept이 필요해 보이면 공통 조건을 별도 concept으로 뽑아 한 방향으로 쌓아야 합니다.
타입 특성의 구현 원리
<type_traits>의 도구 대부분은 몇 개의 작은 빌딩 블록을 조합해 만들어집니다. 실제 표준 라이브러리 구현은 컴파일러 내장 함수(__is_class 등)를 많이 쓰기 때문에 아래와 똑같지는 않지만, 원리는 같습니다.
integral_constant
template <class T, T v>
struct integral_constant {
static constexpr T value = v;
using value_type = T;
using type = integral_constant;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator()() const noexcept { return value; }
};
using true_type = integral_constant<bool, true>;
using false_type = integral_constant<bool, false>;
template <class T, class U> struct is_same : false_type {};
template <class T> struct is_same<T, T> : true_type {};
is_same처럼 대부분의 술어 트레이트는 기본 템플릿이 false_type을, 조건에 맞는 부분 특수화가 true_type을 상속하는 구조입니다. ::type을 쓰지 않도록 하는 _t 별칭 템플릿(std::remove_cv_t 등)은 C++14에, ::value를 쓰지 않도록 하는 _v 변수 템플릿(std::is_same_v 등)은 C++17에 추가되었습니다.
conditional: 타입 수준의 if
template <bool B, class T, class F>
struct conditional { using type = T; };
template <class T, class F>
struct conditional<false, T, F> { using type = F; };
template <bool B, class T, class F>
using conditional_t = typename conditional<B, T, F>::type;
런타임 분기가 아니라, 인스턴스화 시점에 두 타입 중 하나만 남깁니다. 주의할 점은 conditional_t<B, X, Y>의 X와 Y가 둘 다 유효한 타입이어야 한다는 것입니다. conditional_t<std::is_pointer_v<T>, std::remove_pointer_t<T>, typename T::element_type>처럼 선택되지 않는 쪽이 성립하지 않는 타입이면 에러가 나므로, 그런 경우에는 지연 평가를 위해 ::type을 한 단계 늦춰 꺼내는 기법이나 if constexpr 기반 헬퍼를 씁니다.
탐지 관용구의 확장
앞의 void_t 패턴은 표현식 하나를 검사할 때 적합합니다. 요구 사항이 여러 개라면 검사할 표현식을 별칭 템플릿으로 이름 붙이고 std::experimental::is_detected(Library Fundamentals TS v2) 같은 헬퍼로 조합하거나, C++20의 requires 표현식으로 논리를 드러내는 편이 유지보수에 낫습니다.
탐지 관용구에서 흔한 함정은 표현식이 유효하다는 것만 확인하고 결과 타입은 확인하지 않는 것입니다. 예를 들어 size()가 있는지만 보면 size()가 std::string을 반환하는 이상한 타입도 통과합니다. 반환 타입까지 제한해야 한다면 std::is_convertible_v나 std::is_same_v로 decltype 결과를 한 번 더 검사하거나, Concepts의 { t.size() } -> std::convertible_to<std::size_t> 형태를 씁니다.
conjunction과 disjunction의 단락 평가
C++17의 std::conjunction, std::disjunction, std::negation은 단순히 &&로 값을 합치는 것과 다릅니다. conjunction<A, B, C>는 A::value가 거짓이면 B와 C의 ::value를 인스턴스화하지 않습니다. 이 차이는 뒤쪽 트레이트가 앞 조건이 참일 때만 성립하는 경우에 중요합니다.
#include <type_traits>
template <class T, class = void>
struct has_value_type : std::false_type {};
template <class T>
struct has_value_type<T, std::void_t<typename T::value_type>> : std::true_type {};
// value_type이 있을 때만 그 value_type이 정수인지 검사
template <class T>
struct value_type_is_integral : std::is_integral<typename T::value_type> {};
template <class T>
inline constexpr bool int_container_v =
std::conjunction_v<has_value_type<T>, value_type_is_integral<T>>;
static_assert(!int_container_v<int>); // int::value_type을 조회하지 않음
// 반면 아래처럼 쓰면 T = int일 때 value_type_is_integral<int>를 인스턴스화하다가 하드 에러
// template <class T>
// inline constexpr bool bad_v = has_value_type<T>::value && value_type_is_integral<T>::value;
태그 디스패치 vs if constexpr
둘 다 타입에 따라 다른 구현을 고르는 방법이지만 메커니즘이 다릅니다. 태그 디스패치는 오버로드 해석에 선택을 맡기고, if constexpr는 하나의 함수 안에서 컴파일 타임에 가지를 칩니다.
태그 디스패치: 오버로드 해석에 맡긴다
빈 구조체인 태그 타입을 추가 인자로 넘기고, 오버로드 해석이 가장 잘 맞는 구현을 고르게 합니다. 표준 라이브러리의 이터레이터 카테고리 태그(input_iterator_tag 등)가 대표적인 예입니다.
#include <cstddef>
#include <iterator>
template <class Iter>
void advance_impl(Iter& it, std::ptrdiff_t n, std::random_access_iterator_tag) {
it += n;
}
template <class Iter>
void advance_impl(Iter& it, std::ptrdiff_t n, std::bidirectional_iterator_tag) {
if (n >= 0) while (n--) ++it;
else while (n++) --it;
}
template <class Iter>
void advance_impl(Iter& it, std::ptrdiff_t n, std::input_iterator_tag) {
// 입력/전진 이터레이터는 뒤로 갈 수 없으므로 n은 0 이상이어야 함
while (n-- > 0) ++it;
}
template <class Iter>
void my_advance(Iter& it, std::ptrdiff_t n) {
using Cat = typename std::iterator_traits<Iter>::iterator_category;
advance_impl(it, n, Cat{});
}
이 구조가 잘 동작하는 이유는 태그들 사이의 상속 관계입니다. forward_iterator_tag는 input_iterator_tag를, bidirectional_iterator_tag는 forward_iterator_tag를 상속하므로, 전용 오버로드가 없는 forward_iterator_tag는 파생-기반 변환으로 input_iterator_tag 오버로드에 자동으로 매칭됩니다. C++20의 contiguous_iterator_tag도 random_access_iterator_tag를 상속하므로 코드를 고치지 않아도 올바른 구현이 선택됩니다. 구현이 함수별로 분리되어 있어 각 경로를 따로 테스트하고 디버깅하기도 쉽습니다.
if constexpr: 하나의 함수 안에서 가지 치기
#include <cstddef>
#include <iterator>
#include <type_traits>
template <class Iter>
void my_advance2(Iter& it, std::ptrdiff_t n) {
using Cat = typename std::iterator_traits<Iter>::iterator_category;
if constexpr (std::is_base_of_v<std::random_access_iterator_tag, Cat>) {
it += n;
} else if constexpr (std::is_base_of_v<std::bidirectional_iterator_tag, Cat>) {
if (n >= 0) while (n--) ++it;
else while (n++) --it;
} else {
while (n-- > 0) ++it;
}
}
로직이 한곳에 모여 있어 전체 흐름을 읽기 쉽습니다. 대신 태그 디스패치가 상속 관계로 자동 처리하던 부분을 is_base_of_v 순서로 직접 표현해야 하고, 분기 순서를 잘못 두면(예: bidirectional 검사를 random access보다 앞에 두면) 더 구체적인 구현이 가려집니다. 또 이 함수는 닫혀 있어서, 라이브러리 사용자가 자기 이터레이터 카테고리용 구현을 추가하려면 함수를 직접 고쳐야 합니다.
선택 기준
| 상황 | 추천 |
|---|---|
| 사용자가 오버로드를 추가해 확장해야 함 | 태그 디스패치 또는 자유 함수 오버로드(ADL 확장 지점) |
| 분기가 적고 한 모듈 안에서 닫혀 있음 | if constexpr |
| 태그 사이에 계층(상속) 관계가 있음 | 태그 디스패치 |
| 컴파일 시간·바이너리 크기를 줄이고 싶음 | 공개 진입점만 템플릿으로 두고 타입 비의존 로직은 .cpp의 비템플릿 함수로 분리 |
정리하면, “앞으로 다른 타입이 추가될 수 있고 그 구현을 외부에서 제공해야 하는가?”에 예라고 답하면 태그 디스패치나 오버로드가, 아니라면 if constexpr가 대체로 맞습니다.
컴파일 타임 계산 전략
고전적 방법: 템플릿 재귀
template <unsigned N>
struct Fib {
static constexpr unsigned value = Fib<N - 1>::value + Fib<N - 2>::value;
};
template <>
struct Fib<0> { static constexpr unsigned value = 0; };
template <>
struct Fib<1> { static constexpr unsigned value = 1; };
static_assert(Fib<10>::value == 55);
재귀를 타입으로 펼칩니다. 흥미로운 점은 호출 구조가 지수적인데도 인스턴스 수는 N에 비례한다는 것입니다. 같은 템플릿 인자로 이미 인스턴스화된 클래스는 컴파일러가 재사용하므로 Fib<5>는 한 번만 만들어집니다. 다만 인스턴스 하나하나가 컴파일러 안에 심볼로 남아 메모리를 차지하고, 재귀 깊이에는 컴파일러 제한(GCC와 Clang 기본 900~1024 수준, -ftemplate-depth로 조정)이 있습니다.
현대적 방법: constexpr 함수
constexpr unsigned fib(unsigned n) {
unsigned a = 0, b = 1;
for (unsigned i = 0; i < n; ++i) {
unsigned c = a + b;
a = b;
b = c;
}
return a;
}
static_assert(fib(10) == 55);
C++14부터 constexpr 함수에서 반복문과 지역 변수를 쓸 수 있으므로 평범한 코드처럼 쓰고, 런타임에도 같은 함수를 호출할 수 있습니다. 반복문을 쓰면 재귀 깊이 제한에 걸리지 않지만, 대신 컴파일러의 상수 평가 단계 수 제한(-fconstexpr-steps, -fconstexpr-ops-limit 등)이 적용됩니다.
C++20 consteval
consteval 함수(즉시 함수)는 반드시 컴파일 타임에 평가되어야 하며, 런타임 값을 인자로 넘기면 컴파일 에러입니다. 포맷 문자열 검사(std::format이 이 방식을 씁니다)나 컴파일 타임 해시처럼 “실수로 런타임에 계산되면 안 되는” 계산에 적합합니다.
가변 인자 템플릿과 fold expression
template <class... Ts>
constexpr auto sum(Ts... args) {
return (args + ... + 0); // 이항 우측 fold, 인자가 없어도 0
}
static_assert(sum(1, 2, 3, 4) == 10);
값 계산은 constexpr/consteval 함수에, 타입 목록의 조합이나 변환 같은 타입 수준 연산은 템플릿에 맡기는 식으로 역할을 나누면 읽기 쉽고 빌드 시간도 예측하기 쉬워집니다.
실제 코드베이스에서 쓰이는 패턴
제약은 한곳에 이름을 붙여서
C++20에서는 concept에 이름을 붙여 헤더에 API 계약으로 드러냅니다. 그 이전 표준에서는 enable_if와 void_t 조건을 is_container_v 같은 이름 있는 변수 템플릿이나 별칭 템플릿으로 만들어 재사용합니다. 같은 조건을 여러 함수 선언에 복사해 두면 한 곳만 고쳐서 오버로드 사이의 조건이 어긋나는 일이 생기기 쉽습니다.
타입 의존 부분과 비의존 부분 분리
고성능 라이브러리는 타입에 따라 달라지는 핫 경로만 템플릿으로 두고, 타입과 무관한 로직은 공통 비템플릿 함수로 모읍니다. 예를 들어 컨테이너의 메모리 재할당 로직을 요소 크기와 정렬만 받는 비템플릿 함수로 빼면, 요소 타입마다 같은 코드가 인스턴스화되는 것을 막을 수 있습니다. 자주 쓰는 인스턴스는 헤더에 extern template으로 선언하고 한 .cpp에서 명시적으로 인스턴스화하면 다른 번역 단위의 중복 인스턴스화도 줄어듭니다.
CRTP와 정적 다형성
template <class Derived>
struct Interface {
void tick() { static_cast<Derived*>(this)->do_tick(); }
};
struct Timer : Interface<Timer> {
void do_tick() { /* ... */ }
};
가상 함수 테이블 없이 인터페이스 형태를 유지하고, 호출이 컴파일 타임에 결정되어 인라인화할 수 있습니다. 대신 Interface<Timer>와 Interface<Counter>는 서로 무관한 타입이라 한 컨테이너에 담아 다형적으로 다룰 수 없고, 플러그인처럼 런타임에 구현을 로드하는 구조와는 맞지 않습니다. C++23의 명시적 객체 매개변수(deducing this)를 쓰면 CRTP 없이도 같은 효과를 더 간단히 낼 수 있습니다.
빌드 비용 관리
템플릿이 많은 헤더를 여러 번역 단위가 포함하면 파싱과 인스턴스화가 반복됩니다. PCH나 C++20 모듈로 헤더 파싱 비용을 줄이고, 공개 헤더가 포함하는 표준 헤더를 최소화하는 것이 기본입니다. 무엇이 느린지는 추측하지 말고 Clang의 -ftime-trace로 생성한 트레이스를 Chrome의 트레이스 뷰어나 ClangBuildAnalyzer로 보면, 어떤 템플릿 인스턴스화가 시간을 쓰는지 바로 드러납니다. 이렇게 찾아보면 원인이 복잡한 메타프로그램 자체보다 “모든 파일이 포함하는 헤더 하나에 있는 무거운 템플릿”인 경우가 흔합니다. 포함 횟수가 곱해지기 때문입니다.
표현식 템플릿
표현식 템플릿은 a + b * c 같은 연산을 바로 계산하지 않고 연산 트리를 타입으로 기록해 두었다가, 대입 시점에 원소 단위 루프 하나로 한 번에 계산하는 기법입니다. Eigen 같은 선형대수 라이브러리가 중간 임시 벡터의 할당과 복사를 없애기 위해 씁니다. 대가는 긴 타입 이름으로 된 에러 메시지와 늘어나는 컴파일 시간입니다. 또 auto x = a + b;로 받으면 결과 벡터가 아니라 a와 b에 대한 참조를 가진 표현식 객체가 저장되므로, a나 b가 먼저 소멸하면 댕글링 참조가 됩니다. Eigen 문서가 표현식 템플릿 타입에 auto를 쓰지 말라고 따로 경고하는 이유입니다.
더 넓은 흐름은 메타프로그래밍의 진화를, 실전 오류 대응은 C++ 템플릿 에러 메시지 읽는 법을 참고하면 좋습니다.
자주 묻는 질문 (FAQ)
Q. SFINAE로 후보에서 빠질 줄 알았는데 하드 에러가 나는 이유는 무엇인가요?
SFINAE는 함수 템플릿의 선언부, 즉 반환 타입과 매개변수, 템플릿 매개변수를 치환하는 즉시 맥락에서 생긴 실패에만 적용됩니다. 선택된 함수의 본문 안에서 지원하지 않는 연산을 쓰거나, static_assert로 막았거나, 치환 도중 인스턴스화한 다른 클래스 템플릿의 정의 안에서 오류가 나면 후보 제거가 아니라 곧바로 컴파일 에러가 납니다. 조건을 선언부의 enable_if나 decltype, 또는 C++20 Concepts의 requires 절로 옮겨야 원하는 대로 오버로드에서 제외됩니다.