C++ 템플릿 기초: 함수·클래스 템플릿, 가변 인자, SFINAE에서 Concepts까지

들어가며: 타입만 다른 같은 함수

여러 타입의 값을 로그로 남기는 함수를 오버로딩(같은 이름의 함수를 매개변수 타입만 다르게 여러 개 두는 것)으로 만들면 다음과 같은 코드가 됩니다.

void logValue(int value) {
    std::cout << "[LOG] " << value << "\n";
}
void logValue(double value) {
    std::cout << "[LOG] " << value << "\n";
}
void logValue(const std::string& value) {
    std::cout << "[LOG] " << value << "\n";
}
void logValue(const char* value) {
    std::cout << "[LOG] " << value << "\n";
}
// 새 타입마다 함수 추가...

본문은 모두 “[LOG] 값 출력”으로 같은데 타입마다 함수를 따로 두었습니다. 새 타입을 지원하려면 같은 본문을 또 복사해야 하고, 출력 형식을 바꿀 때 한 벌만 고치면 타입마다 동작이 어긋납니다.

함수 템플릿을 쓰면 이 네 벌을 하나로 합칠 수 있습니다. template <typename T>의 T는 호출할 때 정해지는 타입이고, 컴파일러는 logValue(42)를 보면 T = int인 함수를, logValue(3.14)를 보면 T = double인 함수를 각각 만들어 냅니다. 이 과정을 인스턴스화라고 합니다. 동작이 타입마다 다르면 오버로딩을, 동작은 같고 타입만 다르면 템플릿을 쓰는 것이 기본 구분입니다.

flowchart LR
  A[template T] --> B[logValue 42]
  B --> C[T=int 인스턴스]
  A --> D[logValue 3.14]
  D --> E[T=double 인스턴스]
  A --> F[logValue string]
  F --> G[T=std::string 인스턴스]
// g++ -std=c++17 -o log_example log_example.cpp && ./log_example
#include <iostream>
#include <string>

template <typename T>
void logValue(const T& value) {
    std::cout << "[LOG] " << value << "\n";
}

int main() {
    logValue(42);                    // T = int
    logValue(3.14);                  // T = double
    logValue("hello");               // T = char[6] (배열 참조로 받음)
    logValue(std::string("world"));  // T = std::string
}
[LOG] 42
[LOG] 3.14
[LOG] hello
[LOG] world

함수 이름을 log로 짓지 않은 데는 이유가 있습니다. <iostream>이 내부적으로 <cmath>를 포함하는 구현에서는 전역 ::log(double)이 보일 수 있고, 그러면 log(3.14) 호출은 똑같이 정확히 일치하는 템플릿보다 비템플릿 함수를 우선하는 규칙 때문에 자연로그 함수로 가 버립니다. 컴파일 에러도 나지 않으니 표준 라이브러리 이름과 겹치는 이름은 피하는 것이 안전합니다.

다른 언어의 제네릭과 비슷해 보이지만, C++ 템플릿은 컴파일 시점에 타입별로 구체적인 코드를 생성한다는 점이 다릅니다. 그래서 런타임 오버헤드 없이 타입마다 최적화된 코드를 얻는 대신, 컴파일 시간과 바이너리 크기가 늘어날 수 있습니다.

이전 글 C++ 커스텀 예외 클래스 만들기에서는 예외 성능과 선택 기준을 다뤘습니다.


컴파일러가 타입별 코드를 찍어 내는 원리

template <typename T>
T add(T a, T b) {
    return a + b;
}

int main() {
    add(3, 5);      // 컴파일러가 add<int> 생성
    add(1.5, 2.5);  // 컴파일러가 add<double> 생성
}
// 개념적으로 생성되는 코드:
// int add(int a, int b) { return a + b; }
// double add(double a, double b) { return a + b; }
flowchart TB
    subgraph source[소스 코드]
        T[template T add]
        C1[add 3, 5]
        C2[add 1.5, 2.5]
    end
    subgraph compile[컴파일 시점]
        I1[add int 생성]
        I2[add double 생성]
    end
    T --> C1 --> I1
    T --> C2 --> I2

런타임에 타입을 판단하는 것이 아니라, 컴파일 시점에 사용된 타입마다 별도의 함수가 만들어집니다. 그래서 호출에 간접 비용이 없고 인라인 최적화가 잘 되는 반면, 타입마다 코드가 생기므로 코드 크기가 늘 수 있습니다. 템플릿을 많이 쓰는 라이브러리(표준 라이브러리, Eigen 등)를 포함하면 컴파일이 길어지는 것도 같은 이유입니다.


함수 템플릿 기본 문법

단일 타입 매개변수

#include <iostream>
template <typename T>
T square(T value) {
    return value * value;
}

int main() {
    std::cout << square(5) << "\n";    // 25   (T = int)
    std::cout << square(2.5) << "\n";  // 6.25 (T = double)
}

타입 매개변수는 typename이나 class 어느 쪽으로 선언해도 의미가 같습니다. class가 먼저 도입되었지만 T가 클래스일 필요는 없으므로, 오늘날에는 의도를 더 정확히 드러내는 typename을 쓰는 경우가 많습니다.

여러 타입 매개변수

#include <iostream>
template <typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
    return a + b;
}

int main() {
    std::cout << add(3, 5) << "\n";    // 8   (int + int → int)
    std::cout << add(3, 2.5) << "\n";  // 5.5 (int + double → double)
    std::cout << add(1.5, 2) << "\n";  // 3.5 (double + int → double)
}

타입 매개변수를 두 개 두면 int와 double처럼 서로 다른 타입을 한 번에 받을 수 있습니다. 반환 타입은 후행 반환 타입 decltype(a + b)로 두 인자를 더한 결과 타입이 되게 했고, C++14부터는 auto 하나만 써도 return 문에서 추론됩니다.

비타입 템플릿 매개변수

템플릿 인자에는 타입뿐 아니라 컴파일 시점 상수도 쓸 수 있습니다.

#include <iostream>
#include <cstddef>

template <typename T, std::size_t Size>
class FixedArray {
    T data[Size];
public:
    constexpr std::size_t size() const { return Size; }
    T& operator[](std::size_t index) { return data[index]; }
    const T& operator[](std::size_t index) const { return data[index]; }
};

int main() {
    FixedArray<int, 5> arr1;
    FixedArray<double, 10> arr2;
    std::cout << arr1.size() << "\n";  // 5
    std::cout << arr2.size() << "\n";  // 10
}

FixedArray<int, 5>는 원소 타입과 크기가 모두 타입의 일부가 되어, 배열이 객체 안에 고정 크기로 들어가고 힙 할당이 없습니다. FixedArray<int, 5>와 FixedArray<int, 6>은 서로 다른 타입이므로 크기가 다른 배열을 섞어 쓰는 실수도 컴파일 시점에 걸립니다. 비타입 매개변수에는 정수, 열거형, 포인터, 참조 같은 값을 쓸 수 있고, C++20부터는 부동소수점과 일부 리터럴 클래스 타입도 허용됩니다. 표준 라이브러리의 std::array<T, N>이 같은 방식입니다.


타입 추론 규칙: 값, 참조, const 참조

템플릿 인자 추론 덕분에 print<int>(42) 대신 print(42)라고 쓸 수 있습니다. 추론 결과는 매개변수가 값인지, 참조인지, const 참조인지에 따라 달라집니다.

값으로 받을 때

#include <iostream>
template <typename T>
void print(T value) {
    std::cout << value << "\n";
}

int main() {
    print(42);       // T = int
    print(3.14);     // T = double
    print("hello");  // T = const char* (배열이 포인터로 붕괴)

    const int y = 20;
    print(y);        // T = int (복사본이므로 최상위 const 제거)
}

매개변수가 값이면 인자가 복사되므로, const int를 넘겨도 T는 int로 추론됩니다. 배열과 함수는 포인터로 붕괴합니다.

참조로 받을 때

#include <iostream>
template <typename T>
void printRef(T& value) {
    std::cout << value << "\n";
}

int main() {
    int x = 10;
    printRef(x);        // T = int, 매개변수는 int&

    const int y = 20;
    printRef(y);        // T = const int, 매개변수는 const int&

    // printRef(42);    // 에러: 임시 객체는 비const lvalue 참조에 바인딩 불가
}

참조로 받으면 const가 보존됩니다. 리터럴 42 같은 임시 객체는 비const lvalue 참조에 바인딩할 수 없으므로 printRef(42)는 컴파일 에러입니다.

const 참조로 받을 때

#include <iostream>
template <typename T>
void printConstRef(const T& value) {
    std::cout << value << "\n";
}

int main() {
    int x = 10;
    printConstRef(x);   // T = int, 매개변수는 const int&
    printConstRef(42);  // T = int, 임시 객체도 바인딩 가능
}

읽기만 하는 함수는 const T&로 받는 것이 일반적입니다. 복사를 피하면서 lvalue와 임시 객체를 모두 받을 수 있습니다.

매개변수 형태인자T 추론 결과비고
T valueconst intint최상위 const 제거
T& valueintint
T& valueconst intconst intconst 보존
T& value42 (리터럴)에러임시 객체 바인딩 불가
const T& valueintint
const T& value42 (리터럴)int임시 객체 바인딩 가능

추론 실패: max(3, 3.14)

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

int main() {
    // auto r = maxOf(3, 3.14);         // ❌ T가 int인지 double인지 결정할 수 없음

    auto r1 = maxOf<double>(3, 3.14);   // ✅ T를 명시하면 3이 double로 변환됨
    auto r2 = maxOf(3.0, 3.14);         // ✅ 인자 타입을 맞춤
}

첫 번째 인자에서는 T = int, 두 번째 인자에서는 T = double이 추론되어 서로 충돌하므로 “no matching function” 에러가 납니다. 템플릿 인자 추론은 암시적 변환을 고려하지 않기 때문입니다. T를 명시하면 추론 단계가 생략되어 일반 함수처럼 변환이 적용됩니다. 서로 다른 타입을 받아야 한다면 타입 매개변수를 두 개 둡니다.

template <typename T, typename U>
auto maxOf2(T a, U b) {
    return a > b ? a : b;  // 반환 타입은 두 타입의 공통 타입 (int, double → double)
}

예제에서 max 대신 maxOf라는 이름을 쓴 것도 std::max나 매크로 max(Windows 헤더)와의 충돌을 피하기 위해서입니다.


가변 인자 템플릿 기초

인자 개수가 고정되지 않은 함수에는 가변 인자 템플릿(variadic template)을 씁니다. C++11에서 도입되었고, C++17의 fold expression으로 재귀 없이 작성할 수 있습니다. 자세한 내용은 가변 인자 템플릿(#9-3)에서 다루고, 여기서는 기본 문법만 봅니다.

#include <iostream>
template <typename... Args>
void logAll(const Args&... args) {
    std::cout << "[LOG] ";
    (std::cout << ... << args) << "\n";  // C++17 fold expression
}

int main() {
    logAll("Server started");
    logAll("Port: ", 8080);
    logAll("Connected: ", 5, " users");
    logAll("Error: ", 404, ", message: ", "Not Found");
}
[LOG] Server started
[LOG] Port: 8080
[LOG] Connected: 5 users
[LOG] Error: 404, message: Not Found

typename... Args는 0개 이상의 타입을 받는 템플릿 파라미터 팩이고, args는 그에 대응하는 함수 파라미터 팩입니다. (std::cout << ... << args)는 초기값이 std::cout인 이항 왼쪽 fold로, ((std::cout << a) << b) << c처럼 펼쳐집니다. 초기값이 있는 이항 fold는 팩이 비어 있어도 초기값(std::cout)이 결과가 되므로 logAll()도 문제없이 동작합니다. 초기값이 없는 단항 fold는 빈 팩일 때 &&, ||, 쉼표 연산자만 허용되고 + 같은 연산자는 컴파일 에러이므로, 합계처럼 빈 팩이 들어올 수 있는 경우에는 (args + ... + 0)처럼 초기값을 둡니다.

C++17 이전에는 인자를 하나씩 떼어 처리하고 나머지로 자신을 다시 호출하는 재귀와, 인자가 0개일 때 호출될 종료용 오버로드로 같은 일을 했습니다. 가변 인자 템플릿은 로그·포맷 함수, emplace 계열처럼 생성자 인자를 그대로 전달하는 함수, std::tuple과 std::variant 같은 타입 목록 기반 클래스에 쓰이고, 인자 개수가 두세 개로 고정되어 있다면 일반 템플릿으로 충분합니다.


특정 타입만 다르게: 특수화와 오버로드

일반 템플릿은 모든 타입에 같은 로직을 적용하지만, 특정 타입만 다르게 동작하게 하고 싶을 때가 있습니다.

완전 특수화

#include <iostream>
// 일반 템플릿
template <typename T>
void show(const T& value) {
    std::cout << "Generic: " << value << "\n";
}

// bool에 대한 완전 특수화
template <>
void show<bool>(const bool& value) {
    std::cout << "Bool: " << (value ? "true" : "false") << "\n";
}

int main() {
    show(42);    // Generic: 42
    show(3.14);  // Generic: 3.14
    show(true);  // Bool: true
}

template <>로 시작하는 정의는 T = bool일 때만 쓰이는 별도 구현입니다. 완전 특수화는 더 이상 템플릿이 아닌 일반 함수이므로, 헤더에 둔다면 inline을 붙여야 여러 번역 단위에서 포함해도 중복 정의 에러가 나지 않습니다. 함수 템플릿에서는 특수화 대신 일반 오버로드 void show(bool value)를 두는 편이 더 단순한 경우가 많습니다. 특수화는 오버로드 해석에 참여하지 않고 기본 템플릿이 선택된 뒤에야 고려되므로, 오버로드와 섞이면 어떤 함수가 불릴지 예측하기 어려워집니다.

포인터용 오버로드

함수 템플릿은 부분 특수화를 할 수 없습니다. “포인터 타입이면 다르게”를 표현하려면 포인터를 받는 템플릿 오버로드를 하나 더 둡니다.

#include <iostream>
template <typename T>
void process(T value) {
    std::cout << "Value: " << value << "\n";
}

template <typename T>
void process(T* ptr) {
    if (ptr) {
        std::cout << "Pointer to: " << *ptr << "\n";
    } else {
        std::cout << "Null pointer\n";
    }
}

int main() {
    int x = 42;
    process(x);   // Value: 42
    process(&x);  // Pointer to: 42
}

process(&x)에는 두 템플릿이 모두 후보가 되지만(T = int*인 첫 번째, T = int인 두 번째), 부분 순서 규칙에 따라 더 특수한 T* 버전이 선택됩니다. 부분 특수화가 필요한 경우는 클래스 템플릿이며, 템플릿 특수화에서 다룹니다.


자주 만나는 컴파일·링크 에러

템플릿 에러 메시지가 긴 것은 컴파일러가 인스턴스화 과정 전체와 전개된 타입 이름을 그대로 보여 주기 때문입니다. 아래 패턴들이 가장 흔한 원인입니다.

템플릿 정의가 .cpp에만 있음

// math.h
template <typename T>
T add(T a, T b);  // 선언만

// math.cpp
template <typename T>
T add(T a, T b) {  // 정의가 .cpp에만 있음
    return a + b;
}

// main.cpp
#include "math.h"
int main() {
    add(3, 5);  // ❌ 링크 에러: undefined reference to `int add<int>(int, int)'
}

main.cpp를 컴파일할 때 add<int>가 필요하지만 이 번역 단위에서는 정의를 볼 수 없으니 컴파일러는 호출만 남겨 둡니다. 한편 math.cpp에는 템플릿 정의는 있지만 add<int>를 쓰는 코드가 없어서 아무 인스턴스도 만들어지지 않습니다. 결국 링커가 찾을 add<int>가 어디에도 없습니다.

해결책은 두 가지입니다. 보통은 템플릿 정의를 헤더에 둡니다. 그러면 헤더를 포함하는 각 번역 단위가 필요한 인스턴스를 만들고, 여러 번역 단위에서 같은 인스턴스가 생겨도 링커가 하나로 합칩니다. 지원할 타입이 정해져 있다면 정의를 .cpp에 두고 그 파일에 template int add<int>(int, int);처럼 명시적 인스턴스화를 적는 방법도 있습니다. 이 경우 구현을 헤더에서 숨길 수 있지만, 목록에 없는 타입으로는 쓸 수 없습니다.

연산자를 지원하지 않는 타입

template <typename T>
T maxOf(T a, T b) {
    return a > b ? a : b;  // T는 operator>가 필요
}

struct Point {
    int x, y;
};

int main() {
    Point p1{1, 2}, p2{3, 4};
    // auto r = maxOf(p1, p2);  // ❌ no match for 'operator>' (operand types are 'Point' and 'Point')
}

템플릿은 T가 본문에서 쓰는 모든 연산을 지원한다고 가정합니다. Point에 operator>를 정의하면 maxOf(p1, p2)도 동작합니다. 이런 암묵적인 요구사항을 선언에 드러내는 방법이 아래의 static_assert와 Concepts입니다.

const 인자를 비const 참조로 받음

template <typename T>
void increment(T& value) {
    ++value;
}

int main() {
    int x = 10;
    increment(x);     // ✅

    const int y = 20;
    // increment(y);  // ❌ T = const int로 추론되어 const int를 수정하려는 에러
}

T&는 const 인자도 받아서 T = const int로 추론되므로, 에러는 호출 지점이 아니라 템플릿 본문의 ++value에서 납니다. 템플릿 에러를 읽을 때 “required from here” 같은 줄로 실제 호출 지점을 거슬러 올라가야 하는 이유입니다.

의존 이름에 typename 누락

template <typename T>
void func() {
    // T::value_type x;        // ❌ 에러: 'typename' 필요
    typename T::value_type y;  // ✅
}

T::value_type처럼 템플릿 매개변수에 의존하는 이름은 인스턴스화 전에는 타입인지 값인지 알 수 없어 컴파일러가 값으로 해석합니다. 타입으로 쓰려면 typename을 붙여야 합니다.

ODR 위반

같은 템플릿이나 같은 특수화가 여러 번역 단위에서 서로 다르게 정의되면 ODR(One Definition Rule) 위반이고, 진단 없이 미정의 동작이 됩니다. 링커는 같은 이름의 인스턴스 중 하나를 골라 쓰므로, 어떤 파일에서는 다른 파일의 정의가 실행되는 일이 생깁니다. 정의는 헤더 한 곳에 두고, 특수화는 그 특수화를 쓰는 모든 곳에서 보이도록 기본 템플릿과 같은 헤더에 둡니다.

static_assert로 요구사항 앞당기기

#include <type_traits>

template <typename T>
T maxOf(T a, T b) {
    static_assert(std::is_arithmetic_v<T>,
                  "maxOf() requires arithmetic types (int, double, ...)");
    return a > b ? a : b;
}

Point를 넘기면 첫 줄에서 “maxOf() requires arithmetic types”라는 메시지가 먼저 나옵니다. 다만 static_assert가 실패해도 컴파일러는 본문을 계속 검사하므로 operator> 에러도 함께 출력될 수 있습니다. 호출 지점에서 깔끔하게 거절하려면 아래의 Concepts처럼 템플릿 자체를 제약하는 편이 낫습니다. 또 이 예제의 조건은 operator>가 있는 사용자 정의 타입까지 거절하므로, 요구사항은 실제로 필요한 만큼만 거는 것이 좋습니다.


실전 예시

설정값 파서

설정 파일에서 읽은 문자열을 여러 타입으로 변환하는 함수를 템플릿 하나로 만들 수 있습니다.

#include <sstream>
#include <stdexcept>
#include <string>

template <typename T>
T parseConfig(const std::string& value) {
    std::istringstream iss(value);
    T result{};
    if (!(iss >> result)) {
        throw std::invalid_argument("cannot parse: " + value);
    }
    return result;
}

// bool은 "true"/"1"/"yes"를 참으로 처리 (헤더에 둘 때는 inline 필요)
template <>
inline bool parseConfig<bool>(const std::string& value) {
    return value == "true" || value == "1" || value == "yes";
}

int main() {
    int port = parseConfig<int>("8080");
    double timeout = parseConfig<double>("3.5");
    bool enabled = parseConfig<bool>("true");
}

기본 템플릿은 operator>>로 변환하므로, operator>>를 지원하는 타입이면 추가 코드 없이 동작합니다. 변환에 실패하면 예외를 던지게 해서, 잘못된 설정값이 조용히 0으로 바뀌는 일을 막았습니다. operator>>는 공백에서 멈추기 때문에 parseConfig<std::string>("hello world")는 "hello"만 반환한다는 점도 알아 둘 만합니다. 문자열은 별도 오버로드로 값을 그대로 돌려주는 편이 낫습니다.

범위 검사가 있는 고정 크기 배열

#include <cstddef>
#include <stdexcept>

template <typename T, std::size_t N>
class SafeArray {
    T data_[N]{};
public:
    T& at(std::size_t i) {
        if (i >= N) throw std::out_of_range("index out of range");
        return data_[i];
    }
    constexpr std::size_t size() const { return N; }
};

using Vec3 = SafeArray<double, 3>;
// Vec3 pos;
// pos.at(0) = 1.0;

크기 N이 타입에 포함되므로 객체 안에 배열이 직접 들어가고, size()는 컴파일 시점에 결정됩니다. 실제 코드에서는 같은 기능을 하는 std::array<T, N>과 그 at()을 쓰면 됩니다.

JSON 직렬화 헬퍼

#include <sstream>
#include <string>

template <typename T>
std::string toJson(const T& value) {
    std::ostringstream oss;
    oss << value;  // 숫자 등: 스트림 출력
    return oss.str();
}

inline std::string toJson(const std::string& value) {
    return "\"" + value + "\"";  // 실제로는 따옴표·역슬래시·제어 문자 이스케이프가 필요
}

inline std::string toJson(const char* value) {
    return toJson(std::string(value));
}

inline std::string toJson(bool value) {
    return value ? "true" : "false";
}

// toJson(42)      → 42
// toJson("hi")    → "hi"   (const char* 오버로드)
// toJson(true)    → true   (bool 오버로드)

여기서는 std::string, const char*, bool을 템플릿 특수화가 아니라 일반 오버로드로 처리했습니다. 특수화 template <> std::string toJson<std::string>(const std::string&)만 두면 toJson("hi") 호출에서 T가 char[3]으로 추론되어 기본 템플릿이 선택되고, 따옴표 없는 hi가 출력됩니다. 비템플릿 오버로드는 템플릿과 똑같이 잘 맞으면 우선 선택되므로 이런 함정이 없습니다. 기본 템플릿의 ostringstream은 bool을 1로 출력하므로 bool 오버로드도 꼭 필요합니다.


코드 크기와 컴파일 시간

템플릿은 사용된 타입마다 코드를 생성하므로 vector<int>, vector<double>, vector<std::string>을 쓰면 각각 다른 기계어가 만들어집니다. 사용하지 않는 타입의 인스턴스는 만들어지지 않으므로, 바이너리 크기는 실제로 쓰는 타입의 수에 비례합니다.

컴파일 시간 쪽 비용은 다른 형태로 나타납니다. 헤더에 정의된 템플릿은 그것을 쓰는 모든 번역 단위에서 각각 인스턴스화되고, 링커가 중복을 하나로 합치기는 하지만 컴파일 작업 자체는 번역 단위마다 반복됩니다. 이 반복을 줄이는 도구가 extern template입니다.

// matrix.h
template <typename T>
class Matrix { /* ... 정의 ... */ };

extern template class Matrix<double>;  // "이 인스턴스는 다른 곳에서 만든다"

// matrix_instances.cpp (한 곳에서만)
#include "matrix.h"
template class Matrix<double>;          // 명시적 인스턴스화

extern template 선언을 본 번역 단위는 Matrix<double>의 멤버 함수를 직접 인스턴스화하지 않고(인라인 대상은 예외일 수 있음) 링크 시점에 matrix_instances.cpp의 것을 씁니다. 이 기법은 직접 만든 템플릿에 적용합니다. std::vector<int>처럼 사용자 정의 타입이 하나도 들어가지 않은 표준 라이브러리 템플릿을 사용자 코드에서 명시적으로 인스턴스화하는 것은 표준이 허용하지 않습니다. 그 밖에 자주 쓰는 헤더를 미리 컴파일해 두는 PCH, 병렬 빌드(make -j, Ninja)도 템플릿이 많은 프로젝트의 빌드 시간을 줄이는 데 효과가 있습니다.


오버로딩 vs 템플릿 선택

flowchart TD
    A[로직이 타입마다 다르다?] -->|예| B[오버로딩]
    A -->|아니오| C[대부분 같고 일부 타입만 다르다?]
    C -->|예| D[템플릿 + 해당 타입용 오버로드]
    C -->|아니오| E[함수 템플릿]

로직이 타입마다 완전히 다르면 오버로딩이 맞고, 로직이 같고 타입만 다르면 템플릿이 맞습니다. 대부분 같지만 몇몇 타입만 예외라면 템플릿에 그 타입용 오버로드(또는 특수화)를 더합니다. 지원할 타입이 두세 개로 고정되어 있고 앞으로 늘어날 일이 없다면 오버로딩으로도 충분합니다.


Type Traits, CRTP, 태그 디스패칭

타입 특성과 if constexpr

#include <stdexcept>
#include <type_traits>

template <typename T>
T safeDivide(T a, T b) {
    static_assert(std::is_arithmetic_v<T>, "Arithmetic type required");
    if constexpr (std::is_integral_v<T>) {
        if (b == 0) throw std::domain_error("Division by zero");
    }
    return a / b;  // 부동소수점은 0으로 나누면 inf/NaN이 됨
}

if constexpr(C++17)는 조건이 거짓인 분기를 인스턴스화하지 않으므로, 정수형일 때만 0 나눗셈 검사 코드가 생성됩니다. 같은 일을 C++14 이하에서는 SFINAE나 태그 디스패칭으로 오버로드를 나눠서 했습니다.

CRTP

template <typename Derived>
struct Base {
    void interface() {
        static_cast<Derived*>(this)->implementation();
    }
};

struct Concrete : Base<Concrete> {
    void implementation() {
        std::cout << "Concrete\n";
    }
};

CRTP(Curiously Recurring Template Pattern)는 파생 클래스를 기반 클래스의 템플릿 인자로 넘겨, 가상 함수 없이 기반 클래스에서 파생 클래스의 함수를 호출하는 정적 다형성 기법입니다. 호출 대상이 컴파일 시점에 정해지므로 인라인이 가능합니다.

CRTP는 같은 보일러플레이트를 여러 클래스에 붙이는 용도로도 자주 쓰입니다. 대표적인 예가 재사용 가능한 싱글톤 기반 클래스인데, 여기서 템플릿의 성질 하나가 중요해집니다. 클래스 템플릿의 static 멤버와 함수 내부 static 변수는 인스턴스화마다 따로 생깁니다. Singleton<Config>와 Singleton<Logger>는 서로 다른 클래스이므로 인스턴스도 각각 하나씩 존재합니다. 구현할 때는 static std::unique_ptr<T> instance; if (!instance) instance = std::make_unique<T>(); 같은 지연 생성보다 함수 내부 static을 쓰는 편이 낫습니다. 앞의 방식은 두 스레드가 동시에 처음 호출하면 객체가 두 번 만들어질 수 있지만, 함수 내부 static 초기화는 C++11부터 스레드 안전이 보장됩니다.

template <typename T>
class Singleton {
public:
    static T& instance() {
        static T obj;  // T마다 하나, 초기화는 스레드 안전 (C++11~)
        return obj;
    }
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
protected:
    Singleton() = default;
};

class Config : public Singleton<Config> {
    friend class Singleton<Config>;  // instance()가 private 생성자를 호출할 수 있게 허용
    Config() = default;
public:
    int value = 0;
};
// Config::instance().value = 100;

태그 디스패칭

타입 특성에 따라 다른 구현을 고를 때, 빈 구조체 태그를 추가 인자로 넘겨 오버로드를 선택하게 하는 기법입니다. 표준 라이브러리의 std::advance가 std::random_access_iterator_tag, std::bidirectional_iterator_tag 같은 반복자 카테고리 태그로 “임의 접근이면 한 번에 더하고, 아니면 한 칸씩 이동”하는 구현을 나누는 것이 대표적인 예입니다. C++17 이후에는 if constexpr, C++20 이후에는 Concepts로 같은 일을 더 직접적으로 표현할 수 있습니다.


한 단계 더: Fold Expression, Concepts, Perfect Forwarding

재귀 전개와 fold expression

// C++11~14: 재귀 전개
void print() {}  // 재귀 종료

template <typename T, typename... Args>
void print(const T& first, const Args&... rest) {
    std::cout << first << " ";
    print(rest...);
}
// print(1, 2.5, "hello", 3);  // 1 2.5 hello 3
// C++17: fold expression
template <typename... Args>
void printAll(const Args&... args) {
    (std::cout << ... << args) << "\n";   // 이항 왼쪽 fold (초기값 std::cout)
}

template <typename... Args>
auto sum(Args... args) {
    return (args + ... + 0);              // 이항 오른쪽 fold (초기값 0)
}
// printAll(1, 2.5, "hello", 3);  // 12.5hello3 (구분자 없음)
// int total = sum(10, 20, 30);   // 60
형태문법펼쳐진 모양 (팩 a, b, c)
단항 오른쪽(pack op ...)a op (b op c)
단항 왼쪽(... op pack)(a op b) op c
이항 오른쪽(pack op ... op init)a op (b op (c op init))
이항 왼쪽(init op ... op pack)((init op a) op b) op c
#include <memory>
#include <string>
#include <type_traits>

// 1) 쉼표 연산자 fold로 각 인자 뒤에 공백 출력
template <typename... Args>
void logSpaced(const Args&... args) {
    ((std::cout << args << " "), ...);
    std::cout << "\n";
}

// 2) 논리 AND fold로 모든 타입 검사
template <typename... Ts>
constexpr bool all_arithmetic = (std::is_arithmetic_v<Ts> && ...);

static_assert(all_arithmetic<int, double, float>);
static_assert(!all_arithmetic<int, std::string>);  // string은 산술 타입이 아니므로 false

// 3) 완벽한 전달을 쓰는 팩토리
template <typename T, typename... Args>
std::unique_ptr<T> makeWidget(Args&&... args) {
    return std::make_unique<T>(std::forward<Args>(args)...);
}

최적화 빌드에서는 재귀 전개와 fold 모두 인라인되어 실행 성능은 사실상 같습니다. 차이는 컴파일 쪽에 있습니다. 재귀 전개는 인자 개수만큼 함수 템플릿을 하나씩 인스턴스화하지만, fold는 식 하나로 끝나서 컴파일러가 만들어야 하는 함수가 줄고 코드도 짧습니다.

SFINAE에서 Concepts로

SFINAE(Substitution Failure Is Not An Error)는 템플릿 인자를 대입하다 실패한 후보를 에러로 처리하지 않고 오버로드 후보에서 조용히 빼는 규칙입니다. C++11~17에서는 이를 std::enable_if와 함께 써서 템플릿을 제약했습니다.

#include <type_traits>

// C++17까지의 방식: enable_if
template <typename T,
          std::enable_if_t<std::is_arithmetic_v<T>, int> = 0>
T addNumbers(T a, T b) {
    return a + b;
}
// addNumbers(1, 2);      // ✅
// addNumbers("a", "b");  // ❌ 후보 없음. 메시지가 enable_if 내부 이야기로 길어지기 쉬움
#include <concepts>
#include <type_traits>

// C++20: Concepts
template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>;

template <Arithmetic T>                  // 축약 문법
T addNumbers(T a, T b) {
    return a + b;
}

template <typename T>
    requires Arithmetic<T> && (!std::same_as<T, bool>)   // requires 절
T safeAdd(T a, T b) {
    return a + b;
}
// addNumbers("a", "b");  // ❌ "the constraint Arithmetic<const char*> was not satisfied" 류의 메시지

두 방식 모두 조건을 만족하지 못한 후보를 오버로드 후보에서 빼는 것은 같고, 호출할 수 있는 후보가 하나도 남지 않을 때만 에러가 납니다. 차이는 에러 메시지와 선언의 읽기 쉬움, 그리고 오버로드 우선순위에 있습니다. Concepts는 한 제약이 다른 제약을 포함하는 관계(subsumption)를 알아서, std::integral<T> 오버로드와 std::signed_integral<T> 오버로드가 함께 있으면 int에 대해 더 구체적인 쪽을 고릅니다. enable_if로는 두 조건이 겹치면 모호한 호출이 되므로, 조건을 서로 배타적으로 직접 나눠 써야 합니다.

상황C++17 (SFINAE)C++20 (Concepts)
단순 타입 검사enable_if_t<is_arithmetic_v<T>>concept Arithmetic 또는 std::integral 등 표준 concept
표현식이 유효한지 검사void_t<decltype(expr)>requires { expr; }
겹치는 조건의 우선순위조건을 배타적으로 나누거나 태그 디스패칭subsumption으로 자동

C++20을 쓸 수 있다면 새 코드는 Concepts로 작성하고, SFINAE는 C++17 이하를 지원해야 하는 라이브러리나 기존 코드를 읽을 때 필요한 지식으로 보면 됩니다. 자세한 내용은 C++20 Concepts에서 다룹니다.

Perfect Forwarding: std::forward

인자를 받아 다른 함수에 그대로 넘기는 래퍼나 팩토리는 인자가 원래 lvalue였는지 rvalue였는지를 보존해야 합니다.

#include <memory>
#include <utility>

// 값으로 받아 그대로 넘김: 함수 안의 args는 이름이 있으므로 lvalue
template <typename T, typename... Args>
std::unique_ptr<T> makeBad(Args... args) {
    return std::make_unique<T>(args...);
}

// 전달 참조 + std::forward: 원래 value category 유지
template <typename T, typename... Args>
std::unique_ptr<T> makeGood(Args&&... args) {
    return std::make_unique<T>(std::forward<Args>(args)...);
}

struct Widget {
    explicit Widget(std::unique_ptr<int> p) : p_(std::move(p)) {}
    std::unique_ptr<int> p_;
};

int main() {
    // auto w1 = makeBad<Widget>(std::make_unique<int>(1));
    //   ❌ 컴파일 에러: args는 lvalue라서 unique_ptr의 복사 생성자(삭제됨)를 호출하려 함
    auto w2 = makeGood<Widget>(std::make_unique<int>(1));  // ✅ 이동으로 전달
}

makeBad는 이동만 가능한 타입에서는 컴파일 에러가 나고, std::string처럼 복사가 가능한 타입에서는 에러 없이 불필요한 복사를 합니다. 오히려 후자가 눈에 띄지 않아 더 흔합니다.

T&& 형태의 매개변수는 템플릿 인자 추론과 참조 축약 규칙 때문에 lvalue와 rvalue를 모두 받습니다.

template <typename T>
void foo(T&& arg);

std::string s = "hello";
foo(s);                     // lvalue: T = std::string&, T&& = std::string& && → std::string&
foo(std::string("world"));  // rvalue: T = std::string,  T&& = std::string&&

std::forward<T>는 이 T에 남은 정보를 이용합니다. 단순화하면 다음과 같습니다.

template <typename T>
T&& forward(std::remove_reference_t<T>& arg) noexcept {
    return static_cast<T&&>(arg);  // T = U&이면 U&로, T = U이면 U&&로 캐스트
}

전달 참조와 std::forward가 없애 주는 것은 인자를 한 단계 넘길 때마다 생기는 복사입니다. int나 포인터처럼 복사가 싼 타입이면 차이가 없고, std::string이나 std::vector처럼 힙 메모리를 가진 타입을 임시 객체로 넘길 때 복사 대신 이동이 일어나 할당이 줄어듭니다. 래퍼가 여러 겹이면 겹마다 복사가 생기므로, 로거·팩토리·emplace 계열처럼 인자를 그대로 넘기기만 하는 함수에서 특히 중요합니다.

템플릿 에러 메시지를 읽기 쉽게 만드는 방법

가장 효과가 큰 방법은 요구사항을 이름 있는 concept으로 만들어 템플릿에 직접 거는 것입니다.

#include <concepts>
#include <iostream>

template <typename T>
concept Printable = requires(std::ostream& os, const T& t) {
    { os << t } -> std::convertible_to<std::ostream&>;
};

template <Printable T>
void printValue(const T& value) {
    std::cout << value << "\n";
}

// printValue(MyClass{});
// ❌ 호출 지점에서 "constraints not satisfied ... Printable<MyClass>" 로 바로 거절

함수 본문의 static_assert와 달리, 제약을 만족하지 않는 호출은 본문을 인스턴스화하기 전에 거절되므로 operator<<를 못 찾았다는 긴 에러가 뒤따르지 않습니다. 조건이 여러 개라면 하나의 긴 requires 절보다 SignedArithmetic, Addable처럼 의미 단위의 concept으로 나누어 두는 편이 메시지에서 어떤 조건이 실패했는지 찾기 쉽습니다.

컴파일러 옵션으로도 에러를 읽기 쉽게 만들 수 있습니다. GCC와 Clang 모두 -ftemplate-backtrace-limit=N으로 인스턴스화 백트레이스 길이를 줄일 수 있습니다. 컴파일러마다 템플릿 에러를 보여 주는 방식이 달라서, 같은 코드를 GCC와 Clang으로 각각 컴파일해 보면 한쪽 메시지가 훨씬 짧고 명확한 경우가 많습니다.


다음 글

다음 글 클래스 템플릿(#9-2)에서는 멤버 함수의 외부 정의, 부분 특수화, CTAD로 제네릭 컨테이너를 만드는 방법을 다룹니다. 이 글의 logValue, parseConfig, toJson 예제를 g++ -std=c++17로 컴파일해 타입을 바꿔 가며 호출해 보면 인스턴스화가 어떻게 일어나는지 감을 잡을 수 있습니다.

같이 보면 좋은 글

참고 자료