본문으로 건너뛰기 C++ 템플릿 | '제네릭 프로그래밍' 초보자 가이드 | 핵심 개념과 실전 활용

C++ 템플릿 | '제네릭 프로그래밍' 초보자 가이드 | 핵심 개념과 실전 활용

C++ 템플릿 | '제네릭 프로그래밍' 초보자 가이드 | 핵심 개념과 실전 활용

이 글의 핵심

C++ 템플릿: "제네릭 프로그래밍" 초보자 가이드. 템플릿이란?·함수 템플릿.

템플릿이란?

템플릿 (Template)타입에 독립적인 코드를 작성하는 C++ 제네릭 프로그래밍 기법입니다. 컴파일 타임에 타입별로 코드가 생성됩니다.

왜 필요한가?:

  • 코드 재사용: 타입별 중복 제거
  • 타입 안전: 컴파일 타임 타입 체크
  • 성능: 런타임 오버헤드 없음
  • 유연성: 다양한 타입 지원

템플릿이 등장한 근본적인 이유는 “같은 로직인데 타입만 다른” 코드를 반복해서 작성하는 문제를 해결하기 위해서입니다. 아래 코드에서 maxInt, maxDouble, maxChar는 비교 연산 로직이 완전히 동일하지만, 타입이 다르다는 이유만으로 세 번이나 같은 함수를 손으로 복사해서 작성해야 했습니다. 템플릿은 typename T라는 자리표시자를 두어 이 로직을 한 번만 작성하고, 실제 호출 시점에 컴파일러가 필요한 타입(int, double, char)에 맞춰 자동으로 코드를 생성하도록 만듭니다.

// ❌ 타입별 중복 코드
int maxInt(int a, int b) { return (a > b) ? a : b; }
double maxDouble(double a, double b) { return (a > b) ? a : b; }
char maxChar(char a, char b) { return (a > b) ? a : b; }

// ✅ 템플릿: 재사용
template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

max(3, 5);      // int
max(3.5, 2.1);  // double
max('a', 'z');  // char

템플릿 동작 원리:

템플릿 자체는 실제로 실행되는 코드가 아니라 코드를 찍어내는 “틀”입니다. 컴파일러는 소스 코드 전체를 훑어보다가 템플릿이 특정 타입으로 호출되는 지점을 발견하면, 그 타입을 T 자리에 대입한 구체적인 함수(또는 클래스)를 실제로 생성합니다. 이 과정을 인스턴스화(instantiation)라고 부르며, 아래 다이어그램처럼 같은 템플릿 정의 하나로부터 호출되는 타입 수만큼 서로 다른 버전이 만들어져 최종 실행 파일에 포함됩니다.

flowchart LR
    A[템플릿 정의] --> B[컴파일러]
    B -->|int 호출| C[int 버전 생성]
    B -->|double 호출| D[double 버전 생성]
    B -->|string 호출| E[string 버전 생성]
    C --> F[실행 파일]
    D --> F
    E --> F

템플릿 인스턴스화:

인스턴스화는 완전히 자동으로, 그리고 개발자가 직접 호출하는 시점마다 필요에 따라 일어납니다. 이를 암시적 인스턴스화라고 하는데, max(3, 5)를 호출하면 컴파일러가 그 순간 int 버전의 max 함수를 만들어내고, max(3.5, 2.1)을 호출하면 별도로 double 버전을 만들어냅니다. 두 버전은 서로 독립적인 함수로 컴파일되므로, 런타임에는 일반 함수를 호출하는 것과 전혀 차이가 없어 오버헤드가 발생하지 않습니다.

// 템플릿 정의
template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

// 호출
max(3, 5);      // int 버전 생성
max(3.5, 2.1);  // double 버전 생성

// 내부 동작 (개념적)
// int max(int a, int b) { return (a > b) ? a : b; }
// double max(double a, double b) { return (a > b) ? a : b; }

함수 템플릿

함수 템플릿을 사용할 때 개발자가 매번 타입을 명시할 필요는 없습니다. 컴파일러는 함수에 전달된 인자의 실제 타입을 보고 T가 무엇이어야 하는지 스스로 추론하는데, 이를 템플릿 인자 추론(template argument deduction)이라고 합니다. 아래 코드에서 max(3, 5)를 호출하면 인자가 둘 다 int이므로 컴파일러가 자동으로 T = int를 결정하고, 별도의 타입 표기 없이도 올바른 버전이 호출됩니다.

template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

int main() {
    cout << max(3, 5) << endl;        // int
    cout << max(3.5, 2.1) << endl;    // double
    cout << max('a', 'z') << endl;    // char
}

함수 템플릿 특징:

특징설명
타입 추론컴파일러가 자동 추론
명시적 지정max<int>(3, 5)
오버로딩일반 함수와 함께 사용
인라인인라인 최적화 가능

타입 추론이 애매하거나 원하는 타입이 인자 타입과 다를 때는 max<double>(3, 5)처럼 꺾쇠괄호 안에 타입을 명시적으로 지정할 수 있습니다. 또한 함수 템플릿은 일반(비템플릿) 함수와 이름이 같아도 함께 존재할 수 있는데, 이런 경우 컴파일러는 오버로드 해석 규칙에 따라 정확히 일치하는 일반 함수가 있으면 그 함수를 우선적으로 선택하고, 그렇지 않을 때만 템플릿을 인스턴스화합니다.

// 타입 추론
max(3, 5);  // T = int

// 명시적 지정
max<double>(3, 5);  // T = double

// 오버로딩
template <typename T>
T max(T a, T b) { return (a > b) ? a : b; }

int max(int a, int b) { return (a > b) ? a : b; }  // 일반 함수 우선

클래스 템플릿

함수뿐 아니라 클래스도 템플릿으로 만들 수 있으며, 이것이 바로 std::vector, std::map 같은 STL 컨테이너들이 어떤 타입이든 담을 수 있는 이유입니다. 함수 템플릿과 달리 클래스 템플릿은 인자로부터 타입을 자동 추론할 수 없으므로(C++17 CTAD 이전까지는), Box<int>, Box<string>처럼 사용할 때 꺾쇠괄호로 타입을 명시적으로 지정해야 합니다. 이렇게 만들어진 Box<int>Box<string>은 컴파일러 입장에서 완전히 서로 다른 별개의 클래스로 취급됩니다.

template <typename T>
class Box {
private:
    T value;
    
public:
    Box(T v) : value(v) {}
    
    T getValue() { return value; }
    void setValue(T v) { value = v; }
};

int main() {
    Box<int> intBox(10);
    Box<string> strBox("Hello");
    
    cout << intBox.getValue() << endl;
    cout << strBox.getValue() << endl;
}

여러 타입 매개변수

템플릿은 타입 매개변수를 하나만 받아야 한다는 제약이 없습니다. template <typename T, typename U>처럼 쉼표로 구분해 여러 개의 독립적인 타입 매개변수를 선언할 수 있으며, 이는 std::pairstd::map<Key, Value>처럼 서로 다른 두 타입을 함께 다뤄야 하는 자료구조를 구현하는 기반이 됩니다. 아래 Pair<int, string>처럼 각 매개변수 자리에 서로 다른 타입을 자유롭게 조합해 인스턴스화할 수 있습니다.

template <typename T, typename U>
class Pair {
public:
    T first;
    U second;
    
    Pair(T f, U s) : first(f), second(s) {}
};

int main() {
    Pair<int, string> p(1, "one");
    cout << p.first << ": " << p.second << endl;
}

실전 예시

아래 세 가지 예시는 자료구조, 유틸리티 함수, 디자인 패턴이라는 서로 다른 영역에서 템플릿이 어떻게 코드 재사용을 이끌어내는지 보여줍니다.

예시 1: 제네릭 스택

Stack<T>는 내부적으로 std::vector<T>에 데이터를 위임하는 얇은 래퍼 클래스입니다. 직접 배열과 인덱스를 관리하는 스택을 타입마다 새로 구현하는 대신, 템플릿으로 한 번만 작성해두면 Stack<int>Stack<string>이든 동일한 push/pop/top 인터페이스로 사용할 수 있습니다. 이것이 실제로 표준 라이브러리의 std::stack이 채택하고 있는 설계와 동일한 접근입니다.

#include <iostream>
#include <vector>
using namespace std;

template <typename T>
class Stack {
private:
    vector<T> data;
    
public:
    void push(const T& value) {
        data.push_back(value);
    }
    
    void pop() {
        if (!empty()) {
            data.pop_back();
        }
    }
    
    T& top() {
        return data.back();
    }
    
    bool empty() const {
        return data.empty();
    }
    
    size_t size() const {
        return data.size();
    }
};

int main() {
    Stack<int> intStack;
    intStack.push(1);
    intStack.push(2);
    cout << intStack.top() << endl;  // 2
    
    Stack<string> strStack;
    strStack.push("Hello");
    strStack.push("World");
    cout << strStack.top() << endl;  // World
    
    return 0;
}

설명: 어떤 타입이든 저장할 수 있는 제네릭 스택입니다.

예시 2: 제네릭 배열 유틸리티

printArrayfindMaxvector<T>를 받는 자유 함수 템플릿으로, 순회하며 값을 출력하거나 비교하는 로직 자체는 원소 타입과 무관하게 동일하다는 점을 활용합니다. <, > 같은 비교 연산자만 지원하는 타입이라면 정수든 문자열이든 findMax에 그대로 넘길 수 있습니다. filter는 조건 함수(bool (*pred)(T))를 함께 받아 원하는 조건에 맞는 원소만 걸러내는데, 이런 “데이터 + 조건 함수” 조합은 STL 알고리즘들이 채택한 설계와 같은 발상입니다.

#include <iostream>
#include <vector>
using namespace std;

template <typename T>
void printArray(const vector<T>& arr, const string& label) {
    cout << label << ": ";
    for (const T& item : arr) {
        cout << item << " ";
    }
    cout << endl;
}

template <typename T>
T findMax(const vector<T>& arr) {
    T maxVal = arr[0];
    for (const T& item : arr) {
        if (item > maxVal) {
            maxVal = item;
        }
    }
    return maxVal;
}

template <typename T>
vector<T> filter(const vector<T>& arr, bool (*pred)(T)) {
    vector<T> result;
    for (const T& item : arr) {
        if (pred(item)) {
            result.push_back(item);
        }
    }
    return result;
}

bool isEven(int x) { return x % 2 == 0; }

int main() {
    vector<int> numbers = {1, 2, 3, 4, 5};
    printArray(numbers, "숫자");
    cout << "최댓값: " << findMax(numbers) << endl;
    
    vector<int> evens = filter(numbers, isEven);
    printArray(evens, "짝수");
    
    vector<string> words = {"apple", "banana", "cherry"};
    printArray(words, "단어");
    
    return 0;
}

설명: 템플릿으로 타입에 독립적인 유틸리티 함수를 만들 수 있습니다.

예시 3: 제네릭 싱글톤

이 예제는 CRTP(Curiously Recurring Template Pattern)라는 조금 더 고급 기법을 보여줍니다. ConfigSingleton<Config>를 상속받는 방식으로, 파생 클래스 자신을 템플릿 인자로 기반 클래스에 넘겨줍니다. 이렇게 하면 Singleton<T>getInstance()가 항상 정확히 T 타입(여기서는 Config)의 인스턴스를 반환하도록 만들 수 있어, 어떤 클래스든 이 템플릿을 상속받기만 하면 손쉽게 싱글톤 패턴을 재사용할 수 있습니다. 가상 함수 기반 다형성 없이도 컴파일 타임에 타입이 확정되므로 런타임 오버헤드가 전혀 없다는 것도 CRTP의 장점입니다.

#include <iostream>
#include <memory>
using namespace std;

template <typename T>
class Singleton {
private:
    static unique_ptr<T> instance;
    
protected:
    Singleton() {}
    
public:
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
    
    static T& getInstance() {
        if (!instance) {
            instance = make_unique<T>();
        }
        return *instance;
    }
};

template <typename T>
unique_ptr<T> Singleton<T>::instance = nullptr;

class Config : public Singleton<Config> {
private:
    int value;
    
public:
    Config() : value(0) {}
    
    void setValue(int v) { value = v; }
    int getValue() { return value; }
};

int main() {
    Config::getInstance().setValue(100);
    cout << Config::getInstance().getValue() << endl;  // 100
    
    return 0;
}

설명: 템플릿으로 재사용 가능한 싱글톤 패턴을 구현합니다.

자주 발생하는 문제

문제 1: 템플릿 정의를 cpp 파일에 분리

증상: 링크 에러 발생

원인: 템플릿은 컴파일 타임에 인스턴스화되므로 헤더에 정의 필요

해결법:

이 문제는 C++의 컴파일 모델 때문에 발생합니다. 일반 함수는 헤더에 선언만 두고 구현은 별도의 .cpp 파일에 두어도 링커가 나중에 두 조각을 연결해주지만, 템플릿은 실제로 호출되는 지점에서 컴파일러가 그 타입에 맞는 구체적인 코드를 그 자리에서 생성(인스턴스화)해야 합니다. 이때 컴파일러는 템플릿의 전체 정의를 볼 수 있어야 하는데, 정의가 다른 .cpp 파일에 숨겨져 있으면 그 정의를 참조할 수 없어 링크 단계에서 “정의를 찾을 수 없다”는 에러가 발생합니다. 그래서 템플릿은 선언과 정의를 분리하지 않고 헤더 파일 안에 통째로 작성하는 것이 표준적인 관례입니다.

// ❌ 잘못된 방법
// header.h
template <typename T>
T max(T a, T b);

// source.cpp
template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}
// 링크 에러!

// ✅ 올바른 방법 (헤더에 정의)
// header.h
template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

문제 2: typename vs class

증상: typename과 class 중 무엇을 써야 할지 혼란

원인: 둘 다 가능하지만 의미가 약간 다름

해결법:

typenameclass는 템플릿 타입 매개변수를 선언할 때는 완전히 동일하게 동작하며, 어느 쪽을 써도 컴파일 결과에 차이가 없습니다. 다만 typename은 “이것은 타입이다”라는 의미를 이름 그대로 더 명확하게 드러내므로 최근 코드에서는 typename 쪽이 더 널리 권장됩니다. 반면 템플릿 안에서 T::value_type처럼 의존 타입(dependent type, T에 따라 실제로 타입인지 값인지가 달라지는 표현)을 가리킬 때는 typename이 문법적으로 필수이며, 이 경우에는 class로 대체할 수 없습니다.

// 둘 다 가능
template <typename T>
template <class T>

// 권장: typename 사용 (더 명확)
template <typename T>

// typename이 필수인 경우
template <typename T>
void func() {
    typename T::value_type v;  // T의 중첩 타입
}

문제 3: 템플릿 특수화

증상: 특정 타입에 대해 다른 구현이 필요

원인: 일반 템플릿으로는 모든 타입을 처리 못함

해결법:

템플릿 특수화(template specialization)는 일반적인 템플릿 로직이 특정 타입에는 적합하지 않을 때, 그 타입에 대해서만 별도의 구현을 제공하는 기능입니다. 아래 예제에서 일반 Printer<T>cout << value로 값을 그대로 출력하지만, bool 값은 1이나 0이 아니라 "true"/"false"로 보여주고 싶다는 요구사항이 있을 수 있습니다. template <> class Printer<bool>처럼 bool을 위한 완전 특수화를 별도로 정의해두면, 컴파일러는 Printer<bool>을 사용할 때 일반 템플릿 대신 이 특수화 버전을 자동으로 선택합니다.

// 일반 템플릿
template <typename T>
class Printer {
public:
    void print(T value) {
        cout << value << endl;
    }
};

// bool 특수화
template <>
class Printer<bool> {
public:
    void print(bool value) {
        cout << (value ? "true" : "false") << endl;
    }
};

int main() {
    Printer<int> intPrinter;
    intPrinter.print(10);  // 10
    
    Printer<bool> boolPrinter;
    boolPrinter.print(true);  // true
}

실무 패턴

아래 세 가지 패턴은 리소스 관리, 범용 알고리즘, 컨테이너 어댑터라는 실무에서 템플릿이 실제로 어떻게 응용되는지 보여줍니다.

패턴 1: 타입 안전 래퍼

Handle<T>는 원시 포인터를 감싸 소멸자에서 자동으로 delete하는 RAII 스타일 래퍼를 어떤 타입 T에도 적용할 수 있도록 템플릿화한 것입니다. 복사 생성자와 복사 대입 연산자를 삭제해 하나의 자원을 두 Handle이 동시에 소유하는 이중 해제 문제를 방지하고, operator*operator->를 오버로드해 마치 원시 포인터처럼 자연스럽게 사용할 수 있게 합니다. 이는 표준 라이브러리의 std::unique_ptr이 채택한 설계와 본질적으로 같은 아이디어입니다.

template <typename T>
class Handle {
    T* ptr_;
    
public:
    explicit Handle(T* ptr) : ptr_(ptr) {}
    
    ~Handle() {
        delete ptr_;
    }
    
    T* get() const { return ptr_; }
    T& operator*() const { return *ptr_; }
    T* operator->() const { return ptr_; }
    
    Handle(const Handle&) = delete;
    Handle& operator=(const Handle&) = delete;
};

// 사용
Handle<int> handle(new int(42));
std::cout << *handle << '\n';

패턴 2: 범용 알고리즘

swapclamp처럼 특정 타입에 종속되지 않는 순수한 로직은 템플릿 함수로 작성해두면 어떤 타입에도 재사용할 수 있습니다. 이 두 함수가 바로 표준 라이브러리의 std::swap, std::clamp(C++17)와 같은 발상으로 만들어진 것입니다. T<, > 비교와 이동 대입을 지원하는 타입이기만 하면, 정수든 사용자 정의 클래스든 동일한 코드로 동작합니다.

template <typename T>
void swap(T& a, T& b) {
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

template <typename T>
T clamp(T value, T min, T max) {
    if (value < min) return min;
    if (value > max) return max;
    return value;
}

// 사용
int x = 5, y = 10;
swap(x, y);  // x=10, y=5

int clamped = clamp(15, 0, 10);  // 10

패턴 3: 컨테이너 어댑터

템플릿 매개변수는 타입뿐 아니라 기본값도 가질 수 있습니다. Container = std::vector<T>처럼 두 번째 타입 매개변수에 기본값을 지정해두면, 대부분의 경우 Stack<int>처럼 원소 타입만 지정해 간편하게 쓸 수 있으면서도, 필요하다면 Stack<int, std::deque<int>>처럼 내부 저장소를 다른 컨테이너로 바꿔치기할 수도 있습니다. 이 패턴은 실제로 표준 라이브러리의 std::stack, std::queue, std::priority_queue가 채택하고 있는 컨테이너 어댑터 설계입니다.

template <typename T, typename Container = std::vector<T>>
class Stack {
    Container data_;
    
public:
    void push(const T& value) {
        data_.push_back(value);
    }
    
    void pop() {
        if (!empty()) {
            data_.pop_back();
        }
    }
    
    T& top() {
        return data_.back();
    }
    
    bool empty() const {
        return data_.empty();
    }
};

// 사용
Stack<int> stack;  // std::vector 기반
Stack<int, std::deque<int>> dequeStack;  // std::deque 기반

FAQ

Q1: 템플릿은 언제 사용하나요?

A: 타입에 독립적인 코드를 작성할 때 사용합니다. STL 컨테이너가 모두 템플릿입니다.

template <typename T>
T max(T a, T b) {
    return (a > b) ? a : b;
}

Q2: 템플릿과 매크로의 차이는?

A:

  • 템플릿: 타입 안전, 컴파일 타임 체크
  • 매크로: 단순 텍스트 치환, 위험

C/C++ 예제 코드입니다.

// ❌ 매크로: 타입 안전하지 않음
#define MAX(a, b) ((a) > (b) ? (a) : (b))

// ✅ 템플릿: 타입 안전
template <typename T>
T max(T a, T b) { return (a > b) ? a : b; }

Q3: 템플릿은 성능에 영향을 주나요?

A: 아니요, 컴파일 타임에 코드가 생성되므로 런타임 오버헤드가 없습니다.

// 템플릿: 컴파일 타임 생성
max(3, 5);  // int max(int, int) 생성

// 런타임 비용 없음

Q4: 가변 인자 템플릿은?

A: C++11부터 지원합니다.

print 함수의 구현 예제입니다.

template <typename... Args>
void print(Args... args) {
    (std::cout << ... << args) << '\n';
}

print(1, 2, 3, "hello");

Q5: 템플릿 컴파일 에러가 너무 길어요!

A: C++20 Concepts를 사용하면 에러 메시지가 간결해집니다.

C/C++ 예제 코드입니다.

// ❌ 긴 에러 메시지
template <typename T>
T add(T a, T b) { return a + b; }

// ✅ Concepts: 간결한 에러
template <typename T>
requires std::is_arithmetic_v<T>
T add(T a, T b) { return a + b; }

Q6: 템플릿 디버깅은?

A:

  • static_assert: 컴파일 타임 체크
  • 명시적 인스턴스화: 에러 확인
  • Concepts: 제약 조건 명시

C/C++ 예제 코드입니다.

template <typename T>
T add(T a, T b) {
    static_assert(std::is_arithmetic_v<T>, "숫자 타입만");
    return a + b;
}

Q7: 템플릿 정의를 cpp 파일에 분리할 수 있나요?

A: 불가능합니다. 템플릿은 헤더에 정의해야 합니다.

C/C++ 예제 코드입니다.

// ❌ 링크 에러
// header.h
template <typename T>
T max(T a, T b);

// source.cpp
template <typename T>
T max(T a, T b) { return (a > b) ? a : b; }

// ✅ 헤더에 정의
// header.h
template <typename T>
T max(T a, T b) { return (a > b) ? a : b; }

Q8: 템플릿 학습 리소스는?

A:

관련 글: template-specialization, variadic-templates, SFINAE.

한 줄 요약: 템플릿은 타입에 독립적인 코드를 작성하는 C++ 제네릭 프로그래밍 기법입니다.


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

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

관련 글


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

C++, 템플릿, template, 제네릭, 타입 등으로 검색하시면 이 글이 도움이 됩니다.