C++ 클래스 템플릿: 멤버 외부 정의, 부분 특수화, 템플릿 별칭, CTAD로 제네릭 컨테이너 만들기

초보자를 위한 한 줄: template <typename T> class Stack처럼 “타입을 끼워 넣는 설계도”를 한 벌 만들고, 멤버 함수 정의도 헤더(또는 명시적 인스턴스화)에 둡니다. 함수 템플릿은 9-1에서 익힌 뒤 이 글을 보면 연결이 잘 됩니다.

들어가며: int 스택, double 스택… 계속 만들어야 하나?

간단한 스택 자료구조를 만들었습니다. 하지만 타입마다 클래스를 복사해야 했습니다.

문제의 코드:

class IntStack {
    std::vector<int> data;
public:
    void push(int value) { data.push_back(value); }
    int pop() {
        int value = data.back();
        data.pop_back();
        return value;
    }
    bool empty() const { return data.empty(); }
};
class DoubleStack {
    std::vector<double> data;
public:
    void push(double value) { data.push_back(value); }
    double pop() {
        double value = data.back();
        data.pop_back();
        return value;
    }
    bool empty() const { return data.empty(); }
};
// 타입마다 클래스 추가...

IntStack과 DoubleStack은 push/pop/empty 로직이 같고 저장하는 요소 타입만 다릅니다. 새 타입마다 클래스를 복사하면 유지보수 비용이 늘고, 한쪽만 수정하면 동작이 어긋날 수 있어, “요소 타입만 다른” 구조는 클래스 템플릿 하나로 통합하는 편이 좋습니다.

설정값 저장소처럼 타입만 다른 클래스가 늘어나는 경우

설정값 저장소

설정 시스템에서 int, double, std::string, bool 등 여러 타입의 값을 저장해야 합니다. 타입마다 IntConfig, StringConfig를 만들면 코드 중복이 폭발합니다.

네트워크 버퍼 풀

패킷 버퍼를 uint8_t[1024], uint8_t[4096] 등 크기별로 관리할 때, 크기마다 클래스를 복사하면 템플릿 비타입 인자(size_t N)로 한 번에 해결할 수 있습니다.

직렬화 래퍼

JSON, Protobuf 등 직렬화 대상 타입이 User, Order, Product 등 수십 개일 때, 각 타입마다 UserSerializer, OrderSerializer를 만들면 템플릿 하나로 통합할 수 있습니다.

캐시 컨테이너

키-값 캐시에서 키 타입(std::string, int64_t)과 값 타입(User, std::vector<Item>) 조합이 많을 때, template <typename K, typename V> class Cache로 제네릭하게 처리할 수 있습니다. 클래스 템플릿(타입을 인자로 받아 여러 타입에 대해 같은 구조의 클래스를 생성하는 틀)을 쓰면 Stack<int>, Stack<double>처럼 타입만 바꿔서 같은 로직을 재사용할 수 있으며, 컴파일러가 타입별로 코드를 생성합니다. 함수 템플릿과 마찬가지로 “동작은 같은데 요소 타입만 다를 때” 클래스 템플릿으로 통합하면 유지보수가 쉬워집니다. 클래스 템플릿으로 해결 (아래는 복사해 붙여넣은 뒤 g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl 로 실행 가능):

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl
#include <vector>
#include <iostream>
#include <string>
#include <stdexcept>
template <typename T>
class Stack {
    std::vector<T> data;
public:
    void push(const T& value) { data.push_back(value); }
    T pop() {
        if (empty()) throw std::logic_error("Stack is empty");
        T value = data.back();
        data.pop_back();
        return value;
    }
    bool empty() const { return data.empty(); }
    size_t size() const { return data.size(); }
};
int main() {
    Stack<int> intStack;
    intStack.push(10);
    intStack.push(20);
    std::cout << intStack.pop() << "\n";  // 20
    Stack<std::string> strStack;
    strStack.push("hello");
    strStack.push("world");
    std::cout << strStack.pop() << "\n";  // world
    return 0;
}

template <typename T> class Stack으로 한 번 정의하면 Stack<int>, Stack<std::string> 등 타입만 바꿔 사용할 수 있습니다. data는 std::vector<T>로 요소 타입에 맞게 저장되고, pop 시 빈 스택이면 logic_error를 던지도록 했습니다. 클래스 템플릿은 사용할 때 타입을 반드시 지정해야 합니다(Stack<int> 등). 실행 결과: 아래 명령으로 실행하면 20 과 world 가 각각 한 줄씩 출력됩니다.

g++ -std=c++17 -o stack_tpl stack_tpl.cpp && ./stack_tpl

클래스 템플릿 인스턴스화 흐름
컴파일러는 Stack<int>, Stack<std::string>처럼 사용할 때마다 해당 타입에 맞는 클래스를 생성합니다. 아래 다이어그램은 템플릿 정의에서 구체적인 타입이 생성되는 과정을 보여 줍니다.

flowchart TB
  subgraph template[템플릿 정의]
    T[template &lt;typename T&gt;\nclass Stack]
  end
  subgraph instances[인스턴스화된 타입]
    I1[Stack&lt;int&gt;]
    I2[Stack&lt;double&gt;]
    I3["Stack&lt;std string&gt;"]
  end
  T -->|T=int| I1
  T -->|T=double| I2
  T -->|T=std::string| I3

함수 템플릿과 달리, 클래스 템플릿은 사용할 때 타입을 반드시 명시해야 합니다. Stack<int>, Stack<std::string>처럼요. C++17부터는 생성자 인자로부터 타입을 추론하는 CTAD가 도입되어 Stack s(1);처럼 쓸 수 있는 경우도 있지만, 클래스 이름만으로는 “어떤 타입의 Stack인지” 컴파일러가 알 수 없기 때문에 대부분의 코드에서는 여전히 Stack<int>처럼 적어 줍니다.

클래스 템플릿 기본 문법

클래스 템플릿은 타입을 매개변수로 받는 클래스입니다. std::vector<int>, std::map<std::string, int>가 모두 클래스 템플릿의 인스턴스입니다. 한 번 정의해 두면 정수 벡터, 문자열 벡터, 사용자 정의 타입 벡터를 같은 코드로 다룰 수 있어서, STL처럼 재사용 가능한 자료 구조를 만들 때 필수입니다.

기본 선언과 사용

#include <iostream>
#include <string>
template <typename T>
class Box {
    T value;
    
public:
    Box(const T& v) : value(v) {}
    
    T get() const { return value; }
    void set(const T& v) { value = v; }
};
int main() {
    Box<int> intBox(42);
    std::cout << intBox.get() << "\n";  // 42
    
    Box<std::string> strBox("hello");
    std::cout << strBox.get() << "\n";  // hello
    
    // ❌ 에러: 클래스 템플릿은 타입 추론 안 됨 (C++17 전)
    // Box box(42);
    
    // ✅ C++17: CTAD (Class Template Argument Deduction)
    Box box(42);  // Box<int>로 추론
}

Box<T>는 타입 T 하나를 매개변수로 받는 클래스 템플릿입니다. Box<int>, Box<std::string>처럼 사용할 때 타입을 명시해야 하며, C++17에서는 생성자 인자로부터 타입을 추론하는 CTAD로 Box box(42)처럼 쓸 수 있습니다. 함수 템플릿은 func(42)처럼 인자만으로 T를 추론할 수 있지만, 클래스는 생성자 호출 전에 “이 객체의 타입”이 정해져야 합니다. 따라서 C++17 이전에는 항상 Box<int>, Box<std::string>처럼 타입을 써 줘야 했습니다.

여러 타입 매개변수

#include <iostream>
#include <string>
template <typename K, typename V>
class KeyValue {
    K key;
    V value;
    
public:
    KeyValue(const K& k, const V& v) : key(k), value(v) {}
    
    K getKey() const { return key; }
    V getValue() const { return value; }
};
int main() {
    KeyValue<std::string, int> kv("age", 25);
    std::cout << kv.getKey() << ": " << kv.getValue() << "\n";
    // age: 25
}

KeyValue<K, V>는 키 타입 K와 값 타입 V 두 개의 타입 매개변수를 가집니다. KeyValue<std::string, int>처럼 사용하면 “age”: 25 같은 키-값 쌍을 타입 안전하게 저장할 수 있으며, map이나 pair처럼 제네릭한 자료 구조를 만들 때 쓰는 패턴입니다.

기본 템플릿 인자

#include <vector>
#include <deque>
template <typename T, typename Container = std::vector<T>>
class Stack {
    Container data;
    
public:
    void push(const T& value) {
        data.push_back(value);
    }
    
    T pop() {
        T value = data.back();
        data.pop_back();
        return value;
    }
};
int main() {
    Stack<int> stack1;  // std::vector<int> 사용
    Stack<int, std::deque<int>> stack2;  // std::deque<int> 사용
}

두 번째 템플릿 인자 Container에 기본값 std::vector<T>를 주면 Stack<int>만 써도 내부적으로 std::vector<int>를 사용합니다. Stack<int, std::deque<int>>처럼 두 번째 인자를 넘기면 deque 기반 스택으로 바꿀 수 있어, STL의 기본 인자 패턴과 같습니다. 기본 템플릿 인자를 두면 사용하는 쪽에서 생략할 수 있어서, Stack<int>만 써도 내부적으로 std::vector<int>를 쓰도록 할 수 있습니다. std::vector도 실제로는 할당자(allocator) 같은 두 번째 인자가 있지만 기본값이 있어서 보통은 vector<int>만 씁니다.


멤버 함수를 클래스 밖에서 정의하는 문법과 정적 멤버

클래스 내부 정의 (인라인)

template <typename T>
class Container {
    T value;
    
public:
    void set(const T& v) {
        value = v;  // 클래스 내부 정의
    }
    
    T get() const {
        return value;
    }
};

멤버 함수를 클래스 안에 그대로 두면 자동으로 인라인으로 취급됩니다. 템플릿 클래스도 본문이 짧으면 이렇게 내부 정의로 두는 경우가 많고, 컴파일러가 인스턴스화할 때 정의를 볼 수 있어야 하므로 보통 같은 헤더에 둡니다.

클래스 외부 정의

template <typename T>
class Container {
    T value;
    
public:
    void set(const T& v);
    T get() const;
};
// 외부 정의 시 template 키워드 필요
template <typename T>
void Container<T>::set(const T& v) {
    value = v;
}
template <typename T>
T Container<T>::get() const {
    return value;
}

멤버를 클래스 밖에 정의할 때는 template <typename T>를 다시 쓰며, 함수 이름을 Container<T>::set처럼 “클래스명<T>::멤버명”으로 써야 합니다. 이 정의도 사용하는 쪽에서 인스턴스화할 수 있도록 헤더에 두지 않으면 링크 에러가 납니다. 주의: 외부 정의도 헤더 파일에 작성해야 함 (링크 에러 방지) 함수 템플릿과 마찬가지로, 클래스 템플릿의 멤버 함수도 “어떤 타입으로 인스턴스화되는지”가 사용하는 쪽(.cpp)에서 결정됩니다. 따라서 정의가 헤더에 없으면 해당 번역 단위에서 코드를 생성할 수 없어 링크 에러가 납니다. 클래스가 길어지면 선언과 정의를 나누고 싶을 수 있지만, 템플릿은 보통 한 헤더 파일에 선언과 정의를 함께 두는 방식이 일반적입니다.

정적 멤버

#include <iostream>
template <typename T>
class Counter {
    static int count;  // 선언
    
public:
    Counter() { count++; }
    static int getCount() { return count; }
};
// 정의 (헤더에 작성)
template <typename T>
int Counter<T>::count = 0;
int main() {
    Counter<int> c1, c2, c3;
    std::cout << Counter<int>::getCount() << "\n";  // 3
    
    Counter<double> d1, d2;
    std::cout << Counter<double>::getCount() << "\n";  // 2
    
    // 타입마다 별도의 count 변수
}

정적 멤버 count는 타입마다 한 번만 정의해야 하므로 template <typename T> int Counter&lt;T&gt;::count = 0; 형태로 헤더에 둡니다. Counter<int>와 Counter<double>은 서로 다른 count를 가지므로, 타입별로 객체 개수를 따로 셀 수 있습니다. 템플릿 클래스의 정적 멤버는 인스턴스화된 타입마다 따로 존재합니다. Counter<int>::count와 Counter<double>::count는 서로 다른 변수이므로, 정수 타입으로 만든 객체 개수와 double 타입으로 만든 객체 개수를 각각 셀 수 있습니다.


포인터 타입과 같은 타입 쌍을 위한 부분 특수화

완전 특수화는 “이 한 타입만 다르게”일 때 쓰며, 부분 특수화는 “이런 패턴의 타입들은 다르게”일 때 씁니다. 예를 들어 “모든 포인터 타입”, “모든 배열 타입”, “두 타입이 같은 경우”처럼 조건을 걸 수 있습니다. STL의 vector<bool>이 비트 패킹으로 특수화된 것처럼, 특정 패턴에 대해 메모리나 동작을 최적화할 때 자주 사용됩니다.

포인터 타입 특수화

#include <iostream>
// 일반 템플릿
template <typename T>
class SmartPtr {
    T* ptr;
    
public:
    SmartPtr(T* p) : ptr(p) {}
    ~SmartPtr() { delete ptr; }
    
    T& operator*() { return *ptr; }
    T* operator->() { return ptr; }
};
// 배열 타입 특수화
template <typename T>
class SmartPtr<T[]> {
    T* ptr;
    
public:
    SmartPtr(T* p) : ptr(p) {}
    ~SmartPtr() { delete[] ptr; }  // delete[] 사용
    
    T& operator[](size_t index) { return ptr[index]; }
};
int main() {
    SmartPtr<int> p1(new int(42));
    std::cout << *p1 << "\n";
    
    SmartPtr<int[]> p2(new int[5]);
    p2[0] = 10;
    std::cout << p2[0] << "\n";
}

SmartPtr<T>는 단일 객체용(delete), SmartPtr<T[]>는 배열용(delete[])으로 부분 특수화했습니다. SmartPtr<int>는 일반 버전, SmartPtr<int[]>는 배열 버전이 선택되어, 포인터/배열에 따라 다른 소멸자와 operator[]를 사용할 수 있습니다.

두 타입이 같을 때 특수화

#include <iostream>
// 일반 템플릿
template <typename T, typename U>
class Pair {
public:
    void print() {
        std::cout << "Different types\n";
    }
};
// 두 타입이 같을 때 특수화
template <typename T>
class Pair<T, T> {
public:
    void print() {
        std::cout << "Same type\n";
    }
};
int main() {
    Pair<int, double> p1;
    p1.print();  // Different types
    
    Pair<int, int> p2;
    p2.print();  // Same type
}

Pair<T, U>는 서로 다른 두 타입용, Pair<T, T>는 같은 타입 두 개용으로 부분 특수화했습니다. Pair<int, double>은 “Different types”, Pair<int, int>는 “Same type”을 출력합니다. 조건에 맞는 특수화가 있으면 그 버전이 선택됩니다.


using으로 만드는 템플릿 별칭

타입 이름이 길어지면 std::unordered_map<std::string, std::vector<int>>처럼 중첩된 템플릿이 읽기 어려워집니다. 템플릿 별칭으로 의미 있는 이름을 붙여 두면 가독성과 유지보수성이 좋아집니다. C++11의 using으로 타입 별칭을 만드는 방식이 typedef보다 템플릿과 잘 맞아서, 새 코드에서는 using을 쓰는 것이 관례입니다.

using으로 별칭 만들기

#include <vector>
#include <string>
#include <unordered_map>
// 긴 타입 이름 단축
template <typename T>
using Vec = std::vector<T>;
template <typename K, typename V>
using Map = std::unordered_map<K, V>;
int main() {
    Vec<int> numbers = {1, 2, 3};
    Map<std::string, int> ages = {{"Alice", 25}};
}

using Vec = std::vector<T>처럼 템플릿 별칭을 두면 Vec<int>가 std::vector<int>와 동일한 타입이 됩니다. Map<K, V>도 unordered_map의 별칭이라 긴 타입 이름을 짧게 쓰거나, 나중에 구현을 바꿀 때 한 곳만 수정할 수 있습니다.

부분 적용

#include <string>
#include <unordered_map>
template <typename T>
using StringMap = std::unordered_map<std::string, T>;
int main() {
    StringMap<int> ages;
    ages["Alice"] = 25;
    
    StringMap<std::string> names;
    names["user1"] = "Alice";
}

StringMap<T>는 키가 항상 std::string인 map의 별칭입니다. 첫 번째 타입 인자(std::string)를 고정하고 두 번째만 T로 두는 “부분 적용”처럼 쓸 수 있어, 키 타입이 같은 여러 map을 간단한 이름으로 쓸 수 있습니다.


타입 안전한 큐, 범위 체크 배열, 옵셔널 컨테이너

아래 예제들은 클래스 템플릿으로 “타입에 구애받지 않는” 자료 구조를 만드는 패턴을 보여 줍니다. STL의 queue, optional 같은 타입이 내부적으로 어떻게 생겼을지 감이 옵니다. 실제 프로젝트에서는 표준 라이브러리를 우선 사용하며, 표준에 없는 동작이 필요할 때만 이런 식으로 래퍼나 작은 컨테이너를 만드는 편이 안전합니다.

예제 1: 타입 안전한 큐

#include <iostream>
template <typename T>
class Queue {
    std::deque<T> data;
    
public:
    void enqueue(const T& value) {
        data.push_back(value);
    }
    
    T dequeue() {
        if (empty()) {
            throw std::logic_error("Queue is empty");
        }
        T value = data.front();
        data.pop_front();
        return value;
    }
    
    const T& front() const {
        if (empty()) {
            throw std::logic_error("Queue is empty");
        }
        return data.front();
    }
    
    bool empty() const {
        return data.empty();
    }
    
    size_t size() const {
        return data.size();
    }
};
int main() {
    Queue<int> q;
    q.enqueue(10);
    q.enqueue(20);
    q.enqueue(30);
    
    while (!q.empty()) {
        std::cout << q.dequeue() << " ";
    }
    // 10 20 30
}

Queue<T>는 내부에 std::deque<T>를 두고 enqueue(deque::push_back), dequeue(front + pop_front)로 FIFO를 구현합니다. 빈 큐에서 dequeue/front를 호출하면 logic_error를 던지도록 했으며, 타입만 바꿔서 Queue<int>, Queue<std::string> 등으로 재사용할 수 있습니다.

예제 2: 범위 체크하는 배열

#include <iostream>
template <typename T, size_t N>
class SafeArray {
    T data[N];
    
public:
    T& operator[](size_t index) {
        if (index >= N) {
            throw std::out_of_range("Index out of range");
        }
        return data[index];
    }
    
    const T& operator[](size_t index) const {
        if (index >= N) {
            throw std::out_of_range("Index out of range");
        }
        return data[index];
    }
    
    constexpr size_t size() const { return N; }
    
    T* begin() { return data; }
    T* end() { return data + N; }
    const T* begin() const { return data; }
    const T* end() const { return data + N; }
};
int main() {
    SafeArray<int, 5> arr;
    arr[0] = 10;
    arr[1] = 20;
    
    try {
        arr[10] = 30;  // 예외 발생
    } catch (const std::out_of_range& e) {
        std::cerr << e.what() << "\n";
    }
    
    // 범위 기반 for 지원
    for (int& value : arr) {
        value *= 2;
    }
}

SafeArray<T, N>은 크기 N이 컴파일 시점에 고정된 배열로, operator[]에서 인덱스가 N 이상이면 out_of_range 예외를 던집니다. begin/end를 제공해 범위 기반 for에 쓸 수 있으며, 타입과 크기를 템플릿 인자로 받아 타입 안전한 고정 크기 배열을 만드는 패턴입니다.

예제 3: 옵셔널 값 컨테이너

#include <iostream>
template <typename T>
class Optional {
    bool hasValue;
    alignas(T) unsigned char storage[sizeof(T)];
    
    T* ptr() { return reinterpret_cast<T*>(storage); }
    const T* ptr() const { return reinterpret_cast<const T*>(storage); }
    
public:
    Optional() : hasValue(false) {}
    
    Optional(const T& value) : hasValue(true) {
        new (storage) T(value);  // placement new
    }
    
    ~Optional() {
        if (hasValue) {
            ptr()->~T();  // 명시적 소멸자 호출
        }
    }
    
    bool has_value() const { return hasValue; }
    
    T& value() {
        if (!hasValue) {
            throw std::logic_error("No value");
        }
        return *ptr();
    }
    
    const T& value() const {
        if (!hasValue) {
            throw std::logic_error("No value");
        }
        return *ptr();
    }
    
    T value_or(const T& defaultValue) const {
        return hasValue ? *ptr() : defaultValue;
    }
};
int main() {
    Optional<int> opt1(42);
    std::cout << opt1.value() << "\n";  // 42
    
    Optional<int> opt2;
    std::cout << opt2.value_or(0) << "\n";  // 0
    
    try {
        opt2.value();  // 예외
    } catch (const std::logic_error& e) {
        std::cerr << e.what() << "\n";
    }
}

Optional<T>는 값이 있을 수도 없을 수도 있는 타입으로, storage에 placement new로 T를 만들고 소멸자에서 명시적으로 ~T()를 호출합니다. has_value(), value(), value_or(기본값)로 std::optional과 비슷한 인터페이스를 제공하며, 값이 없을 때 value()를 호출하면 logic_error를 던지도록 했습니다.


Stack, 고정 크기 Array, 타입 특성 전체 코드

완전한 Stack 템플릿

컨테이너 선택 가능, 이동 생성자·대입, 예외 안전성을 갖춘 완전한 Stack 예제입니다.

#include <vector>
#include <deque>
#include <stdexcept>
#include <utility>
template <typename T, typename Container = std::deque<T>>
class Stack {
    Container data;
public:
    using value_type = T;
    using container_type = Container;
    using size_type = typename Container::size_type;
    Stack() = default;
    explicit Stack(const Container& cont) : data(cont) {}
    explicit Stack(Container&& cont) : data(std::move(cont)) {}
    Stack(const Stack&) = default;
    Stack(Stack&&) noexcept = default;
    Stack& operator=(const Stack&) = default;
    Stack& operator=(Stack&&) noexcept = default;
    void push(const T& value) { data.push_back(value); }
    void push(T&& value) { data.push_back(std::move(value)); }
    template <typename... Args>
    void emplace(Args&&... args) {
        data.emplace_back(std::forward<Args>(args)...);
    }
    T pop() {
        if (empty()) throw std::logic_error("Stack is empty");
        T value = std::move(data.back());
        data.pop_back();
        return value;
    }
    T& top() {
        if (empty()) throw std::logic_error("Stack is empty");
        return data.back();
    }
    const T& top() const {
        if (empty()) throw std::logic_error("Stack is empty");
        return data.back();
    }
    bool empty() const { return data.empty(); }
    size_type size() const { return data.size(); }
};
int main() {
    Stack<int> s1;
    Stack<int, std::vector<int>> s2;  // vector 기반
    s1.push(42);
    s1.emplace(100);
}

Container 기본값으로 std::deque<T>를 쓰며, emplace로 불필요한 복사/이동을 줄입니다.

완전한 Array 템플릿 (고정 크기)

std::array와 유사한 고정 크기 배열 템플릿입니다. 컴파일 시점 크기, 반복자, at() 범위 검사, std::tuple_size 호환을 지원합니다.

#include <stdexcept>
#include <iterator>
#include <algorithm>
template <typename T, size_t N>
class Array {
    T data[N];
public:
    using value_type = T;
    using size_type = size_t;
    using difference_type = ptrdiff_t;
    using reference = T&;
    using const_reference = const T&;
    using pointer = T*;
    using const_pointer = const T*;
    using iterator = T*;
    using const_iterator = const T*;
    T& operator[](size_t i) { return data[i]; }
    const T& operator[](size_t i) const { return data[i]; }
    T& at(size_type i) {
        if (i >= N) throw std::out_of_range("Array index out of range");
        return data[i];
    }
    const T& at(size_type i) const {
        if (i >= N) throw std::out_of_range("Array index out of range");
        return data[i];
    }
    T& front() { return data[0]; }
    const T& front() const { return data[0]; }
    T& back() { return data[N - 1]; }
    const T& back() const { return data[N - 1]; }
    T* data_ptr() { return data; }
    const T* data_ptr() const { return data; }
    iterator begin() { return data; }
    iterator end() { return data + N; }
    const_iterator begin() const { return data; }
    const_iterator end() const { return data + N; }
    const_iterator cbegin() const { return data; }
    const_iterator cend() const { return data + N; }
    constexpr bool empty() const { return N == 0; }
    constexpr size_type size() const { return N; }
    constexpr size_type max_size() const { return N; }
    void fill(const T& value) { std::fill_n(data, N, value); }
    void swap(Array& other) noexcept { std::swap(data, other.data); }
};
// std::tuple_size 지원 (구조화 바인딩)
namespace std {
template <typename T, size_t N>
struct tuple_size<Array<T, N>> : integral_constant<size_t, N> {};
}

at()에서 범위 검사, begin()/end()로 STL 호환, std::tuple_size로 구조화 바인딩 지원.

타입 특성(Type Traits) 클래스 템플릿

컴파일 시점에 타입의 특성을 조회하는 메타 프로그래밍 예제입니다. std::is_integral, std::remove_const가 이런 식으로 만들어져 있습니다.

#include <type_traits>
// 기본: false
template <typename T>
struct is_pointer_v : std::false_type {};
// 포인터 특수화: true
template <typename T>
struct is_pointer_v<T*> : std::true_type {};
template <typename T>
inline constexpr bool is_pointer = is_pointer_v<T>::value;
// 사용
static_assert(!is_pointer<int>);
static_assert(is_pointer<int*>);
static_assert(is_pointer<double*>);

기본 템플릿은 std::false_type을 상속해 false를 반환하며, T* 패턴에 대한 부분 특수화는 std::true_type을 상속해 true를 반환합니다. static_assert로 컴파일 시점에 검증합니다.

// remove_const 구현
template <typename T>
struct remove_const {
    using type = T;
};
template <typename T>
struct remove_const<const T> {
    using type = T;
};
template <typename T>
using remove_const_t = typename remove_const<T>::type;
// 사용
static_assert(std::is_same_v<remove_const_t<const int>, int>);
static_assert(std::is_same_v<remove_const_t<int>, int>);

remove_const는 const T일 때 T를 추출하며, 그렇지 않으면 T 그대로 둡니다. remove_const_t는 typename remove_const<T>::type의 별칭으로, C++14에서 도입된 _t 접미사 패턴입니다.


undefined reference, dependent name, CTAD 추론 실패

문제 1: “undefined reference” 링크 에러

원인: 템플릿 멤버 함수를 .cpp 파일에 정의하며, 헤더에는 선언만 둔 경우. 컴파일러가 사용하는 쪽에서 인스턴스화할 수 없어서 링크 에러가 납니다. 해결법:

// ❌ 잘못된 방식: Stack.cpp에 정의
// Stack.h
template <typename T>
class Stack {
    std::vector<T> data;
public:
    void push(const T& value);  // 선언만
};
// Stack.cpp - 컴파일러가 main.cpp에서 Stack<int>를 인스턴스화할 때 이 정의를 못 봄
template <typename T>
void Stack<T>::push(const T& value) { data.push_back(value); }
// ✅ 올바른 방식: 정의를 헤더에 모두 포함
// Stack.h
template <typename T>
class Stack {
    std::vector<T> data;
public:
    void push(const T& value) { data.push_back(value); }  // 인라인 정의
};

문제 2: “dependent name” 관련 에러

원인: 템플릿 안에서 T::value 같은 의존 타입을 쓸 때, 컴파일러가 value가 타입인지 값인지 구분하지 못합니다. 해결법:

template <typename T>
class Wrapper {
    // ❌ 에러: 'value'가 타입인지 확인 불가
    // T::value_type x;
    // ✅ typename 키워드로 "타입"임을 명시
    typename T::value_type x;
    // ✅ template 키워드로 "멤버 템플릿"임을 명시
    T obj;
    obj.template foo<int>();
};

문제 3: >> 파싱 오류 (C++11 이전)

원인: Stack<std::vector<int>>처럼 중첩된 >>를 C++03에서는 >>(시프트 연산자)로 파싱할 수 있어 에러가 납니다. 해결법:

// ❌ C++03: ">>"가 shift로 해석됨
Stack<std::vector<int>> s;
// ✅ C++03: 공백으로 구분
Stack<std::vector<int> > s;
// ✅ C++11 이후: >> 자동 해석
Stack<std::vector<int>> s;

문제 4: CTAD로 타입 추론 실패

원인: 생성자 인자만으로는 템플릿 타입을 추론할 수 없는 경우(예: 기본 생성자). 해결법:

template <typename T>
class Box {
    T value;
public:
    Box() : value{} {}
    Box(const T& v) : value(v) {}
};
int main() {
    // ❌ 에러: T를 추론할 수 없음
    // Box b;
    // ✅ 타입 명시
    Box<int> b;
}

문제 5: 특수화 순서/선택 오류

원인: 부분 특수화가 여러 개일 때, 더 구체적인 버전이 선택되어야 합니다. 컴파일러는 “가장 특수화된” 버전을 선택합니다. 해결법: Traits<T* const>가 Traits<T*>보다 더 구체적이므로 int* const에 대해 선택됩니다.


기본 인자, using 별칭, noexcept로 쓰기 편한 템플릿 만들기

템플릿 매개변수는 typename 또는 class 사용

typename과 class는 타입 매개변수에 동일하게 사용됩니다. typename이 “타입”임을 더 명확히 하므로 최신 코드에서는 typename을 선호합니다.

template <typename T> class Stack {};   // 권장
template <class T> class Stack {};      // 동일

기본 템플릿 인자로 사용 편의성 높이기

template <typename T, typename Alloc = std::allocator<T>>
class MyVector {};
// 사용
MyVector<int> v;  // Alloc 생략 가능

using으로 타입 별칭 제공

template <typename T>
class Stack {
public:
    using value_type = T;
    using size_type = size_t;
    using reference = T&;
    using const_reference = const T&;
};

noexcept로 이동/스왑 명시

template <typename T>
class Stack {
    Stack(Stack&& other) noexcept : data(std::move(other.data)) {}
    void swap(Stack& other) noexcept { data.swap(other.data); }
};

if constexpr로 타입별 분기

template <typename T>
void process(T& value) {
    if constexpr (std::is_arithmetic_v<T>) value *= 2;
    else value.append(" processed");
}

static_assert로 컴파일 시점 제약

template <typename T>
class NumericStack {
    static_assert(std::is_arithmetic_v<T>, "T must be arithmetic");
    // ...
};

CRTP, Policy 기반 설계, 단위 타입, extern template

패턴 1: CRTP (Curiously Recurring Template Pattern)

template <typename Derived>
class Comparable {
public:
    bool operator!=(const Derived& other) const {
        return !(static_cast<const Derived*>(this)->operator==(other));
    }
};
class Point : public Comparable<Point> {
    int x, y;
public:
    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
};

파생 클래스가 자신을 템플릿 인자로 전달해, Comparable이 Derived 타입을 알고 operator==를 호출할 수 있게 합니다. operator!=를 한 번만 정의해 재사용합니다.

패턴 2: Policy 기반 설계

template <typename T, typename Container = std::vector<T>>
class Stack {
    Container data;
    
public:
    void push(const T& v) { data.push_back(v); }
    T pop() {
        T v = data.back();
        data.pop_back();
        return v;
    }
};
// 사용: 컨테이너 정책 변경
Stack<int> s1;                           // vector
Stack<int, std::deque<int>> s2;          // deque
Stack<int, std::list<int>> s3;           // list

패턴 3: 타입 안전한 단위

template <typename T, typename Tag>
struct StrongType { T value; explicit StrongType(const T& v) : value(v) {} };
using Meter = StrongType<double, struct MeterTag>;
using Second = StrongType<double, struct SecondTag>;
// Meter(5.0) + Second(10.0);  // ❌ 컴파일 에러

패턴 4: 외부 인스턴스화 (빌드 시간 단축)

// Stack.h - 선언
template <typename T>
class Stack { /* ... */ };
// Stack.cpp - explicit instantiation
#include "Stack.h"
template class Stack<int>;
template class Stack<std::string>;
// main.cpp - Stack<int>, Stack<std::string>만 사용 시
// 링크 시 Stack.cpp의 정의 사용

자주 쓰는 타입만 .cpp에서 template class Stack<int>로 명시적 인스턴스화하면, 해당 타입에 맞는 코드가 한 번만 생성되고, 다른 번역 단위에서는 링크만 하면 됩니다. 빌드 시간을 줄일 수 있습니다.

참고 자료


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

템플릿 기반 클래스의 멤버를 이름만으로 쓰면 왜 not declared 에러가 나나요?

기반 클래스가 Base<T>처럼 템플릿 인자에 의존하면, 컴파일러는 템플릿 정의 시점에 한정되지 않은 이름을 찾을 때 그 기반 클래스를 살펴보지 않습니다. 인스턴스화 전에는 Base<T>가 특수화로 어떤 멤버를 가질지 알 수 없기 때문입니다. this->member나 Base<T>::member로 한정하거나, 클래스 안에 using Base<T>::member;를 선언하면 이름이 의존 이름으로 처리되어 인스턴스화 시점에 올바르게 찾아집니다.