C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화

C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화

이 글의 핵심

C++ 클래스 템플릿에 대한 실전 가이드입니다. 제네릭 컨테이너와 부분 특수화 등을 예제와 함께 상세히 설명합니다.

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

“타입마다 Stack 클래스를 복사하고 있어요”

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

문제의 코드:

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 로직이 같고 저장하는 요소 타입만 다릅니다. 새 타입마다 클래스를 복사하면 유지보수 비용이 늘고, 한쪽만 수정하면 동작이 어긋날 수 있어, “요소 타입만 다른” 구조는 클래스 템플릿 하나로 통합하는 편이 좋습니다.

추가 문제 시나리오

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

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

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

시나리오 4: 캐시 컨테이너
키-값 캐시에서 키 타입(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> 등).

실행 결과: 아래 명령으로 실행하면 20world 가 각각 한 줄씩 출력됩니다.

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>처럼 적어 줍니다.

이 글을 읽으면:

  • 클래스 템플릿의 기본 문법을 이해할 수 있습니다.
  • 멤버 함수를 클래스 외부에 정의하는 방법을 알 수 있습니다.
  • 부분 특수화를 사용할 수 있습니다.
  • Stack·Array·타입 특성 등 완전한 예제를 구현할 수 있습니다.
  • 흔한 에러와 프로덕션 패턴을 파악할 수 있습니다.

목차

  1. 클래스 템플릿 기본 문법
  2. 멤버 함수 외부 정의
  3. 부분 특수화
  4. 템플릿 별칭
  5. 실전 예제: 제네릭 컨테이너
  6. 완전한 예제: Stack·Array·타입 특성
  7. 자주 발생하는 에러와 해결법
  8. 모범 사례
  9. 프로덕션 패턴

1. 클래스 템플릿 기본 문법

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

기본 선언과 사용

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>처럼 타입을 써 줘야 했습니다.

여러 타입 매개변수

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처럼 제네릭한 자료 구조를 만들 때 쓰는 패턴입니다.

기본 템플릿 인자

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>만 씁니다.


2. 멤버 함수 외부 정의

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

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)에서 결정됩니다. 그래서 정의가 헤더에 없으면 해당 번역 단위에서 코드를 생성할 수 없어 링크 에러가 납니다. 클래스가 길어지면 선언과 정의를 나누고 싶을 수 있지만, 템플릿은 보통 한 헤더 파일에 선언과 정의를 함께 두는 방식이 일반적입니다.

정적 멤버

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>::countCounter<double>::count는 서로 다른 변수이므로, 정수 타입으로 만든 객체 개수와 double 타입으로 만든 객체 개수를 각각 셀 수 있습니다.


3. 부분 특수화

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

포인터 타입 특수화

// 일반 템플릿
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 { 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[]를 사용할 수 있습니다.

두 타입이 같을 때 특수화

// 일반 템플릿
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”을 출력합니다. 조건에 맞는 특수화가 있으면 그 버전이 선택됩니다.


4. 템플릿 별칭

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

using으로 별칭 만들기

// 긴 타입 이름 단축
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의 별칭이라 긴 타입 이름을 짧게 쓰거나, 나중에 구현을 바꿀 때 한 곳만 수정할 수 있습니다.

부분 적용

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을 간단한 이름으로 쓸 수 있습니다.


5. 실전 예제: 제네릭 컨테이너

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

예제 1: 타입 안전한 큐

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: 범위 체크하는 배열

template <typename T, size_t N>
class SafeArray {
    T data[N];
    
public:
    T& operator {
        if (index >= N) {
            throw std::out_of_range("Index out of range");
        }
        return data[index];
    }
    
    const T& operator 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: 옵셔널 값 컨테이너

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를 던지도록 했습니다.


6. 완전한 예제: 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 { return data[i]; }
    const T& operator 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_constconst T일 때 T를 추출하고, 그렇지 않으면 T 그대로 둡니다. remove_const_ttypename remove_const<T>::type의 별칭으로, C++14에서 도입된 _t 접미사 패턴입니다.


7. 자주 발생하는 에러와 해결법

문제 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에 대해 선택됩니다.


8. 모범 사례

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

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

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

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

template <typename T, typename Alloc = std::allocator<T>>
class MyVector {};

// 사용
MyVector<int> v;  // Alloc 생략 가능

3. using으로 타입 별칭 제공

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

4. noexcept로 이동/스왑 명시

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

5. if constexpr로 타입별 분기

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

6. static_assert로 컴파일 시점 제약

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

9. 프로덕션 패턴

패턴 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;
    }
};

위 코드 설명: 파생 클래스가 자신을 템플릿 인자로 전달해, ComparableDerived 타입을 알고 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>로 명시적 인스턴스화하면, 해당 타입에 맞는 코드가 한 번만 생성되고, 다른 번역 단위에서는 링크만 하면 됩니다. 빌드 시간을 줄일 수 있습니다.

구현 체크리스트

  • 템플릿 정의는 헤더에 모두 포함
  • typename/template로 의존 이름 명시
  • static_assert로 타입 제약 검사
  • 이동 생성자·대입에 noexcept 적용
  • using으로 타입 별칭 제공
  • 자주 쓰는 타입은 explicit instantiation 고려

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 템플릿 입문 | template와 템플릿 컴파일 에러 해결법
  • C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
  • C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법

이 글에서 다루는 키워드 (관련 검색어)

C++ 클래스 템플릿, template class, 부분 특수화, 제네릭 컨테이너, 템플릿 별칭, 타입 특성, CRTP, Policy 기반 설계, Strong Type 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
기본 문법template <typename T> class C { };
사용C<int> obj; (타입 명시 필수, C++17 CTAD 예외)
멤버 정의외부 정의도 헤더에 작성 (링크 에러 방지)
부분 특수화포인터, 배열, 동일 타입 등 특정 패턴 특수화
별칭using Alias = Template<T>;
타입 특성std::true_type/false_type 상속, 부분 특수화
실전 용도컨테이너, 래퍼, 타입 안전성, CRTP, Policy 설계

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ 클래스 템플릿 완벽 가이드. template<typename T> class 문법, 멤버 함수 정의, 부분 특수화(partial specialization), 템플릿 별칭(using), Stack·Array·타입 특성 구현, 흔한 에러와 프로덕션 패턴(CRTP, Policy 설계)까지 실무에 바로 적용할 수 있습니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: 클래스 템플릿으로 타입에 무관한 Stack·Queue를 만들고 부분 특수화로 예외를 다룰 수 있습니다. 다음으로 가변 인자 템플릿(#9-3)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #9-3: 가변 인자 템플릿


관련 글

  • C++ 템플릿 특수화 완벽 가이드 | 완전·부분 특수화, 문제 시나리오, 프로덕션 패턴
  • C++ 템플릿 입문 | template와 템플릿 컴파일 에러 해결법
  • C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
  • C++ 예외 안전성 |