C++17 CTAD: 클래스 템플릿 인자 추론 원리와 추론 가이드가 필요한 경우

이 글의 핵심

C++17 CTAD는 생성자마다 만들어지는 암시적 추론 가이드로 클래스 템플릿 인자를 추론합니다. 반복자 쌍 생성자처럼 사용자 정의 추론 가이드가 꼭 필요한 경우와, std::vector{v}가 복사가 되는 이유, std::map을 중첩 중괄호로 추론할 수 없는 이유, explicit이 추론을 막지 못하는 이유를 GCC로 확인한 결과와 함께 정리합니다.

들어가며

C++17에서 도입된 CTAD(Class Template Argument Deduction)는 클래스 템플릿 인자를 자동으로 추론하여 코드를 간결하게 만들어줍니다.


CTAD 기본

C++17의 CTAD(Class Template Argument Deduction)는 함수 템플릿이 인자로 T를 추론하듯, 클래스 템플릿의 인자를 생성자 인자로부터 추론합니다.

// C++14: 타입 명시
std::pair<int, double> p(1, 3.14);
std::vector<int> vec = {1, 2, 3};

// C++17: 타입 추론
std::pair p(1, 3.14);          // pair<int, double>
std::vector vec = {1, 2, 3};   // vector<int>
std::tuple t(1, 2.0, "Hi");    // tuple<int, double, const char*>
std::optional o(42);           // optional<int>
std::array a = {1, 2, 3};      // array<int, 3>

동작 원리는 단순합니다. 컴파일러는 클래스 템플릿의 생성자마다 가상의 함수 템플릿(암시적 추론 가이드)을 하나씩 만들고, 평범한 함수 템플릿 오버로드 해석을 돌려 이긴 쪽의 반환 타입을 객체 타입으로 씁니다. template<class T> struct Box { Box(T); };라면 template<class T> Box(T) -> Box<T>;가 자동으로 있는 셈입니다. 그래서 생성자 인자에서 모든 템플릿 매개변수를 추론할 수 있으면 별도 작업 없이 CTAD가 되고, 그렇지 않을 때만 사용자 정의 추론 가이드가 필요합니다.

아래 예제의 동작과 에러 메시지는 모두 GCC 10.3(-std=c++20)으로 컴파일해 확인했습니다.


사용자 정의 추론 가이드가 필요한 경우

경우 1: 다른 타입으로 저장하고 싶을 때

template<typename T>
class Box {
    T value;
public:
    Box(T v) : value(v) {}
};

Box(const char*) -> Box<std::string>;   // 문자열 리터럴은 std::string으로 저장

Box b("hi");   // Box<std::string>
Box n(42);     // Box<int> (암시적 가이드)

가이드가 없으면 Box b("hi")는 Box<const char*>가 되어, 리터럴이 아닌 임시 문자열을 넘기는 순간 댕글링 포인터를 저장하게 됩니다. 추론 가이드는 생성자와 별개로 “이 인자 형태면 이 타입으로 만들어라”를 지정하는 선언이라, 생성자는 T를 받는 그대로 두고 추론 결과만 바꿀 수 있습니다.

경우 2: 반복자 쌍 생성자

template<typename T>
class Stack {
    std::vector<T> data_;
public:
    template<typename It>
    Stack(It first, It last) : data_(first, last) {}
};

std::vector<int> v{1, 2, 3};
Stack s(v.begin(), v.end());   // ❌ error: class template argument deduction failed

생성자 매개변수에는 It만 있고 T는 어디에도 나오지 않으므로, 암시적 가이드로는 T를 알 수 없습니다. 표준 컨테이너가 쓰는 것과 같은 가이드를 추가합니다.

template<typename It>
Stack(It, It) -> Stack<typename std::iterator_traits<It>::value_type>;

Stack s(v.begin(), v.end());   // ✅ Stack<int>

std::vector v2(v.begin(), v.end());가 vector<int>로 추론되는 것도 표준 라이브러리에 똑같은 가이드가 들어 있기 때문입니다. 실무에서 사용자 정의 가이드가 가장 많이 필요한 경우가 이 반복자 생성자입니다.


자주 틀리는 부분

문제 1: 한 원소짜리 중괄호는 “복사”가 이긴다

std::vector vec{1, 2, 3};
std::vector vec2{vec};        // vector<int>, 크기 3  ← vector<vector<int>>가 아님
std::vector vec4{vec, vec};   // vector<vector<int>>, 크기 2

vec2{vec}는 vec을 원소로 하는 vector<vector<int>>가 될 것 같지만, 실제로는 vec의 복사본(vector<int>, 크기 3)입니다. CTAD에는 “같은 템플릿의 특수화 하나로 초기화하면 복사로 본다”는 복사 추론 후보가 있고, 인자가 하나일 때는 이것이 우선합니다. 원소가 둘 이상이면 비로소 initializer_list 쪽으로 추론됩니다. 이 글의 이전 버전도 vec2.size()를 1이라고 적었다가, 실제로 돌려 보고 3이 나오는 것을 확인해 고쳤습니다. 템플릿 코드에서 std::vector{x}로 “x 하나를 담은 벡터”를 만들려다 x가 이미 벡터인 경우에만 동작이 바뀌는 버그로 이어지므로, 그런 곳에서는 std::vector<T>{x}처럼 인자를 명시합니다. 사용자 클래스도 마찬가지로, Wrapper w3(w1);은 Wrapper<Wrapper<int>>가 아니라 Wrapper<int>의 복사입니다.

문제 2: 중첩 중괄호에서는 추론하지 못한다

std::map m = {{"a", 1}, {"b", 2}};                     // ❌ class template argument deduction failed
std::map m2 = {std::pair{"a", 1}, std::pair{"b", 2}};  // 컴파일은 되지만 map<const char*, int>
std::map<std::string, int> m3 = {{"a", 1}, {"b", 2}};  // ✅ 의도한 타입

{"a", 1} 같은 안쪽 중괄호는 그 자체로 타입이 없어서 pair<Key, T>의 Key와 T를 추론할 근거가 되지 못합니다. std::pair{...}로 감싸면 컴파일은 되지만, 키가 std::string이 아니라 const char*로 추론되어 문자열 내용이 아니라 포인터 주소로 비교하는 map이 됩니다. 같은 문자열 리터럴이 우연히 같은 주소로 합쳐지는 환경에서는 테스트를 통과하다가, 런타임에 만든 문자열로 find하면 못 찾는 식으로 드러나는 고약한 버그입니다. 키 타입이 중요한 컨테이너는 CTAD 대신 타입을 명시하는 것이 원칙입니다. 같은 이유로 std::vector<std::vector<T>>를 받는 생성자에 {{1, 2}, {3, 4}}를 넘기는 코드도 추론에 실패합니다.

문제 3: 문자열 리터럴은 const char*

std::pair p("Hello", "World");   // pair<const char*, const char*>

std::string을 기대했다면 std::pair<std::string, std::string>으로 명시하거나, using namespace std::string_literals; 후 "Hello"s처럼 std::string 리터럴을 씁니다. 직접 만든 클래스라면 경우 1처럼 추론 가이드로 해결할 수 있습니다.

문제 4: explicit로는 CTAD를 막을 수 없다

template<typename T>
struct NoDeduction {
    explicit NoDeduction(T) {}
};
NoDeduction n(42);   // 컴파일됨: NoDeduction<int>

explicit은 NoDeduction n = 42; 같은 복사 초기화만 막을 뿐 추론 자체에는 영향이 없습니다. 사용자가 반드시 템플릿 인자를 적게 하려면(예: 단위 타입을 실수로 추론하지 않도록) 매개변수를 추론되지 않는 문맥으로 만듭니다.

template<typename T>
struct Blocked {
    Blocked(std::type_identity_t<T>) {}   // C++20 (C++17이면 직접 만든 type_identity)
};
Blocked b(1);          // ❌ class template argument deduction failed
Blocked<int> b2(1);    // ✅

문제 5: 추론할 수 없는 가이드는 조용히 무시된다

template<typename T>
TypedId(std::string) -> TypedId<T>;   // T를 인자에서 알 수 없음

이런 가이드는 컴파일 에러 없이 선언되지만, T를 추론할 방법이 없으니 절대 선택되지 않습니다. 결과적으로 TypedId id("U001");은 여전히 실패하고, 사용자는 TypedId<User>처럼 명시해야 합니다. 태그 타입(User, Post)으로 구분하는 강타입 ID처럼 일부러 명시하게 만든 설계라면 가이드를 아예 쓰지 않는 것이 의도를 더 잘 드러냅니다.

문제 6: 일부만 명시하고 나머지를 추론할 수는 없다

std::pair<int> p(1, 2.0);        // ❌ 템플릿 인자 부족 (나머지를 추론하지 않음)
std::pair p2(1, 2.0);            // ✅ 전부 추론
std::pair<int, double> p3(1, 2); // ✅ 전부 명시

함수 템플릿은 f<int>(1, 2.0)처럼 앞쪽 인자만 명시하고 나머지를 추론할 수 있지만, CTAD는 전부 추론하거나 전부 명시하거나 둘 중 하나입니다. 템플릿 인자 목록을 하나라도 적으면 CTAD는 일어나지 않고 일반적인 템플릿 인자 규칙이 적용됩니다(기본 템플릿 인자가 있는 경우는 예외처럼 보이지만, 그것도 추론이 아니라 기본값 채우기입니다). 그래서 std::array<int> a = {1, 2, 3};처럼 원소 타입만 적고 크기를 추론하는 것도 불가능하며, 이 경우 C++20의 std::to_array<int>({1, 2, 3})를 쓰면 됩니다.

문제 7: CTAD가 동작하지 않는 자리

CTAD는 초기화식이 있는 변수 선언과 함수형 표기(Box(42)) 에서만 동작합니다. 초기화식이 없는 std::vector v;는 추론할 근거가 없어 에러이고, 클래스의 비정적 멤버 선언(struct S { std::vector v = {1, 2}; };)에서는 기본 멤버 초기화식이 있어도 CTAD를 쓸 수 없습니다. 함수 매개변수 타입에도 쓸 수 없는데, 이 자리에서 “타입을 추론하고 싶다”면 CTAD가 아니라 함수 템플릿이나 C++20 약식 템플릿(void f(std::vector<auto>)는 불가, void f(auto v)는 가능)을 써야 합니다.

C++20에서는 적용 범위가 두 방향으로 넓어졌습니다. 생성자가 없는 집합체(aggregate) 도 멤버 초기화로부터 추론할 수 있게 되어 template<class T> struct Point { T x, y; }; Point p{1, 2};가 Point<int>로 추론되고(GCC 10 이상, Clang 17 이상), 별칭 템플릿(template<class T> using Vec = std::vector<T>;)으로도 Vec v = {1, 2, 3};처럼 추론할 수 있습니다. 컴파일러마다 지원 시점이 달랐으므로, 여러 컴파일러를 지원해야 하는 라이브러리라면 이 두 기능에 기대기 전에 대상 컴파일러에서 먼저 확인하는 편이 좋습니다.


언제 CTAD를 쓰고 언제 피하나

상황권장
지역 변수, 인자 타입이 명확함 (std::pair p(1, 2.0))CTAD 사용
std::lock_guard lock(m);, std::scoped_lock lock(a, b);CTAD 사용 (가장 흔한 좋은 예)
키·값 타입이 중요한 컨테이너 (map, set)타입 명시
원소 하나를 중괄호로 감싸는 제네릭 코드타입 명시 (문제 1)
문자열 리터럴이 들어가는 경우가이드나 s 리터럴, 또는 명시
공개 API의 반환 타입·멤버 타입명시 (읽는 사람이 추론 규칙을 몰라도 되게)

CTAD 자체는 컴파일 시간에 끝나므로 런타임 비용이 없습니다. 판단 기준은 성능이 아니라 읽는 사람이 타입을 바로 알 수 있는가입니다.

lock_guard가 가장 좋은 예로 꼽히는 이유는 템플릿 인자가 정보를 전혀 더하지 않기 때문입니다. std::lock_guard<std::mutex> lock(m);의 <std::mutex>는 바로 옆의 m 타입을 반복할 뿐이고, 나중에 m을 std::shared_mutex로 바꾸면 이 줄까지 함께 고쳐야 합니다. 반대로 map의 키 타입은 문자열 비교 방식이라는 의미를 담고 있어서, 추론에 맡기면 문제 2처럼 의미가 조용히 바뀝니다. 이 구분이 CTAD를 쓸지 판단하는 가장 실용적인 기준입니다.

직접 만든 클래스가 CTAD로 쓰일 때 의도대로 동작하는지 확신이 없다면, Clang의 -Wctad-maybe-unsupported 경고를 켜 볼 만합니다. 사용자 정의 추론 가이드가 하나도 없는 클래스 템플릿에 CTAD를 쓰면 “이 타입이 CTAD를 고려해 설계되었는지 알 수 없다”고 알려 줍니다. 라이브러리 작성자는 CTAD를 지원할 의도가 있다면 template<class T> Box(T) -> Box<T>;처럼 암시적 가이드와 똑같은 가이드라도 명시적으로 적어 두어 의도를 표시하는 것이 관례입니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. CTAD를 쓰면 코드가 느려지나요?

A. 아닙니다. 추론은 컴파일 중에 끝나고 결과 타입은 직접 적은 것과 똑같습니다. CTAD를 쓸지 말지는 성능이 아니라 읽는 사람이 타입을 바로 알 수 있는지(4절 표)로 정하면 됩니다.