C++ 템플릿 인자 추론 규칙: 값·참조·전달 참조, decay, CTAD

이 글의 핵심

make_pair나 make_unique처럼 타입을 적지 않아도 동작하는 코드는 모두 템플릿 인자 추론에 기대고 있습니다. 이 글은 값·참조·포인터로 받을 때 추론 결과가 어떻게 달라지는지 비교하고, add(1, 2.0) 같은 호출이 실패하는 이유와 에러 메시지 읽기, static_assert와 Concepts로 추론 결과를 확인하는 방법을 보여줍니다.

템플릿 인자 추론이란?

함수 템플릿 호출 시 타입 인자를 생략하면, 컴파일러가 인자식으로부터 타입을 추론합니다. 이를 템플릿 인자 추론(template argument deduction)이라고 합니다. 실무에서는 std::make_pair, std::make_unique처럼 타입을 적지 않고 쓰는 경우가 이 추론에 의존합니다.

기본 예시

template<typename T>
T add(T a, T b) {
    return a + b;
}
int main() {
    add(1, 2);      // T = int
    add(1.0, 2.0);  // T = double
    // add(1, 2.0); // 오류: T가 int와 double로 모호
    add<double>(1, 2.0);  // 명시적 지정
    return 0;
}

컴파일러는 인자마다 따로 매개변수 타입과 인자 타입을 맞춰 T 후보를 구하고, 모든 인자에서 나온 T가 같으면 추론에 성공합니다. add(1, 2.0)에서는 첫 번째 인자가 T = int, 두 번째 인자가 T = double을 요구해 충돌하므로 실패합니다. GCC는 다음과 같은 메시지를 냅니다.

error: no matching function for call to 'add(int, double)'
note:   template argument deduction/substitution failed:
note:   deduced conflicting types for parameter 'T' ('int' and 'double')

일반 함수 double add(double, double)이라면 1이 double로 변환되어 아무 문제 없이 호출됩니다. 템플릿에서 안 되는 이유는 추론이 변환을 고려하지 않기 때문입니다. 추론 단계는 “인자의 타입을 그대로 보고 T에 무엇을 넣으면 매개변수 타입과 정확히 맞는가”만 따지고, 암시적 변환은 T가 정해진 뒤에 적용됩니다. 그래서 add<double>(1, 2.0)처럼 T를 먼저 정해 주면, 이제 일반 함수처럼 1이 double로 변환됩니다.

이 규칙을 알면 “추론은 첫 번째 인자에서만 하고 두 번째는 변환을 허용하라”는 의도를 코드로 표현할 수도 있습니다. C++20의 std::type_identity_t<T>로 매개변수 타입을 감싸면 그 자리는 추론하지 않는 문맥(non-deduced context)이 되므로, T add(T a, std::type_identity_t<T> b)는 add(1, 2.5)에서 T = int로 정하고 2.5를 int로 변환합니다. 결과가 3이 되는 것처럼 의도하지 않은 좁히기 변환이 숨어들 수 있으므로, 이 기법은 첫 인자가 기준 타입이라는 것이 명확한 API에만 쓰는 것이 좋습니다.


추론 규칙 상세

규칙 1: 값 전달 시 decay

값으로 받으면 최상위 const와 volatile, 참조가 제거되고, 배열과 함수는 포인터로 decay됩니다.

template<typename T>
void by_value(T x) { (void)x; }
int i = 0;
const int ci = 10;
int& ref = i;
int arr[5];
by_value(i);    // T = int
by_value(ci);   // T = int (const 제거)
by_value(ref);  // T = int (참조 제거)
by_value(arr);  // T = int* (배열 decay)

값 전달은 복사본을 만들기 때문에 원본이 const였는지, 참조로 접근했는지는 복사본과 상관이 없습니다. 배열은 크기 정보를 잃고 포인터가 됩니다.

규칙 2: 참조 전달 시 타입 유지

T&로 받으면 인자의 const는 T에 남고, 인자가 참조라는 사실은 무시됩니다(T 자체가 참조 타입으로 추론되지는 않습니다).

template<typename T>
void by_ref(T& x) { (void)x; }
int i = 0;
const int ci = 10;
by_ref(i);   // T = int
by_ref(ci);  // T = const int (const 유지)
// by_ref(5);  // 에러: 임시 객체는 비const 참조로 받을 수 없음

인자를 수정해야 하면 T&, 읽기만 한다면 임시 객체도 받을 수 있는 const T&, 받은 인자를 값 범주 그대로 다른 함수에 넘겨야 하면 뒤에서 다룰 전달 참조 T&&를 씁니다. T&에 const 객체를 넘기면 T가 const int가 되므로 함수 안에서 수정하려는 코드는 컴파일 에러가 납니다.

규칙 3: 포인터 전달

포인터로 받으면 가리키는 대상의 const는 T에 남고, 포인터 자체의 const는 무시됩니다.

template<typename T>
void by_ptr(T* p) { (void)p; }
int x = 10;
const int cx = 20;
by_ptr(&x);   // T = int
by_ptr(&cx);  // T = const int (포인터가 가리키는 대상이 const)

세 규칙을 하나로 묶어 보면, 추론은 “매개변수 타입의 모양(T, T&, T*)을 인자 타입에 겹쳐 놓고 남는 부분을 T로 삼는” 과정입니다. T*에 const int*를 겹치면 *가 맞물리고 const int가 남습니다. 값 전달에서 const가 사라지는 것은 복사본의 const 여부는 원본과 상관없기 때문이고, 포인터가 가리키는 대상의 const는 복사해도 여전히 같은 대상을 가리키므로 유지됩니다. 즉 by_value에 const int* const p를 넘기면 T는 const int*로, 포인터 자체의 const(최상위 const)만 떨어집니다.

초기화 리스트는 특별한 경우입니다. f({1, 2, 3})처럼 중괄호를 그대로 넘기면 중괄호 목록에는 타입이 없어서 couldn't deduce template parameter 'T' 에러가 납니다. auto x = {1, 2, 3};은 std::initializer_list<int>로 추론되는데 템플릿은 안 되는 것이 auto와 템플릿 추론의 거의 유일한 차이이며, 받아야 한다면 매개변수를 std::initializer_list<T>로 선언해야 합니다.


실무 예시

예시 1: 타입 불일치 해결

template<typename T>
T add(T a, T b) { return a + b; }
// ❌ 에러
// add(1, 2.0);  // T = int? double?

첫 번째 방법은 템플릿 인자를 명시하는 것입니다.

add<double>(1, 2.0);  // T = double, 1이 double로 변환됨

두 번째 방법은 템플릿 매개변수를 둘로 나누는 것입니다.

template<typename T1, typename T2>
auto add(T1 a, T2 b) {
    return a + b;  // 반환 타입은 자동 추론
}
add(1, 2.0);  // T1 = int, T2 = double, 반환 = double

세 번째 방법은 반환 타입을 std::common_type_t로 정하는 것입니다.

template<typename T1, typename T2>
std::common_type_t<T1, T2> add(T1 a, T2 b) {
    return a + b;
}
add(1, 2.0);  // 반환 = double (공통 타입)

두 개의 템플릿 인자와 auto 반환이 가장 유연하지만, 유연함에는 대가가 있습니다. auto 반환은 a + b의 결과 타입을 그대로 쓰므로, short 두 개를 더하면 정수 승격 때문에 int가 반환되고, unsigned와 int를 섞으면 unsigned가 되어 음수가 매우 큰 양수로 바뀝니다. std::common_type_t도 int와 unsigned의 공통 타입을 unsigned로 정하므로 이 함정을 피하지 못합니다. “아무 타입이나 받는다”가 오히려 버그를 숨기는 경우가 있으므로, 부호가 섞이는 것을 막고 싶다면 C++20 concept로 두 타입의 부호가 같을 때만 호출되게 제한하는 편이 안전합니다.

예시 2: 완벽한 전달 (Perfect Forwarding)

template<typename T>
void wrapper(T&& arg) {  // universal reference
    process(std::forward<T>(arg));
}
int x = 10;
wrapper(x);        // T = int&, lvalue 전달
wrapper(10);       // T = int, rvalue 전달
wrapper(std::move(x));  // T = int, rvalue 전달

T&&는 universal reference(표준 용어로는 전달 참조, forwarding reference)로, lvalue가 오면 T = int&, rvalue가 오면 T = int로 추론됩니다. 이것은 전달 참조에만 적용되는 특별한 추론 규칙이고, 그렇게 정해진 T = int&를 매개변수 T&&에 대입하면 int& &&가 되는데 이를 int&로 접는 것이 reference collapsing입니다. 두 규칙이 함께 동작해 lvalue는 lvalue 참조로, rvalue는 rvalue 참조로 받게 됩니다.

전달 참조가 되려면 형태가 정확히 T&&이고 T가 그 함수 템플릿에서 추론되는 매개변수여야 합니다. const T&&는 rvalue 참조일 뿐이고, 클래스 템플릿 Box<T>의 멤버 함수 void set(T&& v)도 T가 클래스 생성 시점에 이미 정해져 있으므로 rvalue 참조입니다. 이 경우 box.set(x)처럼 lvalue를 넘기면 “cannot bind rvalue reference of type ‘int&&’ to lvalue of type ‘int’” 에러가 나는데, 전달 참조로 착각한 코드에서 흔히 보는 메시지입니다. std::vector<T>&&처럼 T를 다른 타입으로 감싼 경우도 전달 참조가 아닙니다. 팩토리 함수와 래퍼 함수가 대표적인 사용처입니다.

// make_unique의 단순화된 구현
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
auto ptr = make_unique<Widget>(10, "hello");
// Args = int, const char(&)[6] 추론 (문자열 리터럴은 배열의 lvalue)

이 예제는 명시 지정과 추론이 섞이는 방식을 보여 줍니다. T는 인자에서 추론할 수 없으므로(Widget은 어떤 인자에도 나타나지 않습니다) 반드시 <Widget>으로 지정하고, 뒤의 Args...는 인자에서 추론합니다. 템플릿 매개변수 목록에서 추론할 수 없는 것을 앞에 두는 이유가 이것입니다. 순서를 반대로 template<typename... Args, typename T>로 두면 T를 지정할 방법이 없어집니다. 문자열 리터럴이 const char*가 아니라 const char(&)[6]로 추론된다는 점도 눈여겨볼 만합니다. 전달 참조는 decay를 하지 않으므로, 길이가 다른 문자열 리터럴을 넘길 때마다 다른 인스턴스가 만들어집니다.

std::forward<T>(arg) 대신 std::move(arg)를 쓰면 lvalue로 넘긴 인자까지 이동되어, 호출한 쪽의 변수가 몰래 비워지는 버그가 생깁니다. 반대로 std::forward 없이 process(arg)만 쓰면 이름이 있는 arg는 항상 lvalue이므로 rvalue로 넘겨도 복사가 일어납니다. 둘 다 컴파일은 잘 되고 결과만 달라서, 이동 생성자에 로그를 찍어 보기 전에는 알아채기 어렵습니다.

배열·함수와 decay

배열이나 함수를 값으로 넘기면 decay가 적용되어 포인터로 추론됩니다. 크기 정보가 필요하면 참조로 받아 T (&)[N] 형태로 추론되게 할 수 있습니다.

#include <cstddef>
#include <iostream>
template<typename T>
void f(T x) { (void)x; }
template<typename T, std::size_t N>
void g(T (&a)[N]) { 
    std::cout << "Array size: " << N << '\n';
}
int arr[3] = {1, 2, 3};
f(arr);   // T = int* (크기 정보 손실)
g(arr);   // T = int, N = 3 (크기 정보 유지)

컴파일 시점에 배열 크기가 필요할 때 이 형태를 씁니다.

template<typename T, std::size_t N>
constexpr std::size_t array_size(T (&)[N]) {
    return N;
}
int arr[] = {1, 2, 3, 4, 5};
std::cout << array_size(arr) << '\n';  // 5
// C++17 이후는 std::size 사용
std::cout << std::size(arr) << '\n';  // 5

클래스 템플릿: CTAD (C++17)

생성자 인자로부터 클래스 템플릿 인자를 추론할 수 있습니다. 표준 라이브러리의 std::pair, std::vector, std::optional 등이 이렇게 쓸 수 있습니다.

std::pair p(1, 2.0);       // std::pair<int, double>
std::vector v = {1, 2, 3}; // std::vector<int>
std::optional opt(42);     // std::optional<int>
std::tuple t(1, "hello", 3.14);  // std::tuple<int, const char*, double>

컴파일러는 생성자의 인자 타입에서 템플릿 인자를 역으로 추론합니다. std::pair(1, 2.0)은 std::pair<int, double>(1, 2.0) 생성자를 호출하는 것과 같습니다. 사용자 정의 클래스 템플릿도 생성자에서 그대로 추론할 수 있고, 생성자만으로는 원하는 타입이 나오지 않을 때는 deduction guide를 직접 정의합니다.

// 사용자 정의 클래스
template<typename T>
class Container {
    T value_;
public:
    Container(T v) : value_(v) {}
};
// ✅ CTAD 사용
Container c(42);  // Container<int>
// Deduction guide 예시
template<typename T>
Container(T) -> Container<T>;  // 명시적 가이드 (보통 자동 생성됨)

반복자 범위에서 원소 타입을 추론하는 경우는 생성자만으로는 불가능해서, 표준 라이브러리가 가이드를 제공합니다.

std::vector<int> v = {1, 2, 3};
// 반복자 범위로 vector 생성 (CTAD)
std::vector v2(v.begin(), v.end());  // std::vector<int>
// deduction guide가 이를 가능하게 함:
// template<typename It>
// vector(It, It) -> vector<typename std::iterator_traits<It>::value_type>;

// ⚠️ 중괄호로 쓰면 결과가 완전히 달라짐
std::vector v3{v.begin(), v.end()};  // std::vector<std::vector<int>::iterator> (원소 2개)

CTAD에서 가장 유명한 함정이 마지막 줄입니다. 중괄호 초기화는 initializer_list 생성자를 우선하므로, v3은 정수 벡터가 아니라 반복자 두 개를 원소로 가진 벡터가 됩니다. 컴파일 에러는 나지 않고, 나중에 v3[0] + 1 같은 코드에서 알 수 없는 타입 에러로 드러납니다. 반복자 범위나 크기를 넘길 때는 소괄호를 쓰는 것이 안전합니다. 비슷하게 std::vector v4{v};는 “v를 원소로 가진 벡터”가 아니라 v의 복사본(std::vector<int>)으로 추론되는데, 이는 복사 추론 후보가 우선하도록 표준이 정했기 때문입니다. CTAD는 편리하지만 이런 경계 사례에서 직관과 다르게 동작하므로, 타입이 코드의 의미에 중요한 곳에서는 여전히 명시하는 편이 읽는 사람에게 친절합니다.

CTAD는 내부적으로 생성자마다 가상의 함수 템플릿(암시적 추론 가이드)을 만들어 일반 함수 템플릿 추론을 돌리는 방식이라, 생성자 인자에 나타나지 않는 템플릿 매개변수는 추론할 수 없습니다. 위 vector(It, It)처럼 직접 가이드를 적어야 하는 이유가 이것이고, Box(const char*) -> Box<std::string>;처럼 인자와 다른 타입으로 저장하게 만들 때도 가이드를 씁니다. 반대로 함수 템플릿과 다른 점도 있습니다. f<int>(1, 2.0)처럼 앞쪽만 명시하고 나머지를 추론하는 것이 CTAD에서는 불가능해서, std::pair<int> p(1, 2.0);은 에러이고 전부 추론하거나 전부 명시해야 합니다. 또 생성자를 explicit으로 만들어도 추론은 막히지 않으므로(복사 초기화만 막힘), 사용자가 반드시 템플릿 인자를 적게 하려면 매개변수를 std::type_identity_t<T>(C++20)처럼 추론되지 않는 문맥으로 만들어야 합니다.

컨테이너 키 타입은 CTAD에 맡기지 않는 것이 원칙입니다. std::map m = {{"a", 1}, {"b", 2}};는 안쪽 중괄호에 타입이 없어 추론에 실패하고, std::pair{"a", 1}로 감싸면 컴파일은 되지만 키가 const char*로 추론되어 문자열 내용이 아니라 포인터 주소로 비교하는 map이 됩니다. 같은 리터럴이 같은 주소로 합쳐지는 테스트에서는 통과하다가 런타임에 만든 문자열로 find하면 못 찾는 식으로 드러납니다. 반대로 std::lock_guard lock(m);처럼 템플릿 인자가 바로 옆 인자의 타입을 반복할 뿐인 곳이 CTAD를 쓰기 가장 좋은 자리입니다. C++20부터는 생성자가 없는 집합체(template<class T> struct Point { T x, y; }; Point p{1, 2};)와 별칭 템플릿도 추론 대상이 되었지만 컴파일러별 지원 시점이 달랐으므로, 여러 컴파일러를 지원하는 코드라면 먼저 확인합니다.


추론 실패 시 디버깅

std::max에 int와 long을 넘겼을 때 템플릿 인자 추론 충돌 에러

그림: 실제 실행 결과 — 두 인자에서 T를 각각 int와 long으로 추론해 충돌한다. std::max(a, b)처럼 타입을 직접 지정하거나 한쪽을 캐스트한다. (환경: Ubuntu 24.04 · g++ 13.3)

에러 메시지 읽기

템플릿 관련 에러는 길지만, 어디를 봐야 하는지 알면 읽을 만합니다.

template<typename T>
void process(T& x) { x.foo(); }
int main() {
    int x = 10;
    process(x);  // 에러: int에는 foo() 없음
}

Clang은 다음과 같이 보고합니다.

error: no member named 'foo' in 'int'
note: in instantiation of function template specialization 'process<int>' requested here
    process(x);

“in instantiation of” 줄에서 T가 무엇으로 추론되었는지 확인하고, 템플릿 정의로 돌아가 그 타입이 본문의 요구 사항을 만족하는지 보면 됩니다.

이 예제는 엄밀히 말하면 추론 실패가 아니라 인스턴스화 실패입니다. 추론은 T = int로 성공했고, 그 T로 함수 본문을 만들다가 int에 foo()가 없어 에러가 난 것입니다. 두 에러는 메시지 모양이 다릅니다. 추론 실패는 호출 지점에서 “no matching function for call”과 함께 후보마다 template argument deduction/substitution failed라는 메모가 붙고, 인스턴스화 실패는 템플릿 본문 안의 줄에서 에러가 나며 “in instantiation of … requested here”로 호출 지점을 거슬러 알려 줍니다. 전자는 호출하는 쪽 인자를 고쳐야 하고, 후자는 그 타입이 템플릿의 요구 사항을 만족하지 못한다는 뜻이라 고칠 곳이 다릅니다. 추론된 타입 자체가 궁금할 때는 template<typename> struct TypeIs;처럼 정의 없는 템플릿에 TypeIs<T> probe;를 넣어 보면, 에러 메시지에 추론된 T가 그대로 찍힙니다.

static_assert와 Concepts로 요구 사항 드러내기

static_assert를 본문 앞쪽에 두면 요구 사항을 읽기 쉬운 메시지로 먼저 보여 줄 수 있습니다. 다만 인스턴스화 안에서 검사하므로 뒤따르는 본문 에러도 함께 출력될 수 있습니다.

template<typename T>
void process(T& x) {
    static_assert(std::is_class_v<T>, "T must be a class type");
    x.foo();
}

C++20 Concepts로 제약하면 요구 사항이 함수 선언의 일부가 되어, 조건을 만족하지 않는 타입은 본문을 인스턴스화하기 전에 후보에서 제외됩니다.

template<typename T>
concept HasFoo = requires(T t) {
    { t.foo() } -> std::same_as<void>;
};
template<HasFoo T>
void process(T& x) {
    x.foo();
}
// process(x) (x는 int): 제약 HasFoo<int>를 만족하지 않는다는 에러가 호출 지점에서 남

규칙 전체는 cppreference의 Template argument deduction 문서에 정리돼 있습니다.