C++ 템플릿 입문 | template<typename T>와 템플릿 컴파일 에러 해결법
이 글의 핵심
C++ 템플릿 입문에 대한 실전 가이드입니다. template<typename T>와 템플릿 컴파일 에러 해결법 등을 예제와 함께 상세히 설명합니다.
들어가며: 같은 코드를 타입마다 복사하고 있었다
“int용, double용, string용… 언제까지 복사해야 하나요?”
로그 시스템을 만들고 있었습니다. 여러 타입의 값을 로그로 남기려고 오버로딩(같은 이름의 함수를 매개변수 타입·개수만 다르게 여러 개 두는 것)을 사용했습니다.
문제의 코드에서는 log를 타입마다 오버로딩해서, 출력 형식은 동일한데 int, double, std::string, const char* 네 벌을 따로 작성했습니다. 새 타입(예: MyClass)을 로그에 넣고 싶으면 log를 또 하나 추가해야 하고, 한쪽만 수정하면 동작이 어긋날 수 있습니다. 이런 “로직은 같고 타입만 다른” 경우에 함수 템플릿 하나로 통합하면 유지보수가 쉬워집니다.
void log(int value) {
std::cout << "[LOG] " << value << "\n";
}
void log(double value) {
std::cout << "[LOG] " << value << "\n";
}
void log(const std::string& value) {
std::cout << "[LOG] " << value << "\n";
}
void log(const char* value) {
std::cout << "[LOG] " << value << "\n";
}
// 새 타입마다 함수 추가...
위 코드 설명: 로직은 모두 “[LOG] 값 출력”으로 같은데, int·double·string·const char*마다 함수를 따로 두었습니다. 새 타입을 지원할 때마다 동일한 본문을 복사해야 하고, 한쪽만 수정하면 동작이 달라질 수 있어 유지보수가 어렵습니다.
문제점:
- 로직은 똑같은데 타입만 다름
- 새 타입 지원할 때마다 함수 추가
- 실수로 한 함수만 수정하면 불일치 발생
함수 템플릿을 쓰면 타입 T 하나로 위 함수들을 하나로 통합할 수 있고, 컴파일러가 호출 시점에 실제 타입으로 인스턴스화합니다. 정의를 풀어 쓰면 “템플릿”은 타입을 나중에 정하는 틀이고, 컴파일러가 log(42)처럼 호출을 보면 “이번에는 T를 int로 해서 함수를 만들어 쓴다”고 인스턴스화합니다. 오버로딩은 “타입별로 동작이 다를 때”, 템플릿은 “동작은 같은데 타입만 다를 때” 쓰면 됩니다.
비유하면 함수 템플릿은 붕어빵 틀·쿠키 커터와 같습니다. 같은 틀로 팥·슈크림·피자 치즈를 넣어 서로 다른 붕어빵을 구워 내듯, 컴파일러는 동일한 템플릿에서 T=int, T=double처럼 모양은 같고 재료(타입)만 다른 함수 코드를 찍어 냅니다.
템플릿 인스턴스화 흐름을 한눈에 보면 아래와 같습니다.
flowchart LR A[template T] --> B[log 42] B --> C[T=int 인스턴스] A --> D[log 3.14] D --> E[T=double 인스턴스] A --> F[log string] F --> G[T=string 인스턴스]
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o log_example log_example.cpp && ./log_example
#include <iostream>
#include <string>
template <typename T>
void log(const T& value) {
std::cout << "[LOG] " << value << "\n";
}
int main() {
log(42); // T = int
log(3.14); // T = double
log("hello"); // T = const char*
log(std::string("world")); // T = std::string
}
위 코드 설명: template <typename T> void log(const T& value) 하나로 모든 타입을 처리합니다. 호출 시 전달한 인자 타입으로 T가 추론되어, 컴파일러가 log<int>, log<double>, log<const char*>, log<std::string> 등을 각각 생성합니다. 한 번만 작성하면 새 타입도 자동으로 지원됩니다.
실행 결과:
[LOG] 42
[LOG] 3.14
[LOG] hello
[LOG] world
처음 템플릿을 보면 template <typename T>가 낯설 수 있습니다. “T는 나중에 호출할 때 정해질 타입 하나”라고 생각하면 됩니다. 컴파일러는 log(42)를 보면 T=int로, log(3.14)를 보면 T=double로 각각 다른 함수를 만들어 냅니다.
장점:
- 한 번만 작성
- 모든 타입에 자동 대응
- 타입 안전성 유지
이 경험으로 템플릿의 필요성을 느꼈습니다. 다른 언어의 제네릭(generic—타입을 나중에 정해서 여러 타입에 재사용하는 프로그래밍 방식)이나 “타입 파라미터”와 비슷해 보이지만, C++ 템플릿은 컴파일 시점에 구체적인 타입별 코드를 생성한다는 점이 다릅니다. 그래서 런타임 오버헤드 없이 타입마다 최적화된 코드를 쓸 수 있는 대신, 컴파일 시간과 바이너리 크기가 늘어날 수 있습니다.
이 글을 읽으면:
- 함수 템플릿의 기본 문법을 이해할 수 있습니다.
- 타입 추론 규칙을 알 수 있습니다.
- 템플릿 특수화를 사용할 수 있습니다.
- 실전에서 자주 겪는 템플릿 에러를 해결할 수 있습니다.
이전 글: C++ 실전 가이드 #8-3: 커스텀 예외와 성능에서 예외 성능과 선택 기준을 다뤘습니다.
목차
- 흔히 겪는 문제 시나리오
- 템플릿이란 무엇인가
- 함수 템플릿 기본 문법
- 타입 추론 규칙
- 가변 인자 템플릿 기초
- 템플릿 특수화
- 실전 에러와 해결법
- 베스트 프랙티스
- 실전 예시 3가지
- 성능 비교와 최적화
- 오버로딩 vs 템플릿 선택 가이드
- 프로덕션 패턴
1. 흔히 겪는 문제 시나리오
템플릿을 처음 접하거나 실무에서 사용할 때 아래와 같은 상황을 자주 겪습니다.
시나리오 1: “타입마다 함수를 복사하고 있어요”
상황: 로그·직렬화·설정 파싱에서 int·double·string마다 거의 같은 함수를 오버로딩으로 복사했습니다. 해결 방향: 함수 템플릿 기본 문법에서
template <typename T>로 통합하세요.
시나리오 2: “템플릿을 쓰니 링크 에러가 나요”
상황: 템플릿 함수를 .cpp에 구현하고 헤더에는 선언만 두었더니
undefined reference에러가 납니다. 해결 방향: 실전 에러와 해결법의 “템플릿 정의가 헤더에 없음” 항목을 참고하세요.
시나리오 3: “max(3, 3.14)가 컴파일이 안 돼요”
상황:
max(3, 3.14)호출 시 “no matching function” 에러가 납니다. T 하나로 int와 double을 동시에 만족할 수 없어 타입 추론이 실패합니다. 해결 방향: 타입 추론 규칙에서max<double>(3, 3.14)또는template <typename T, typename U>패턴을 확인하세요.
시나리오 4: “템플릿 에러 메시지가 너무 길어요”
상황: MyClass에 복사 생성자가 없을 때 수십 줄의 템플릿 전개 에러가 출력됩니다. 해결 방향: 베스트 프랙티스에서
static_assert로 요구사항을 앞에서 검사하세요.
시나리오 5: “인자 개수가 가변인 함수를 만들고 싶어요”
상황:
log("Port:", 8080),log("Error:", 404, "message:", "Not Found")처럼 인자 개수·타입이 달라지는 함수가 필요합니다. 해결 방향: 가변 인자 템플릿 기초에서typename... Args와 fold expression을 확인하세요.
2. 템플릿이란 무엇인가
제네릭 프로그래밍
템플릿은 타입을 매개변수로 받는 함수나 클래스를 만드는 기능입니다.
// 템플릿 없이: 타입마다 함수 작성
int max(int a, int b) { return a > b ? a : b; }
double max(double a, double b) { return a > b ? a : b; }
// 템플릿 사용: 한 번만 작성
template <typename T>
T max(T a, T b) {
return a > b ? a : b;
}
위 코드 설명: 타입마다 max를 오버로딩하는 대신, template <typename T> T max(T a, T b) 한 개로 int·double 등 모든 비교 가능한 타입에 대응합니다. 호출 시 인자 타입으로 T가 결정되고, 컴파일 시점에 해당 타입용 함수가 생성됩니다.
컴파일 타임 코드 생성
템플릿은 컴파일 시점에 실제 코드를 생성합니다. 인스턴스화 과정을 시각화하면 다음과 같습니다.
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
subgraph binary["생성된 코드"]
B1["int add int, int"]
B2["double add double, double"]
end
T --> C1 --> I1 --> B1
T --> C2 --> I2 --> B2
위 다이어그램 설명: add(3, 5)와 add(1.5, 2.5) 호출 시 컴파일러가 각각 add<int>, add<double> 인스턴스를 생성합니다. 런타임에 타입을 판단하는 것이 아니라, 컴파일 시점에 구체적인 함수가 만들어집니다.
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; }
위 코드 설명: add(3, 5)와 add(1.5, 2.5)를 컴파일할 때 컴파일러가 add<int>, add<double> 두 버전의 코드를 생성합니다. 런타임에 타입을 판단하는 것이 아니라, 컴파일 시점에 구체적인 함수가 만들어지므로 오버헤드가 없고 타입별로 최적화된 코드가 사용됩니다.
특징:
- 런타임 오버헤드 없음
- 타입마다 별도 코드 생성 (코드 크기 증가)
- 타입 안전성 보장
실무에서는 템플릿을 많이 쓰는 라이브러리(STL(Standard Template Library, 표준 템플릿 라이브러리), Eigen 등)를 포함하면 컴파일이 꽤 길어질 수 있습니다. 대신 한 번 컴파일된 코드는 해당 타입에 맞게 최적화되어 있어서, 성능이 중요한 코드에서 템플릿을 쓰는 이유가 됩니다.
3. 함수 템플릿 기본 문법
단일 타입 매개변수
template <typename T> 다음에 오는 함수는 “T가 하나만 있는” 함수 템플릿입니다. 호출할 때 전달한 인자 타입으로 T가 추론되므로, square(5)는 T=int, square(2.5)는 T=double이 됩니다.
template <typename T>
T square(T value) {
return value * value;
}
int main() {
std::cout << square(5) << "\n"; // 25
std::cout << square(2.5) << "\n"; // 6.25
}
위 코드 설명: template <typename T> T square(T value)에서 T는 “호출할 때 정해지는 타입” 하나입니다. square(5)는 T=int, square(2.5)는 T=double로 추론되어 각각 다른 인스턴스가 생성됩니다. 타입 매개변수는 typename 또는 class로 선언할 수 있습니다.
typename vs class:
template <typename T> // ✅ 권장
void func1(T value) { }
template <class T> // ✅ 동일한 의미
void func2(T value) { }
위 코드 설명: template <typename T>와 template <class T>는 타입 매개변수 선언에서 동일한 의미입니다. T는 반드시 클래스일 필요 없고 int, double 등 어떤 타입이든 될 수 있으므로, 의미상으로는 typename이 더 적절하고 관례적으로도 typename을 많이 씁니다.
둘 다 같은 의미지만, typename이 더 명확하므로 권장됩니다. 역사적으로 class가 먼저 도입되었고, 나중에 중첩 타입을 가리킬 때 typename이 필요해지면서 “타입 매개변수”에는 typename을 쓰는 쪽이 관례처럼 자리 잡았습니다.
여러 타입 매개변수
template <typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
return a + b;
}
int main() {
auto result1 = add(3, 5); // int + int → int
auto result2 = add(3, 2.5); // int + double → double
auto result3 = add(1.5, 2); // double + int → double
std::cout << result1 << "\n"; // 8
std::cout << result2 << "\n"; // 5.5
std::cout << result3 << "\n"; // 3.5
}
위 코드 설명: T와 U 두 타입 매개변수를 쓰면 int와 double처럼 서로 다른 타입을 한 번에 받을 수 있습니다. 반환 타입을 auto와 trailing return type decltype(a + b)로 두면, 두 인자를 더한 결과 타입이 그대로 반환 타입이 되어 int+double 등에서도 컴파일러가 올바르게 추론합니다.
반환 타입을 auto와 decltype(a + b)로 두면, 서로 다른 타입 두 개를 더한 결과 타입(int, double 등)을 컴파일러가 알아서 정해 줍니다. 타입을 하나만 쓰면 add(3, 2.5)처럼 인자 타입이 다를 때 추론이 실패하므로, 이렇게 두 개의 타입 매개변수를 쓰는 패턴이 자주 나옵니다.
비타입 템플릿 매개변수
템플릿 인자에는 타입뿐 아니라 컴파일 시점 상수(정수, enum 등)도 쓸 수 있습니다. FixedArray<int, 5>처럼 원소 타입과 크기를 모두 템플릿 인자로 주면, 크기가 컴파일 시점에 고정되어 스택에 배열을 잡을 수 있고, 런타임 오버헤드가 없습니다.
template <typename T, int Size>
class FixedArray {
T data[Size];
public:
constexpr int size() const { return Size; }
T& operator {
return data[index];
}
const T& operator 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
}
위 코드 설명: template <typename T, int Size>에서 두 번째 인자 Size는 타입이 아니라 컴파일 시점 상수입니다. FixedArray<int, 5>는 원소 타입 int, 크기 5인 배열이 되고, data[Size]는 스택에 고정 크기로 할당됩니다. size()를 constexpr로 쓸 수 있어 컴파일 타임에 값이 정해집니다.
타입뿐 아니라 값도 템플릿 인자로 줄 수 있습니다. int Size처럼 정수 상수로 크기를 받으면, 배열 크기가 컴파일 시점에 고정되어 스택에 할당할 수 있고, size()를 constexpr로 쓸 수 있습니다. 다만 비타입 인자에는 정수, 포인터, enum 등 제한된 종류만 쓸 수 있습니다.
비타입 매개변수 사용 사례:
| 사례 | 예시 | 장점 |
|---|---|---|
| 고정 크기 배열 | FixedArray<int, 5> | 스택 할당, 런타임 오버헤드 없음 |
| 수학 벡터/행렬 | Matrix<double, 3, 3> | 컴파일 타임 크기 검사 |
| 버퍼 크기 | Buffer<char, 1024> | 크기가 컴파일 상수로 최적화 |
4. 타입 추론 규칙
타입 추론은 템플릿을 쓸 때 호출하는 쪽에서 타입을 생략할 수 있게 해 줍니다. print(42)처럼 쓰면 컴파일러가 T = int로 추론하므로, 매번 print<int>(42)라고 쓰지 않아도 됩니다. 다만 추론 규칙은 매개변수가 값인지, 참조인지, const인지에 따라 달라지기 때문에, 한 번쯤 정리해 두는 것이 좋습니다.
자동 타입 추론
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*
int x = 10;
print(x); // T = int
const int y = 20;
print(y); // T = int (const 제거됨)
}
위 코드 설명: 매개변수가 값(T value)이면 인자가 복사되므로, const int를 넘겨도 T는 int로 추론되고 const는 사라집니다. “hello”는 const char*로 추론됩니다. 호출할 때마다 전달한 인자 타입으로 T가 결정되므로 명시적으로 타입을 적지 않아도 됩니다.
참조 타입 추론
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 참조 불가
}
위 코드 설명: 매개변수가 T&이면 인자의 참조 타입이 그대로 전달됩니다. const int y를 넘기면 T=const int, 매개변수 타입은 const int&가 됩니다. 값으로 받을 때와 달리 const가 유지됩니다. 리터럴 42 같은 임시 객체는 비const 참조에 바인딩할 수 없어 printRef(42)는 컴파일 에러입니다.
참조(T&)로 받으면 const가 보존됩니다. const int를 넘기면 T는 const int가 되고, 매개변수 타입은 const int&가 됩니다. 반대로 값으로 받으면(T value) const가 떨어져서 T는 int가 됩니다. 임시 객체(리터럴 42 등)는 비const 참조에 바인딩할 수 없어서 printRef(42)는 컴파일 에러가 납니다.
const 참조 타입 추론
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, 임시 객체도 OK
}
위 코드 설명: const T&로 받으면 T는 참조와 const가 제거된 타입으로 추론됩니다. int x를 넘기면 T=int, 매개변수는 const int&가 됩니다. 임시 객체(42)도 const 참조에 바인딩할 수 있으므로 printConstRef(42)가 가능합니다. 읽기만 할 때는 const T&를 쓰는 것이 일반적입니다.
타입 추론 규칙 요약:
| 매개변수 형태 | 인자 예시 | T 추론 결과 | 비고 |
|---|---|---|---|
T value | const int | int | const 제거 |
T& value | int | int | 참조 유지 |
T& value | const int | const int | const 보존 |
T& value | 42 (리터럴) | 에러 | 임시 객체 불가 |
const T& value | int | int | 참조·const 제거 |
const T& value | 42 (리터럴) | int | 임시 객체 OK |
타입 추론 실패
template <typename T>
T max(T a, T b) {
return a > b ? a : b;
}
int main() {
// ❌ 에러: T가 int인지 double인지 모호
// auto result = max(3, 3.14);
// ✅ 해결 1: 명시적 지정
auto result1 = max<double>(3, 3.14);
// ✅ 해결 2: 타입 통일
auto result2 = max(3.0, 3.14);
}
위 코드 설명: max(3, 3.14)처럼 인자 타입이 int와 double로 다르면, T 하나로는 두 타입을 동시에 만족할 수 없어 추론이 실패합니다. 해결하려면 max<double>(3, 3.14)처럼 명시적으로 T를 지정하거나, 인자 타입을 맞추거나, 아래처럼 타입 매개변수를 두 개 두는 방법이 있습니다.
해결 3: 여러 타입 매개변수 사용
template <typename T, typename U>
auto max2(T a, U b) {
return a > b ? a : b;
}
int main() {
auto result = max2(3, 3.14); // T=int, U=double → OK
}
5. 가변 인자 템플릿 기초
인자 개수가 고정되지 않은 함수를 만들 때 가변 인자 템플릿(variadic template)을 사용합니다. C++11에서 도입되었고, C++17의 fold expression으로 재귀 없이 간단히 작성할 수 있습니다. 자세한 내용은 가변 인자 템플릿(#9-3)에서 다루므로, 여기서는 기본 문법만 소개합니다.
기본 문법: typename… Args
template <typename... Args>
void log(Args... args) {
std::cout << "[LOG] ";
(std::cout << ... << args) << "\n"; // C++17 fold expression
}
int main() {
log("Server started");
log("Port: ", 8080);
log("Connected: ", 5, " users");
log("Error: ", 404, ", message: ", "Not Found");
}
위 코드 설명: typename... Args는 0개 이상의 타입을 한꺼번에 받는 파라미터 팩입니다. Args... args는 인자 팩입니다. (std::cout << ... << args)는 C++17 fold로 모든 인자를 순서대로 cout에 넘깁니다. 인자 개수와 타입이 달라도 하나의 템플릿으로 처리됩니다.
실행 결과
[LOG] Server started
[LOG] Port: 8080
[LOG] Connected: 5 users
[LOG] Error: 404, message: Not Found
C++11 재귀 전개 (fold 없을 때)
C++17 이전에는 재귀 종료 조건 void log()와 재귀 호출 log(rest...)로 전개했습니다. 인자가 하나씩 줄어들다가 0개가 되면 종료 조건이 호출됩니다. 자세한 내용은 가변 인자 템플릿(#9-3)을 참고하세요.
가변 인자 템플릿 사용 시점
| 상황 | 권장 |
|---|---|
| 로그·포맷 출력 | 가변 인자 템플릿 |
| emplace 생성자 | 가변 인자 템플릿 |
| std::tuple, std::variant | 가변 인자 템플릿 |
| 인자 개수 2~3개로 고정 | 일반 템플릿 |
6. 템플릿 특수화
일반 템플릿은 “모든 타입에 같은 로직”을 적용하지만, 특정 타입만 다르게 동작하게 하고 싶을 때가 있습니다. 예를 들어 bool은 “true/false”로만 출력하고 싶거나, 포인터 타입은 역참조해서 내용을 출력하고 싶을 수 있습니다. 이때 특수화를 쓰면, 해당 타입에 대해서만 별도 구현을 제공할 수 있습니다.
완전 특수화 (Full Specialization)
// 일반 템플릿
template <typename T>
void print(const T& value) {
std::cout << "Generic: " << value << "\n";
}
// bool에 대한 특수화
template <>
void print<bool>(const bool& value) {
std::cout << "Bool: " << (value ? "true" : "false") << "\n";
}
int main() {
print(42); // Generic: 42
print(3.14); // Generic: 3.14
print(true); // Bool: true
}
위 코드 설명: 일반 템플릿 print<T>는 모든 타입에 “Generic: 값”을 출력하고, template <> void print<bool>은 bool에 대해서만 “Bool: true/false” 형태로 다르게 동작합니다. 호출 시 컴파일러가 bool 인자면 특수화 버전을, 그 외에는 일반 버전을 선택합니다.
포인터 타입 특수화
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(T value)는 일반 값용, process(T* ptr)는 포인터용 오버로드입니다. process(x)는 T=int인 첫 번째가, process(&x)는 T=int인 두 번째(포인터 버전)가 선택됩니다. 포인터일 때만 역참조해서 내용을 출력하는 식으로, 타입에 따라 다른 동작을 나눌 수 있습니다.
7. 실전 에러와 해결법
템플릿을 쓰다 보면 에러 메시지가 길고 난해한 경우가 많습니다. 컴파일러가 템플릿을 전개한 뒤의 타입 이름이 그대로 노출되기 때문입니다. 아래 패턴들은 실무에서 자주 만나는 실수이므로, 한 번씩 경험해 두면 디버깅할 때 도움이 됩니다.
에러 1: 타입 추론 실패
template <typename T>
T divide(T a, T b) {
return a / b;
}
int main() {
// ❌ 에러: T가 int인지 double인지 모호
// auto result = divide(10, 3.0);
// ✅ 해결
auto result = divide<double>(10, 3.0);
}
위 코드 설명: divide(10, 3.0)은 첫 인자는 int, 두 번째는 double이라 T 하나로는 추론이 맞지 않아 컴파일 에러가 납니다. 이때 호출하는 쪽에서 divide<double>(10, 3.0)처럼 템플릿 인자를 명시하면 T=double로 고정되고, 10이 double로 변환되어 사용됩니다.
에러 2: 템플릿 정의가 헤더에 없음
// 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); // 링크 에러!
}
위 코드 설명: 템플릿 함수의 정의가 .cpp에만 있으면, main.cpp를 컴파일할 때 add(3, 5)에 대한 add<int> 인스턴스가 필요한데 그 정의를 이 번역 단위에서 볼 수 없습니다. 다른 .cpp에 있는 정의는 링커가 “어떤 타입으로 인스턴스화했는지” 알 수 없어 링크 에러가 납니다.
해결: 템플릿 정의는 헤더 파일에 작성
// math.h
template <typename T>
T add(T a, T b) { // ✅ 헤더에 정의
return a + b;
}
위 코드 설명: 템플릿 정의를 헤더에 두면, 이 헤더를 include하는 모든 .cpp에서 add(3, 5) 같은 호출을 컴파일할 때 해당 번역 단위 안에서 add<int> 정의가 생성됩니다. 컴파일러가 인스턴스화할 때 정의가 보여야 하므로, 템플릿 라이브러리는 보통 헤더에 구현까지 넣거나 명시적 인스턴스화를 따로 둡니다.
이유는 간단합니다. 컴파일러가 add(3, 5)를 컴파일할 때 add<int> 버전의 코드를 그 번역 단위(.cpp) 안에서 만들어야 합니다. 선언만 있고 정의가 다른 .cpp에 있으면, 링커가 “어떤 타입으로 인스턴스화했는지” 알 수 없어서 정의를 찾지 못합니다. 그래서 템플릿을 쓰는 라이브러리는 대부분 헤더만 제공하거나, 명시적 인스턴스화를 별도로 둡니다.
에러 3: 연산자 지원 안 하는 타입
template <typename T>
T max(T a, T b) {
return a > b ? a : b; // T는 > 연산자 필요
}
struct Point {
int x, y;
// > 연산자 없음
};
int main() {
Point p1{1, 2}, p2{3, 4};
// ❌ 컴파일 에러: Point에 > 연산자 없음
// auto result = max(p1, p2);
}
위 코드 설명: max 템플릿은 내부에서 a > b를 사용하므로, T는 operator>를 지원해야 합니다. Point에는 비교 연산자가 없으므로 max(p1, p2)를 쓰면 “operator>가 없다”는 컴파일 에러가 납니다. 템플릿은 “T가 가진 연산만” 사용할 수 있다는 제약이 있습니다.
해결: 연산자 오버로딩
struct Point {
int x, y;
bool operator>(const Point& other) const {
return x > other.x; // x 기준 비교
}
};
int main() {
Point p1{1, 2}, p2{3, 4};
auto result = max(p1, p2); // ✅ OK
}
위 코드 설명: Point에 operator>를 정의하면 max(p1, p2)에서 T=Point로 인스턴스화할 수 있습니다. 템플릿이 요구하는 연산을 타입이 제공하면, 그 타입에도 동일한 제네릭 함수를 사용할 수 있습니다.
에러 4: const 불일치
template <typename T>
void modify(T& value) {
value++;
}
int main() {
int x = 10;
modify(x); // ✅ OK
const int y = 20;
// ❌ 에러: const int&를 int&로 바인딩 불가
// modify(y);
}
위 코드 설명: modify(T& value)는 비const 참조만 받을 수 있습니다. const int y를 넘기면 const int&가 되는데, 이를 int&로 바인딩할 수 없어 컴파일 에러가 납니다. const 객체를 수정하지 않고 읽기만 할 때는 const T& 버전을 따로 두어야 합니다.
해결: const 버전 추가
template <typename T>
void modify(T& value) {
value++;
}
template <typename T>
void modify(const T& value) {
// const 버전: 읽기만
std::cout << value << "\n";
}
위 코드 설명: 동일한 이름으로 T& 버전과 const T& 버전을 두면, 비const 인자에는 첫 번째가, const 인자에는 두 번째가 선택됩니다. const 버전에서는 value를 수정하지 않고 읽기만 하므로, const 객체나 임시 객체도 받을 수 있습니다.
에러 5: 템플릿 에러 메시지가 너무 길다
템플릿 에러는 전개된 타입 이름이 그대로 노출되어 읽기 어렵습니다.
error: no match for 'operator>' (operand types are 'Point' and 'Point')
return a > b ? a : b;
^
해결: static_assert로 요구사항을 명시하면 에러 위치가 앞당겨집니다.
에러 6: ODR 위반 (One Definition Rule)
같은 템플릿이 여러 번역 단위에서 다르게 정의되면 ODR 위반으로 미정의 동작이 됩니다. 템플릿 정의는 한 곳(헤더)에만 두세요.
에러 7: 가변 인자 템플릿에서 빈 인자
log()처럼 인자가 0개일 때, (std::cout << ... << args) fold는 일부 컴파일러에서 문제가 될 수 있습니다. 종료 조건 void log()를 두는 것이 안전합니다.
에러 8: dependent name에 typename 누락
template <typename T>
void func() {
T::value_type x; // ❌ 에러: 'typename' 필요
typename T::value_type y; // ✅ OK
}
위 코드 설명: T::value_type처럼 템플릿 매개변수에 의존하는 중첩 타입은 typename을 붙여 “타입”임을 컴파일러에 알려야 합니다.
#include <type_traits>
template <typename T>
T max(T a, T b) {
static_assert(std::is_arithmetic_v<T>,
"max() requires arithmetic types (int, double, etc.)");
return a > b ? a : b;
}
위 코드 설명: static_assert로 T가 산술 타입인지 검사하면, Point를 넘겼을 때 “max() requires arithmetic types”처럼 명확한 메시지가 먼저 나옵니다. <type_traits>의 std::is_arithmetic_v를 사용합니다.
8. 베스트 프랙티스
템플릿을 안전하고 유지보수하기 쉽게 쓰기 위한 권장 사항입니다.
1. const T&로 읽기 전용 인자 받기
읽기만 할 때는 const T&를 쓰면 복사를 피하고, 임시 객체도 받을 수 있습니다. T value로 받으면 복사가 발생하고, T&는 임시 객체에 바인딩할 수 없습니다.
2. static_assert로 요구사항 명시
static_assert(std::is_arithmetic_v<T>, "max() requires arithmetic types")로 T가 산술 타입인지 검사하면, Point를 넘겼을 때 “operator>가 없다” 같은 긴 에러 대신 명확한 메시지가 먼저 나옵니다.
3. typename vs class
template <typename T>와 template <class T>는 동일한 의미입니다. T는 클래스일 필요 없으므로 typename이 더 명확합니다.
4. if constexpr (C++17)
if constexpr로 컴파일 시점에 분기하면, 포인터일 때와 아닐 때 다른 코드 경로가 컴파일됩니다. SFINAE보다 읽기 쉽습니다.
5. 명시적 인스턴스화로 바이너리 크기 제어
헤더에 템플릿 정의를 두되, template void process<int>(int);처럼 특정 타입만 명시적 인스턴스화하면 사용하지 않는 타입은 바이너리에 포함되지 않습니다.
베스트 프랙티스 체크리스트
- 읽기 전용:
const T&사용 - 요구사항:
static_assert로 명시 - 타입 매개변수:
typename권장 - 분기:
if constexpr사용 (C++17) - 인스턴스화: 필요한 타입만 명시적 인스턴스화
9. 실전 예시 3가지
예시 1: 설정값 로더 (타입별 파싱)
설정 파일에서 값을 읽을 때, int·double·string·bool마다 파싱 로직이 다릅니다. 템플릿으로 통합하면 새 타입 추가 시 특수화만 하면 됩니다.
#include <string>
#include <sstream>
template <typename T>
T parseConfig(const std::string& value) {
T result;
std::istringstream iss(value);
iss >> result;
return result;
}
// bool 특수화: "true"/"1" 등 처리
template <>
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");
}
위 코드 설명: parseConfig<T>는 istringstream으로 문자열을 T로 변환합니다. bool은 “true”/“1” 같은 문자열을 처리하도록 특수화했습니다. 새 타입을 지원하려면 해당 타입에 대한 operator>>를 정의하거나 특수화를 추가하면 됩니다.
예시 2: 안전한 배열 인덱스 접근
런타임에 크기가 정해지는 std::vector와 달리, 컴파일 시점에 크기가 고정된 배열이 필요할 때 비타입 템플릿을 씁니다. 수학 연산이나 신호 처리에서 3x3 행렬, 4D 벡터 등이 대표적입니다.
#include <stdexcept>
template <typename T, size_t N>
class SafeArray {
T data[N];
public:
T& at(size_t i) {
if (i >= N) throw std::out_of_range("index out of range");
return data[i];
}
constexpr size_t size() const { return N; }
};
// 3D 좌표
using Vec3 = SafeArray<double, 3>;
// Vec3 pos;
// pos.at(0) = 1.0; // x
// pos.at(1) = 2.0; // y
// pos.at(2) = 3.0; // z
위 코드 설명: SafeArray<T, N>은 크기 N이 컴파일 시점에 고정되어 스택에 할당됩니다. at()으로 범위 검사를 하고, size()는 constexpr로 컴파일 타임에 결정됩니다. std::array와 유사한 패턴입니다.
예시 3: JSON 직렬화 헬퍼
여러 타입을 JSON 문자열로 변환할 때, 타입별로 to_json을 오버로딩하는 대신 템플릿 + 특수화로 확장 가능하게 만들 수 있습니다.
#include <string>
#include <sstream>
template <typename T>
std::string toJson(const T& value) {
std::ostringstream oss;
oss << value; // 기본: stream 출력
return oss.str();
}
template <>
std::string toJson<std::string>(const std::string& value) {
return "\"" + value + "\"";
}
template <>
std::string toJson<bool>(const bool& value) {
return value ? "true" : "false";
}
// 사용 예:
// std::string s = toJson(42); // "42"
// std::string t = toJson("hi"); // "\"hi\""
// std::string u = toJson(true); // "true"
위 코드 설명: 기본 템플릿은 operator<<로 출력하고, std::string과 bool은 JSON 형식에 맞게 따옴표나 true/false로 특수화했습니다. 복잡한 구조체는 해당 타입에 대한 특수화를 추가해 확장합니다.
10. 성능 비교와 최적화
템플릿 vs 오버로딩 vs 매크로
| 방식 | 컴파일 시간 | 바이너리 크기 | 타입 안전성 | 유지보수 |
|---|---|---|---|---|
| 템플릿 | 길어짐 (인스턴스마다 코드 생성) | 커짐 | ✅ | ✅ |
| 오버로딩 | 짧음 | 작음 | ✅ | ❌ (타입마다 추가) |
| 매크로 | 짧음 | 작음 | ❌ | ❌ |
템플릿은 사용된 타입마다 코드가 생성되므로, vector<int>, vector<double>, vector<std::string>을 쓰면 각각 다른 기계어가 만들어집니다. 대신 런타임 분기 없이 인라인 최적화가 잘 됩니다.
인스턴스화 최소화
불필요하게 많은 타입으로 인스턴스화하면 컴파일 시간과 바이너리가 커집니다. 실제로 쓰는 타입만 인스턴스화되도록 하세요.
// ❌ 나쁜 예: 모든 타입에 대해 인스턴스화 유도
template <typename T>
void process(T value) { /* ... */ }
// 여러 .cpp에서 int, double, string, MyClass... 호출
// → 각 타입마다 코드 생성
// ✅ 좋은 예: 필요한 타입만 명시적 인스턴스화
// template_impl.cpp
template <typename T>
void process(T value) { /* ... */ }
template void process<int>(int);
template void process<double>(double);
// MyClass는 이 .cpp에서 쓰지 않으면 인스턴스화 안 됨
위 코드 설명: 헤더에 템플릿 정의를 두되, 특정 타입만 template void process<int>(int);처럼 명시적 인스턴스화를 두면, 그 타입들에 대해서만 코드가 생성됩니다. 사용하지 않는 타입은 바이너리에 포함되지 않습니다.
컴파일 시간 단축 팁
- PCH(Precompiled Header) 사용: 자주 쓰는 헤더를 미리 컴파일
- 외부 템플릿(extern template):
extern template class std::vector<int>;로 중복 인스턴스화 방지 - 빌드 병렬화:
-j옵션으로 여러 코어 활용
11. 오버로딩 vs 템플릿 선택 가이드
언제 오버로딩을 쓰고, 언제 템플릿을 쓸지 결정하는 흐름입니다.
flowchart TD
A[로직이 타입마다 다르다?] -->|예| B[오버로딩 사용]
A -->|아니오| C[로직이 동일하다?]
C -->|예| D[함수 템플릿 사용]
C -->|아니오| E[일부 타입만 다르다?]
E -->|예| F[템플릿 + 특수화]
B --> G[int: 덧셈, string: 연결 등]
D --> H[max, min, swap 등]
F --> I[bool: true/false 출력 등]
선택 기준 요약:
| 상황 | 권장 |
|---|---|
| 로직이 타입마다 완전히 다름 | 오버로딩 |
| 로직이 같고 타입만 다름 | 템플릿 |
| 대부분 같고 일부 타입만 예외 | 템플릿 + 특수화 |
| 타입 수가 2~3개로 고정 | 오버로딩도 가능 |
| 타입이 무한히 늘어날 수 있음 | 템플릿 |
12. 프로덕션 패턴
실무에서 자주 쓰는 템플릿 패턴입니다.
패턴 1: 타입 특성 (Type Traits) 활용
#include <type_traits>
template <typename T>
void 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::runtime_error("Division by zero");
}
}
위 코드 설명: std::is_arithmetic_v, std::is_integral_v로 타입 제약을 걸어, 정수형일 때만 0 나눗셈 검사를 합니다.
패턴 2: CRTP (Curiously Recurring Template Pattern)
template <typename Derived>
struct Base {
void interface() {
static_cast<Derived*>(this)->implementation();
}
};
struct Concrete : Base<Concrete> {
void implementation() {
std::cout << "Concrete\n";
}
};
위 코드 설명: 파생 클래스를 템플릿 인자로 받아, 정적 다형성(가상 함수 없이)을 구현합니다. 성능이 중요한 코드에서 사용합니다.
패턴 3: 태그 디스패칭
타입 특성에 따라 다른 구현을 호출할 때, tag_vector, tag_list 같은 태그 타입으로 오버로드를 나눕니다. STL 알고리즘에서 흔히 쓰는 패턴입니다.
패턴 4: extern template로 중복 인스턴스화 방지
// common_types.h
extern template class std::vector<int>;
extern template class std::vector<double>;
// vector_instances.cpp (한 번만 컴파일)
template class std::vector<int>;
template class std::vector<double>;
위 코드 설명: 여러 번역 단위에서 std::vector<int>를 쓰면 각각 인스턴스화되어 컴파일이 느려집니다. extern template으로 “다른 곳에서 이미 인스턴스화했음”을 알려 중복을 막습니다.
프로덕션 체크리스트
- 타입 제약:
static_assert또는 C++20 concepts - 헤더 전용 vs 명시적 인스턴스화 결정
-
extern template로 대형 프로젝트 컴파일 시간 단축 - CRTP·태그 디스패칭 등 패턴 적재적소 활용
구현 체크리스트
함수 템플릿을 도입할 때 확인할 항목입니다.
- 정의 위치: 템플릿 정의를 헤더에 두었는가?
- 타입 추론:
func<int>(...)명시 없이 호출 가능한가? - 연산자 제약: T가 사용하는 연산(>, +, << 등)을 지원하는가?
- const/참조: 읽기만 할 때
const T&를 썼는가? - 특수화 필요성: 일부 타입만 다르게 동작해야 하는가?
- 에러 메시지:
static_assert로 요구사항을 명시했는가? - 인스턴스화: 불필요한 타입으로 인스턴스화되지 않았는가?
일반적인 실수와 주의점
실수 1: 템플릿을 .cpp에 구현하고 헤더에서 선언만 두기
템플릿 정의가 헤더에 없으면 링크 에러가 납니다. 컴파일러가 add<int>를 만들 때 그 정의를 그 번역 단위에서 볼 수 있어야 합니다. 선언만 있고 정의가 다른 .cpp에 있으면, 링커가 “어떤 타입으로 인스턴스화했는지” 알 수 없어 정의를 찾지 못합니다.
실수 2: max(3, 3.14)처럼 서로 다른 타입을 한 번에 넘기기
template <typename T> T max(T a, T b)에서 T는 하나뿐입니다. max(3, 3.14)는 T가 int인지 double인지 모호해 추론이 실패합니다. 해결: max<double>(3, 3.14)처럼 명시하거나, template <typename T, typename U> auto max2(T a, U b)처럼 타입을 두 개 두세요.
실수 3: 임시 객체를 비const 참조로 받기
template <typename T> void foo(T& x)에 foo(42)를 넘기면 에러입니다. 리터럴 42는 임시 객체이고, 비const 참조는 임시 객체에 바인딩할 수 없습니다. 읽기만 할 때는 const T&를 쓰세요.
실수 4: T가 지원하지 않는 연산 사용하기
template <typename T> T add(T a, T b) { return a + b; }에 T=std::string을 넘기면 operator+가 있으므로 동작합니다. 반면 T=MyClass인데 MyClass에 operator+가 없으면 컴파일 에러입니다. 템플릿은 “T가 가진 연산만” 사용할 수 있다는 제약이 있습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법
- C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화
- C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
이 글에서 다루는 키워드 (관련 검색어)
C++ 템플릿, 함수 템플릿, 클래스 템플릿, template 기초, 타입 파라미터, 템플릿 인스턴스화, 가변 인자 템플릿, typename vs class, 템플릿 에러 해결, static_assert, CRTP, 템플릿 베스트 프랙티스 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| 기본 문법 | template <typename T> T func(T a) |
| 타입 추론 | 컴파일러가 호출 시점에 자동 결정 |
| 명시적 지정 | func<int>(...) |
| 특수화 | 특정 타입에 대한 별도 구현 |
| 정의 위치 | 헤더 파일에 작성 필수 |
| 제약 | T가 지원하는 연산만 사용 가능 |
| 실전 예시 | 설정 파싱, SafeArray, JSON 직렬화 |
| 성능 | 런타임 오버헤드 없음, 인스턴스화 최소화 권장 |
참고 자료
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++ 함수 템플릿 완벽 입문 가이드. template
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
Q. Java/C# 제네릭과 C++ 템플릿의 차이는?
A. Java/C# 제네릭은 타입 소거(type erasure) 방식으로, 런타임에는 하나의 코드만 존재합니다. C++ 템플릿은 컴파일 시점에 타입별로 코드를 생성하므로, vector<int>와 vector<double>은 완전히 다른 클래스입니다. 그래서 C++에서는 기본 타입(int, double)도 템플릿 인자로 쓸 수 있고, 런타임 오버헤드가 없으며, 인라인 최적화가 잘 됩니다. 대신 컴파일 시간과 바이너리 크기가 늘어날 수 있습니다.
Q. 템플릿 에러가 너무 길어서 읽기 어려울 때는?
A. static_assert로 요구사항을 앞에서 검사하면, “operator>가 없다” 같은 긴 메시지 대신 “max() requires arithmetic types”처럼 짧은 메시지가 먼저 나옵니다. #include <type_traits>의 std::is_arithmetic_v<T>, std::is_integral_v<T> 등을 활용하세요.
한 줄 요약: 같은 로직에 타입만 다를 땐 함수 템플릿 template <typename T>로 하나로 통합하면 유지보수가 쉬워집니다. 다음으로 클래스 템플릿(#9-2)을 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #9-2: 클래스 템플릿
실습 추천: 이 글의 log, parseConfig, toJson 예제를 복사해 컴파일해 보세요. g++ -std=c++17 -o test test.cpp로 빌드한 뒤, 타입을 바꿔 가며 호출해 보면 템플릿 인스턴스화를 체감할 수 있습니다.
관련 글
- C++ 클래스 템플릿 | 제네릭 컨테이너와 부분 특수화
- C++ 템플릿 특수화 완벽 가이드 | 완전·부분 특수화, 문제 시나리오, 프로덕션 패턴
- C++ 가변 인자 템플릿 | Variadic Templates와 Fold Expression
- C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
- C++ 예외 안전성 |