C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
이 글의 핵심
C++20 Concepts에 대해 정리한 개발 블로그 글입니다. 템플릿 함수에 잘못된 타입을 넘겼을 때, 컴파일러는 인스턴스화 스택을 길게 찍어서 에러가 읽기 어려웠습니다. Concepts(컨셉—템플릿 인자가 만족해야 할 조건을 이름 붙여 선언하는 C++20 기능)는 "이 템플릿이 어떤… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: C+…
들어가며: 템플릿 에러 메시지가 너무 난해했다
”타입이 뭔지 몰라서 100줄 에러가 나요”
템플릿 함수에 잘못된 타입을 넘겼을 때, 컴파일러는 인스턴스화 스택을 길게 찍어서 에러가 읽기 어려웠습니다. Concepts(컨셉—템플릿 인자가 만족해야 할 조건을 이름 붙여 선언하는 C++20 기능)는 “이 템플릿이 어떤 타입을 기대하는지”를 선언해서, 제약을 만족하지 않으면 컴파일러가 “어떤 제약이 불만족인지”를 먼저 알려 주게 합니다. 비유하면 “이 자리에는 정수형만 넣을 수 있다”라고 규칙을 적어 두면, 실수로 다른 타입을 넣었을 때 “정수형이 아님”이라고 바로 짚어 주는 것과 같습니다. std::integral, std::ranges::range 같은 표준 개념을 쓰면 의도를 드러내고 에러 메시지도 나아지며, 오버로딩·특수화 시에도 우선순위를 정하기 쉬워집니다.
이 글에서 다루는 것:
- Concept이 무엇인지, 기존 SFINAE(Substitution Failure Is Not An Error—템플릿 인자 치환 실패 시 그 오버로드만 제외하고 에러로 보지 않는 규칙)/enable_if와 어떤 차이가 있는지
- 표준 개념을 골라 쓰는 방법과, 실제 코드에 적용하는 패턴
- “정수만 받고 싶다”, “컨테이너만 받고 싶다” 같은 자주 묻는 질문에 대한 답
문제의 코드에서는 add가 어떤 타입 T든 받을 수 있어서, vector를 넘기면 컴파일러가 T = std::vector<int>로 인스턴스화한 뒤 a + b를 찾다가 실패합니다. 이때 에러는 “operator+가 없다”가 아니라 템플릿 인스턴스화 스택이 길게 나와서, “왜 이 타입이 안 되는지”를 한눈에 보기 어렵습니다. Concepts를 쓰면 T에 “정수만 받겠다” 같은 제약을 걸어 두어, vector를 넘기는 순간 “std::integral을 만족하지 않습니다”처럼 어떤 제약이 깨졌는지 먼저 알 수 있습니다. 실무에서 템플릿 에러가 길게 나올 때 Concepts를 도입하면 원인 파악이 훨씬 빨라집니다.
template <typename T>
T add(T a, T b) {
return a + b;
}
int main() {
add(std::vector<int>{}, std::vector<int>{}); // vector에 + 없음
// 에러: 50줄 넘는 인스턴스화 메시지...
}
Concepts로 해결:
// 복사해 붙여넣은 뒤: g++ -std=c++20 -o concepts_add concepts_add.cpp && ./concepts_add
#include <concepts>
#include <iostream>
template <std::integral T> // 정수 타입만 허용
T add(T a, T b) {
return a + b;
}
int main() {
std::cout << add(3, 5) << "\n"; // OK → 8
// add(3.14, 2.0); // 이 줄의 주석을 풀면 컴파일 에러: std::integral 불만족
return 0;
}
실행 결과: 8 이 한 줄 출력됩니다.
Concept을 만족하지 않으면 컴파일러가 제약 조건을 먼저 지적합니다. 예시 메시지(환경에 따라 다름):
error: no matching function for call to 'add(double, double)'
note: template constraint not satisfied
note: 'double' does not satisfy 'std::integral'
컴파일: 이 글의 예제는 C++20 기준입니다. g++ -std=c++20 또는 Clang -std=c++20으로 빌드하면 됩니다.
에러 메시지 Before / After 비교
Before (Concept 없이): add(std::vector<int>{}, std::vector<int>{}) 호출 시
error: no match for 'operator+' (operand types are 'std::vector<int>' and 'std::vector<int>')
34 | return a + b;
| ~~^~~
note: candidate: 'operator+(int, int)' (built-in)
note: candidate: 'operator+(double, double)' (built-in)
... (수십 줄의 템플릿 인스턴스화 스택)
After (Concept 사용): add(std::vector<int>{}, std::vector<int>{}) 호출 시
error: no matching function for call to 'add(std::vector<int>, std::vector<int>)'
note: template constraint not satisfied
note: 'std::vector<int>' does not satisfy 'std::integral'
차이: Concepts를 쓰면 “std::integral을 만족하지 않습니다”가 바로 나와서, “이 함수는 정수만 받는다”는 것을 즉시 알 수 있습니다. Before에서는 “operator+가 없다”가 나오지만, 왜 vector가 안 되는지는 스택을 끝까지 따라가야 알 수 있습니다.
Concepts vs 기존 방식 (SFINAE, enable_if)
C++20 이전에는 SFINAE(Substitution Failure Is Not An Error)나 std::enable_if로 “이 타입일 때만 이 오버로드를 사용한다”를 표현했습니다. 동작은 비슷하지만, 가독성과 에러 메시지가 크게 다릅니다.
flowchart LR
subgraph old["SFINAE / enable_if"]
O1[복잡한 문법] --> O2[긴 인스턴스화 스택]
O2 --> O3[원인 파악 어려움]
end
subgraph new["Concepts (C++20)"]
N1[한 줄로 의도 명확] --> N2[제약 불만족 즉시 표시]
N2 --> N3[원인 파악 빠름]
end
old -.->|개선| new
| 구분 | SFINAE / enable_if | Concepts (C++20) |
|---|---|---|
| 가독성 | typename std::enable_if<std::is_integral_v<T>>::type* = nullptr 같은 복잡한 문법 | template <std::integral T> 한 줄로 의도 명확 |
| 에러 메시지 | ”대체 실패” 또는 긴 인스턴스화 스택 | ”T does not satisfy std::integral”처럼 어떤 제약이 깨졌는지 바로 표시 |
| 조합 | 여러 조건을 &&, ||로 묶을 때 문법이 매우 복잡해짐 | requires A<T> && B<T> 형태로 읽기 쉬움 |
| 오버로드 분리 | 가능하지만 코드가 길어짐 | 같은 이름으로 정수/실수 등 타입별 오버로드를 깔끔하게 나누기 좋음 |
정리: 새로 작성하는 C++20 이상 코드에서는 Concepts를 우선 사용하는 것이 좋습니다. 레거시 코드에서 enable_if를 만나면 “과거에는 이렇게 제약을 걸었구나” 정도로 이해하면 됩니다.
enable_if vs Concepts — 실제 코드 비교
enable_if로 작성한 코드 (C++17 이전):
#include <type_traits>
// 정수만 받는 add — enable_if가 반환 타입에 붙어 있음
template <typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
add(T a, T b) {
return a + b;
}
// 실수만 받는 add — 별도 오버로드
template <typename T>
typename std::enable_if<std::is_floating_point_v<T>, T>::type
add(T a, T b) {
return a + b;
}
문제점:
typename std::enable_if<...>::type이 반환 타입에 붙어 있어서, 의도가 “이 타입일 때만 이 함수를 쓸 수 있다”인데 문법이 길고 읽기 어렵습니다.add(std::vector<int>{}, std::vector<int>{})를 호출하면, 두 오버로드 모두 대체 실패가 되어 “no matching function” 에러가 나는데, 어떤 조건이 빠졌는지가 에러 메시지에 드러나지 않습니다.
Concepts로 동일한 코드:
#include <concepts>
template <std::integral T>
T add(T a, T b) { return a + b; }
template <std::floating_point T>
T add(T a, T b) { return a + b; }
장점:
- 한 줄로 의도가 명확합니다. “정수만”, “실수만”이 바로 보입니다.
add(std::vector<int>{}, std::vector<int>{})를 호출하면 “std::integral을 만족하지 않습니다”, “std::floating_point를 만족하지 않습니다”처럼 어떤 제약이 깨졌는지 바로 표시됩니다.
실전에서 겪는 문제 시나리오 3가지
시나리오 1: 라이브러리 API 문서화 부족
팀원이 작성한 process_data(T& data) 함수에 std::unique_ptr<int>를 넘겼더니, 80줄짜리 에러가 나왔습니다. “왜 안 되지?”를 찾느라 30분을 썼고, 결국 T가 복사 가능해야 한다는 걸 코드 깊은 곳에서 발견했습니다. Concepts로 template <std::copyable T>를 붙여 두었다면, “std::copyable을 만족하지 않습니다” 한 줄로 끝났을 겁니다.
시나리오 2: 제네릭 알고리즘에 잘못된 반복자
sort_range(first, last)에 std::list의 반복자를 넘겼는데, std::sort는 random_access_iterator만 받습니다. 에러 메시지가 “no match for ‘operator-’…”로 시작해, “왜 list가 안 되지?”를 파악하기 어려웠습니다. template <std::random_access_iterator It>로 제약을 걸면, “random_access_iterator를 만족하지 않습니다”가 바로 나옵니다.
시나리오 3: 오버로드 충돌로 디버깅 난이도 상승
serialize(T value)가 정수·실수·문자열을 각각 다르게 처리하는데, enable_if 3개가 겹쳐 있어서 “이 타입일 때 어떤 버전이 선택되지?”를 추적하기 힘들었습니다. Concepts로 std::integral, std::floating_point, std::convertible_to<T, std::string>를 나누면, 선택 규칙이 코드에 그대로 드러납니다.
이 글을 읽으면:
- Concept와
requires절의 기본 문법을 이해할 수 있습니다. - 표준 개념(
std::integral,std::copyable등)을 언제 어떤 것을 쓸지 선택할 수 있습니다. - 템플릿 제약으로 에러 메시지를 개선하고, 오버로드·특수화를 명확히 할 수 있습니다.
목차
- 실전 문제 시나리오
- Concept이란
- requires 절
- 표준 개념 완전 예제
- 함수 템플릿에 적용
- 실전 활용
- 자주 발생하는 문제와 해결법
- 모범 사례
- 프로덕션 패턴
- 성능과 컴파일 시간
- 정리 및 FAQ
- 구현 체크리스트
1. 실전 문제 시나리오
개발 중 Concepts가 없을 때 겪는 전형적인 상황을 정리했습니다.
| 상황 | 증상 | Concepts 도입 후 |
|---|---|---|
| 잘못된 타입 전달 | 50~100줄 인스턴스화 스택 | ”std::integral 불만족” 한 줄 |
| API 의도 불명확 | 코드 읽어도 어떤 타입이 허용되는지 모름 | template <std::copyable T>로 즉시 파악 |
| 오버로드 선택 규칙 복잡 | enable_if 여러 개가 겹쳐 추적 어려움 | Concept별로 명확히 분리 |
| 반복자/range 혼동 | list에 sort 적용 시 난해한 에러 | ”random_access_iterator 필요”로 즉시 표시 |
2. Concept이란
타입에 대한 조건
Concept은 타입이 만족해야 하는 조건을 표현합니다. “이 함수는 정수 타입만 받는다”, “이 클래스는 복사 가능한 타입만 받는다”처럼 요구 사항을 문서화하면서, 컴파일러가 그 조건을 컴파일 타임에 검사하게 합니다. 만족하지 않으면 바로 그 자리에서 에러가 나므로, 실행 중이 아니라 코드를 짤 때 잘못된 사용을 잡을 수 있습니다.
flowchart TD
A[템플릿 호출] --> B{Concept 제약 검사}
B -->|만족| C[템플릿 인스턴스화]
B -->|불만족| D[즉시 에러: "어떤 제약이 깨졌는지" 표시]
C --> E[정상 실행]
실전에서 쓰는 경우:
- API 의도 명확화: “이 함수는
int,long같은 정수만 받는다”를 코드만 봐도 알 수 있음 - 에러 메시지 개선:
vector를 넘겼을 때 “std::integral을 만족하지 않습니다”처럼 어떤 제약이 깨졌는지 바로 확인 가능 - 오버로드 분리: 정수용·실수용 구현을 같은 이름으로 나누고, Concept으로 어떤 버전이 선택될지 제어
아래 square는 std::integral로 “정수 타입만” 받으므로 int, unsigned, long, char 등에서 동작하고, half는 std::floating_point로 float, double, long double만 받습니다. 같은 이름 square·half로 타입별로 다른 템플릿이 선택되며, add(3.14, 2.0)처럼 실수를 넘기면 integral 제약을 만족하지 않아 컴파일 시점에 에러가 납니다. 기존에는 enable_if로 길게 적어야 했던 것을 한 줄로 표현할 수 있습니다.
#include <concepts>
// 정수 타입만 허용 (int, unsigned, long, char 등)
template <std::integral T>
T square(T x) {
return x * x;
}
// 실수 타입만 허용 (float, double, long double)
template <std::floating_point T>
T half(T x) {
return x / 2;
}
왜 이렇게 나누나요?
square는 정수·실수 모두 받을 수 있지만, 제약을 걸면 “정수 전용”이라는 의도가 명확해집니다. 나중에 실수용square를 별도로 정의해 오버로드할 때도 구분이 쉽습니다.half는 실수만 받는 이유: 정수half(3)은3/2가 되는데, 정수 나눗셈은 1이 됩니다. 실수용half(3.0)은 1.5가 됩니다. 의도에 따라 다르게 쓰이므로 분리하는 것이 좋습니다.
문법 형태 (두 가지 쓰는 법)
Concept을 적용하는 방법은 크게 두 가지입니다. 짧은 형태가 읽기 쉬우므로, 단일 개념만 쓸 때는 template <Concept T>를 추천합니다.
template <typename T>
requires std::integral<T> // requires 절 (여러 조건 조합할 때 유리)
T add(T a, T b) { return a + b; }
// 위와 동일 (짧은 형태) — 한 개념만 쓸 때 추천
template <std::integral T>
T add(T a, T b) { return a + b; }
주의점:
- 짧은 형태는
template <Concept T>처럼 한 개념만 쓸 때만 가능합니다.requires A<T> && B<T>처럼 여러 조건을 조합할 때는 requires 절을 써야 합니다. - requires 절은
template <typename T>다음 줄에requires 조건형태로 붙입니다.
3. requires 절
requires는 두 가지 용도로 쓰입니다.
- requires 절: 이미 있는 Concept이나 조건식을 조합해서 “이 템플릿은 이 조건을 만족할 때만 사용한다”고 적을 때
- requires 표현식: “이 타입이 이런 연산·멤버를 가져야 한다”를 정의해 새 Concept을 만들 때
아래는 둘을 구분해서 설명합니다.
복합 요구 사항 (여러 조건 조합)
표준 개념이나 sizeof, std::is_* 같은 조건을 &&, ||로 묶어서 쓸 수 있습니다. “정수이면서 4바이트 이상인 타입만 받고 싶다” 같은 요구에 맞습니다.
#include <concepts>
#include <type_traits>
template <typename T>
requires std::integral<T> && (sizeof(T) >= 4)
T add(T a, T b) {
return a + b;
}
// int, long, long long 등은 OK. char, short는 sizeof가 4 미만이면 제외 가능
언제 쓰나요?
- 한 가지 Concept만으로 부족하고, 크기·트레이트 등을 추가로 제한하고 싶을 때
requires Concept1<T> && Concept2<T>처럼 여러 Concept을 동시에 만족시키고 싶을 때
주의: sizeof(T) >= 4는 플랫폼에 따라 다를 수 있습니다. int는 보통 4바이트이지만, 일부 임베디드에서는 2바이트일 수 있습니다. “최소 4바이트 이상”을 명시하려면 std::is_same_v<T, int> || std::is_same_v<T, long> || ...처럼 구체적으로 나열하거나, sizeof(T) >= 4를 사용하고 플랫폼별 문서를 남기는 것이 좋습니다.
requires 표현식 (동작 요구 사항)
“T 타입에 이 연산이 있다”를 검사하려면 requires 표현식으로 Concept을 정의합니다. requires(파라미터...) { 표현식; } 형태이며, 그 표현식이 문법적으로 유효한지만 검사합니다(실제로 호출하지는 않음).
template <typename T>
concept Addable = requires(T a, T b) {
a + b; // a + b가 유효한 표현식이어야 함
};
template <Addable T>
T add(T a, T b) {
return a + b;
}
의미: requires(T a, T b) { a + b; }는 “T 타입의 a, b에 대해 a + b가 문법적으로 유효하고 컴파일 가능해야 한다”는 뜻입니다.
int,double→+가 있으므로 Addable 만족std::vector<int>→+가 없으므로 불만족 →add(vector{}, vector{})호출 시 “Addable 제약 불만족”으로 에러가 나며, 메시지가 인스턴스화 스택보다 훨씬 짧고 명확합니다.
표준 개념이 없을 때: “더하기 가능한 타입”, “크기 메서드가 있는 타입”처럼 도메인 조건을 걸고 싶을 때 이렇게 Concept을 정의해 두고 재사용하면 됩니다. (커스텀 Concept 작성은 다음 글 #22-2에서 다룹니다.)
타입 요구 사항 (멤버 타입이 있는지)
“T 안에 이런 타입이 존재하는지”만 검사할 때는 typename T::이름을 requires 안에 씁니다. STL 스타일 컨테이너처럼 value_type, iterator를 요구할 때 유용합니다.
#include <iostream>
#include <typeinfo>
template <typename T>
concept HasValueType = requires {
typename T::value_type; // T::value_type이 존재해야 함
};
// 사용 예: vector, list, set 등은 모두 value_type을 가짐
template <HasValueType C>
void print_element_type() {
std::cout << typeid(typename C::value_type).name() << '\n';
}
왜 typename이 필요할까요?
T::value_type은 템플릿 의존 타입이므로, 컴파일러가 “이게 타입인지 값인지” 알 수 없습니다.typename을 붙여 “이 타입이다”라고 명시해야 합니다.
4. 표준 개념 완전 예제
표준 라이브러리 <concepts> 헤더에는 타입 분류, 동작 가능 여부를 나타내는 개념들이 정의되어 있습니다. “정수만 받고 싶다”, “복사 가능한 타입만 받고 싶다” 같은 요구에 그대로 쓸 수 있어서, 가능하면 직접 조건을 짜기보다 표준 개념을 먼저 쓰는 것이 좋습니다.
헤더 — 자주 쓰는 개념
| 개념 | 의미 | 언제 쓰나요? |
|---|---|---|
std::integral<T> | 정수 타입 (int, unsigned, char, long 등) | 수치 연산을 정수에만 허용할 때 |
std::floating_point<T> | 부동소수점 (float, double, long double) | 실수 연산 전용 함수/템플릿 |
std::copyable<T> | 복사 생성·대입 가능 | vector<T> 등에 넣을 타입을 제한할 때 |
std::movable<T> | 이동 생성·대입 가능 | 복사는 안 되지만 이동만 허용하고 싶을 때 |
std::default_constructible<T> | 기본 생성 가능 | T t; 같은 기본 생성이 필요할 때 |
std::same_as<T, U> | T와 U가 같은 타입 | 두 템플릿 인자가 동일 타입이어야 할 때 |
std::convertible_to<T, U> | T가 U로 변환 가능 | 인자를 다른 타입으로 바꿔 쓸 때 |
std::arithmetic<T> | 정수 또는 부동소수점 타입 | 정수·실수 모두 받는 산술 연산용 |
참고: std::copyable<T>는 std::movable<T>를 포함합니다(복사 가능하면 이동도 가능). “복사만 막고 이동은 허용”하고 싶다면 std::movable<T> && !std::copyable<T>처럼 조합할 수 있습니다.
표준 개념 + requires 절 완전 예제
아래 예제는 표준 개념과 requires 절을 함께 사용하는 실행 가능한 코드입니다. 복사해 g++ -std=c++20 -o concepts_demo concepts_demo.cpp로 빌드해 볼 수 있습니다.
// concepts_demo.cpp — 표준 개념 + requires 절 통합 예제
#include <concepts>
#include <ranges>
#include <vector>
#include <list>
#include <iostream>
// 1) std::integral — 정수만
template <std::integral T>
T mod(T a, T b) {
return a % b;
}
// 2) std::floating_point — 실수만
template <std::floating_point T>
T reciprocal(T x) {
return T{1} / x;
}
// 3) requires 절 — 여러 조건 조합
template <typename T>
requires std::integral<T> && (sizeof(T) >= 4)
T safe_multiply(T a, T b) {
return a * b;
}
// 4) std::copyable — 복사 가능한 타입만
template <std::copyable T>
void push_twice(std::vector<T>& v, const T& x) {
v.push_back(x);
v.push_back(x);
}
// 5) std::ranges::range — 순회 가능한 컨테이너
template <std::ranges::range R>
void print_range(const R& r) {
for (const auto& x : r)
std::cout << x << " ";
std::cout << "\n";
}
// 6) requires 절 — range + 요소 타입 제약
template <typename R>
requires std::ranges::range<R> && std::arithmetic<std::ranges::range_value_t<R>>
double sum(const R& r) {
double acc = 0;
for (const auto& x : r) acc += static_cast<double>(x);
return acc;
}
int main() {
std::cout << mod(7, 3) << "\n"; // 1 (int)
std::cout << reciprocal(2.0) << "\n"; // 0.5
std::cout << safe_multiply(5, 6) << "\n"; // 30
std::vector<int> v;
push_twice(v, 42);
print_range(v); // 42 42
std::cout << sum(std::list<int>{1,2,3}) << "\n"; // 6
}
실행 결과:
1
0.5
30
42 42
6
핵심 포인트:
mod:std::integral로%연산이 가능한 정수만 허용reciprocal:std::floating_point로 실수 나눗셈만 허용safe_multiply:requires절로 “정수이면서 4바이트 이상” 제한push_twice:std::copyable로vector::push_back에 안전한 타입만print_range:std::ranges::range로vector,list, C 배열 등 순회 가능한 것만sum:requires로 range이면서 요소가std::arithmetic인 경우만
표준 개념 계층 구조
표준 개념들은 계층적으로 정의되어 있습니다. 상위 개념을 만족하면 하위 개념도 만족하는 경우가 많습니다.
flowchart TD
subgraph type["타입 분류"]
A["std integral"] --> C["std arithmetic"]
B["std floating_point"] --> C
C --> D["std totally_ordered"]
end
subgraph object["객체 속성"]
E["std movable"] --> F["std copyable"]
G["std default_constructible"]
end
- std::arithmetic:
std::integral또는std::floating_point— 정수·실수 모두 - std::copyable:
std::movable을 포함 — 복사 가능하면 이동도 가능 - std::totally_ordered:
<,<=,>,>=,==,!=가 모두 정의된 타입 (비교 가능)
ranges 관련 개념 계층
<ranges> 헤더의 개념들은 반복자 종류에 따라 계층이 나뉩니다.
flowchart TD R["std ranges range"] --> IR["std ranges input_range"] IR --> FR["std ranges forward_range"] FR --> BR["std ranges bidirectional_range"] BR --> RAR["std ranges random_access_range"] RAR --> CR["std ranges contiguous_range"]
| 개념 | 의미 | 예시 |
|---|---|---|
input_range | 한 번씩 앞으로만 읽기 | std::istream_view |
forward_range | 여러 번 순회 가능 | std::forward_list |
bidirectional_range | 앞뒤로 이동 가능 | std::list |
random_access_range | 인덱스 접근 가능 | std::vector, std::deque |
contiguous_range | 연속 메모리 | std::vector, C 배열 |
언제 어떤 것을 쓸까?
- “순회만 하면 된다” →
std::ranges::range또는input_range - “인덱스로 접근해야 한다” →
random_access_range - “연속 메모리여야 한다” (예: 바이너리 직렬화) →
contiguous_range
예제 — 복사 가능 타입만 저장, 변환 가능한 타입만 받기
#include <concepts>
#include <vector>
template <std::copyable T>
void store(T value) {
std::vector<T> v;
v.push_back(value); // 복사 가능한 타입만 허용
}
template <typename T, typename U>
requires std::convertible_to<T, U>
U convert(T t) {
return static_cast<U>(t);
}
// convert(3.14, int{}) → int로 변환 가능하므로 사용 가능
주의: convert 함수의 두 번째 인자 U는 예시에서 생략했습니다. 실제로는 convert<T, U>(t)처럼 호출 시 U를 명시하거나, T에서 U로 변환하는 단일 인자 함수를 설계해야 합니다. 위 예는 “T가 U로 변환 가능할 때만 이 함수를 쓸 수 있다”는 제약을 보여주는 용도입니다.
5. 함수 템플릿에 적용
오버로드와 조합 — “정수용 / 실수용” 나누기
같은 함수 이름으로 타입별로 다른 구현을 쓰고 싶을 때, Concept으로 오버로드를 나누면 됩니다. 컴파일러는 제약을 더 잘 만족하는 오버로드를 선택하므로, add(1, 2)는 정수용, add(1.0, 2.0)은 실수용이 자동으로 선택됩니다.
template <std::integral T>
T add(T a, T b) {
return a + b;
}
template <std::floating_point T>
T add(T a, T b) {
return a + b;
}
// add(3, 5) → integral 버전, add(3.0, 5.0) → floating_point 버전
주의: int는 std::integral을 만족하고, double은 std::floating_point를 만족합니다. 두 Concept이 겹치지 않으므로 ambiguous(모호) 없이 각각 하나의 오버로드만 선택됩니다.
정수와 실수를 섞어서 호출하고 싶다면(예: add(3, 5.0)) 반환 타입·변환 규칙을 정한 뒤, 공통 타입을 받는 오버로드를 추가하거나, 호출하는 쪽에서 명시적으로 캐스팅하는 방식으로 처리할 수 있습니다.
auto와 함께 — 반환 타입 추론
제약이 걸린 템플릿에서도 auto 반환을 쓸 수 있습니다. square처럼 연산 결과 타입이 T와 같을 때 그대로 auto로 두면 됩니다.
template <std::integral T>
auto square(T x) {
return x * x; // 반환 타입도 T (int → int, long → long)
}
6. 실전 활용
실전 예시 1: 수치 알고리즘 라이브러리
수치 연산 라이브러리에서 정수·실수 모두 받는 산술 함수를 만들 때 std::arithmetic을 쓰면 됩니다.
#include <concepts>
#include <cmath>
// 산술 타입(정수·실수) 모두 받는 절대값
template <std::arithmetic T>
T safe_abs(T x) {
if constexpr (std::integral<T>) {
return x < 0 ? -x : x; // 정수용
} else {
return std::abs(x); // 실수용 (std::abs 오버로드)
}
}
// 사용 예
// safe_abs(-3) → 3
// safe_abs(-3.14) → 3.14
왜 std::arithmetic인가요?
std::integral만 쓰면double을 받을 수 없고,std::floating_point만 쓰면int를 받을 수 없습니다.std::arithmetic은 둘을 모두 포함하므로, “정수·실수 모두” 받는 산술 함수에 적합합니다.
실전 예시 2: 컨테이너·범위 제약 — std::ranges::range
“이 함수는 순회 가능한 것만 받는다”고 제한하고 싶을 때 std::ranges::range를 쓰면 됩니다. std::vector, std::list, C 스타일 배열, 그리고 begin/end가 있는 커스텀 컨테이너까지 모두 range로 취급됩니다.
#include <ranges>
#include <iostream>
#include <vector>
#include <list>
template <typename R>
requires std::ranges::range<R>
void print(const R& r) {
for (const auto& x : r) {
std::cout << x << " ";
}
std::cout << "\n";
}
int main() {
print(std::vector<int>{1, 2, 3}); // OK
print(std::list<double>{1.0, 2.0}); // OK
int arr[] = {10, 20, 30};
print(arr); // C 배열도 OK — range로 취급됨
}
차이점 정리:
std::ranges::range<R>: “R은begin/end로 순회 가능하다”std::ranges::input_range<R>,std::ranges::random_access_range<R>등으로 반복자 종류까지 좁힐 수 있음 (필요할 때 사용)
실전 예시 3: 반복자 제약 — 구간 [first, last) 받기
“시작 반복자 + 끝 반복자” 구간을 받는 전형적인 STL 스타일 함수에서는 반복자 개념으로 제약을 걸 수 있습니다. std::input_iterator면 “한 번씩 앞으로만 읽기 가능”, std::random_access_iterator면 “인덱스 접근·비교”까지 가능하다는 뜻입니다.
#include <iterator>
template <std::input_iterator It>
void process(It first, It last) {
for (; first != last; ++first) {
// *first로 요소 접근
}
}
언제 어떤 개념을 쓸까?
- range를 통째로 받을 때 →
std::ranges::range<R> - 반복자 쌍을 받을 때 →
std::input_iterator<It>,std::forward_iterator<It>등 - “정수만”, “복사 가능한 타입만” 등 타입 분류가 중요할 때 →
std::integral,std::copyable
실전 예시 4: 제네릭 알고리즘에서 타입 안전성
제네릭 알고리즘에서 “비교 가능한 타입”만 받고 싶을 때 std::totally_ordered를 쓸 수 있습니다.
#include <concepts>
#include <algorithm>
template <std::totally_ordered T>
const T& max_of(const T& a, const T& b) {
return a < b ? b : a;
}
// std::totally_ordered: <, <=, >, >=, ==, != 모두 정의된 타입
실전 예시 5: 데이터 처리 파이프라인
여러 range를 연결해 데이터를 처리하는 파이프라인에서 Concepts로 입력 타입을 제한할 수 있습니다.
#include <ranges>
#include <vector>
#include <numeric>
// 입력 range의 요소 타입이 산술 타입일 때만 통계 계산
template <std::ranges::range R>
requires std::arithmetic<std::ranges::range_value_t<R>>
double mean(const R& r) {
auto n = std::ranges::distance(r);
if (n == 0) return 0.0;
using Val = std::ranges::range_value_t<R>;
auto sum = std::accumulate(std::ranges::begin(r), std::ranges::end(r), Val{});
return static_cast<double>(sum) / n;
}
// 사용: mean(std::vector<int>{1,2,3,4,5}) → 3.0
설명: std::ranges::range_value_t<R>는 range R의 요소 타입입니다. std::arithmetic으로 “정수 또는 실수”만 허용해, 문자열 컨테이너 등이 넘어오면 컴파일 시점에 에러가 납니다.
실전 예시 6: 클래스 템플릿에 Concept 적용
함수뿐 아니라 클래스 템플릿에도 Concept을 적용할 수 있습니다.
#include <concepts>
#include <vector>
// 복사 가능한 타입만 저장하는 간단한 컨테이너 래퍼
template <std::copyable T>
class TypedBuffer {
std::vector<T> data_;
public:
void push(const T& value) {
data_.push_back(value); // 복사 — copyable이므로 안전
}
const T& at(size_t i) const { return data_.at(i); }
};
// TypedBuffer<std::unique_ptr<int>> // ❌ 에러: unique_ptr는 copyable이 아님
// TypedBuffer<int> // ✅ OK
주의: std::unique_ptr는 이동만 가능하고 복사는 불가능합니다. std::copyable을 쓰면 unique_ptr를 저장하는 실수를 컴파일 시점에 막을 수 있습니다.
7. 자주 발생하는 문제와 해결법
문제 1: “ambiguous” — 여러 오버로드가 동시에 만족할 때
증상: add(3, 5.0)처럼 정수와 실수를 섞어 호출하면 “call to ‘add’ is ambiguous” 에러가 납니다.
원인: int는 std::integral을 만족하고, double은 std::floating_point를 만족합니다. 두 오버로드가 각각 다른 인자 타입에 대해 “더 나은” 선택이 되지 않아, 컴파일러가 어느 쪽을 선택할지 모릅니다.
해결법:
// 방법 1: 호출 시 명시적 캐스팅
add(3, static_cast<int>(5.0)); // 또는 add(static_cast<double>(3), 5.0);
// 방법 2: 공통 타입을 받는 오버로드 추가
template <typename T, typename U>
requires std::arithmetic<T> && std::arithmetic<U>
auto add(T a, U b) {
return a + b; // 반환 타입은 std::common_type_t<T, U> 등으로 결정
}
문제 2: “constraints not satisfied” — 제약이 너무 엄격할 때
증상: std::vector<int>를 넘겼는데 “does not satisfy std::integral” 에러가 납니다.
원인: std::integral은 정수 타입만 받습니다. std::vector<int>는 컨테이너이므로 std::integral을 만족하지 않습니다.
해결법:
// 컨테이너를 받고 싶다면 std::ranges::range 사용
template <std::ranges::range R>
void process_range(const R& r) {
for (const auto& x : r) { /* ... */ }
}
문제 3: requires 절 문법 오류
증상: requires std::integral<T> && std::copyable<T>를 썼는데 “expected ’>’ before ’&&’” 같은 에러가 납니다.
원인: template <typename T> 다음에 requires를 붙일 때, 괄호로 감싸지 않으면 &&가 파싱에 문제를 일으킬 수 있습니다.
해결법:
// 괄호로 묶어서 명확히
template <typename T>
requires (std::integral<T> && std::copyable<T>)
void foo(T t) {}
문제 4: 커스텀 Concept에서 “invalid operands” 에러
증상: requires(T a, T b) { a + b; }를 정의했는데, a + b가 “invalid operands”가 됩니다.
원인: a + b의 반환 타입을 검사하려면 { a + b } -> std::same_as<T>처럼 반환 타입을 명시해야 합니다. 단순히 a + b;만 쓰면 “유효한 표현식인지”만 검사할 때는 대부분 문제없지만, 일부 컴파일러는 엄격하게 검사할 수 있습니다.
해결법:
// 반환 타입까지 명시
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::same_as<T>;
};
문제 5: std::ranges::range_value_t를 쓸 수 없다는 에러
증상: std::ranges::range_value_t<R>를 사용했는데 “incomplete type” 또는 “invalid use of” 에러가 납니다.
원인: R이 실제로 std::ranges::range를 만족하지 않을 수 있습니다. 또는 <ranges> 헤더를 포함하지 않았을 수 있습니다.
해결법:
#include <ranges> // 필수
template <std::ranges::range R>
void foo(const R& r) {
using value_type = std::ranges::range_value_t<R>; // R이 range일 때만 유효
// ...
}
문제 6: Concept과 비Concept 오버로드가 충돌할 때
증상: template <typename T> void f(T)와 template <std::integral T> void f(T)를 둘 다 정의했는데, f(3) 호출 시 “ambiguous” 에러가 납니다.
원인: int는 std::integral을 만족하므로 두 오버로드 모두 후보가 됩니다. 제약이 있는 오버로드가 더 제한적이므로 보통 우선 선택되지만, 컴파일러 버전에 따라 다를 수 있습니다.
해결법:
// 비제약 오버로드를 제거하거나, 제약이 있는 쪽만 두기
template <std::integral T>
void f(T t) { /* 정수 전용 */ }
// "그 외" 타입을 받고 싶다면 명시적으로 나누기
template <typename T>
requires (!std::integral<T>)
void f(T t) { /* 비정수용 */ }
문제 7: std::list에 std::ranges::sort 적용 시 에러
증상: std::ranges::sort(my_list)를 호출했는데 “does not satisfy random_access_range” 에러가 납니다.
원인: std::ranges::sort는 std::ranges::random_access_range를 요구합니다. std::list는 bidirectional_range이므로 인덱스 접근이 불가능해 sort에 사용할 수 없습니다.
해결법:
#include <ranges>
#include <list>
#include <vector>
std::list<int> lst = {3, 1, 2};
// std::ranges::sort(lst); // ❌ 에러
// 방법 1: vector로 복사 후 정렬
std::vector<int> vec(lst.begin(), lst.end());
std::ranges::sort(vec);
// 방법 2: list::sort 멤버 함수 사용 (list 전용)
lst.sort();
문제 8: requires 표현식에서 반환 타입 검사 실패
증상: { a + b } -> std::same_as<T>를 썼는데, int와 double을 더하면 double이 반환되어 “does not satisfy same_as
원인: T가 int일 때 int + double은 double을 반환하므로 std::same_as<int>를 만족하지 않습니다. 반환 타입 제약이 너무 엄격한 경우입니다.
해결법:
// 반환 타입을 T로 고정하지 말고, 변환 가능한 타입으로 완화
template <typename T>
concept Addable = requires(T a, T b) {
{ a + b } -> std::convertible_to<T>; // T로 변환 가능하면 OK
};
// 또는 반환 타입 검사를 생략하고 표현식만 검사
template <typename T>
concept Addable = requires(T a, T b) {
a + b; // 유효한 표현식이면 OK
};
문제 9: std::movable만 요구했는데 복사 연산을 사용
증상: template <std::movable T>로 정의한 함수 안에서 T t2 = t1;(복사)를 사용했는데 에러가 납니다.
원인: std::movable은 이동만 보장합니다. std::unique_ptr는 movable이지만 copyable이 아님으로, 복사 생성은 불가능합니다.
해결법:
// 복사가 필요하면 std::copyable 사용
template <std::copyable T>
void duplicate(T t) {
T t2 = t; // OK: copyable
}
// 이동만 필요하면 std::movable 사용
template <std::movable T>
void take_ownership(T&& t) {
T moved = std::move(t); // OK: 이동만 사용
}
8. 모범 사례
1) 표준 개념 우선 사용
가능하면 표준 라이브러리 개념을 먼저 사용하세요. std::integral, std::copyable, std::ranges::range 등은 컴파일러가 최적화된 경로로 검사하며, 코드베이스 전반에서 일관된 의미를 가집니다.
// ✅ 좋음: 표준 개념 사용
template <std::copyable T>
void store(T value);
// ❌ 피할 것: 불필요한 커스텀 개념 (표준에 이미 있음)
template <typename T>
concept MyCopyable = std::copyable<T>;
template <MyCopyable T>
void store(T value); // std::copyable을 그대로 쓰는 게 낫다
2) 짧은 형태 vs requires 절 — 상황에 맞게 선택
한 개념만 쓸 때는 template <Concept T> 짧은 형태가 읽기 쉽습니다. 여러 조건을 조합할 때는 requires 절을 사용하세요.
// ✅ 한 개념: 짧은 형태
template <std::integral T>
T add(T a, T b) { return a + b; }
// ✅ 여러 조건: requires 절
template <typename T>
requires std::ranges::range<T> && std::arithmetic<std::ranges::range_value_t<T>>
double mean(const T& r) { /* ... */ }
3) 제약은 “필요 최소한”으로
과도하게 엄격한 제약은 사용성을 해칩니다. “이 연산만 필요하다”면 그에 맞는 가장 넓은 개념을 선택하세요.
// ✅ 좋음: input_range면 충분한 경우
template <std::ranges::input_range R>
void process(R&& r);
// ❌ 과도: random_access_range를 요구했는데, list만 넘기면 실패
template <std::ranges::random_access_range R>
void process(R&& r); // list, forward_list는 사용 불가
4) 오버로드 시 Concept 겹침 주의
std::integral과 std::floating_point는 겹치지 않아 안전합니다. 커스텀 개념을 여러 개 쓸 때는 상호 배타적인지 확인하세요. 겹치면 add(3, 5.0)처럼 ambiguous 에러가 납니다.
5) 문서화 대신 Concept으로 의도 표현
주석으로 “T는 정수여야 합니다”를 적는 대신, template <std::integral T>로 코드 자체가 문서가 되게 하세요. 컴파일러가 검사하므로 주석과 구현이 어긋날 위험도 없습니다.
9. 프로덕션 패턴
패턴 1: 수치 라이브러리 API — sqrt/log vs gcd/lcm
수학 함수 라이브러리에서 sqrt, log는 실수만, gcd, lcm은 정수만 받는 전형적인 패턴입니다.
#include <concepts>
#include <cmath>
#include <numeric>
// 실수 전용: sqrt, log
template <std::floating_point T>
T safe_sqrt(T x) {
if (x < 0) throw std::domain_error("sqrt of negative");
return std::sqrt(x);
}
// 정수 전용: gcd, lcm (C++17 std::gcd/lcm 활용)
template <std::integral T>
T safe_gcd(T a, T b) {
return std::gcd(a, b);
}
패턴 2: 직렬화 모듈 — 커스텀 Concept
JSON, 바이너리 직렬화에서 “직렬화 가능한 타입”만 받을 때는 표준에 없으므로 커스텀 Concept을 정의합니다. (#22-2 커스텀 Concepts 참고)
template <typename T>
concept Serializable = requires(const T& t, std::ostream& os) {
{ t.serialize(os) } -> std::same_as<void>;
};
template <Serializable T>
void save(const T& obj, std::ostream& out) {
obj.serialize(out);
}
패턴 3: 알고리즘 라이브러리 — 비교·정렬
정렬, 검색, max/min 등은 “비교 가능한 타입”이 필요합니다. std::totally_ordered로 제한하면 비교 불가 타입 전달 시 즉시 에러가 납니다.
#include <concepts>
#include <algorithm>
template <std::totally_ordered T>
void sort_range(T* first, T* last) {
std::sort(first, last);
}
패턴 4: C++17/20 분기 — 점진적 마이그레이션
레거시 프로젝트에서 C++20으로 올리기 전, #if로 Concepts 버전과 enable_if 버전을 나눌 수 있습니다.
#if __cplusplus >= 202002L
template <std::integral T>
T add(T a, T b) { return a + b; }
#else
template <typename T>
typename std::enable_if<std::is_integral_v<T>, T>::type
add(T a, T b) { return a + b; }
#endif
표준 개념 선택 가이드
| 요구 사항 | 추천 개념 | 비고 |
|---|---|---|
| 정수만 받고 싶다 | std::integral | int, long, char, unsigned 등 |
| 실수만 받고 싶다 | std::floating_point | float, double, long double |
| 정수·실수 모두 | std::arithmetic | 산술 연산 공통 |
| 복사 가능한 타입만 | std::copyable | vector 등에 저장할 때 |
| 이동만 허용 (복사 불가) | std::movable | unique_ptr 등 |
| 기본 생성 가능 | std::default_constructible | T t; 필요할 때 |
| 순회 가능한 컨테이너 | std::ranges::range | for-range, begin/end |
| 반복자 쌍 [first, last) | std::input_iterator | STL 스타일 알고리즘 |
| 비교 가능 (정렬 등) | std::totally_ordered | <, == 등 |
| T를 U로 변환 가능 | std::convertible_to<T, U> | 캐스팅 전 검사 |
10. 성능과 컴파일 시간
Concepts는 컴파일 타임에 제약을 검사합니다. enable_if는 SFINAE로 여러 오버로드 후보를 시도하고, 실패한 오버로드를 제외하는 과정이 있습니다. Concepts는 제약 조건을 먼저 확인하고, 불만족한 오버로드는 아예 시도하지 않으므로, 대규모 템플릿 라이브러리에서는 컴파일 시간 단축에 도움이 될 수 있습니다.
실무 팁:
- 표준 개념을 우선 사용하면, 컴파일러가 이미 최적화된 검사로 처리할 수 있습니다.
- 커스텀 Concept을 과도하게 쪼개면, 오버로드 해석 시 검사할 후보가 늘어나 컴파일 시간이 늘어날 수 있습니다. 적당한 범위로 묶어서 사용하는 것이 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]
- C++ Concepts와 Constraints | “타입 제약” 가이드
- C++ 메타프로그래밍의 진화: Template에서 Constexpr, 그리고 Reflection까지
이 글에서 다루는 키워드 (관련 검색어)
C++20 Concepts, concept, 템플릿 제약, requires, 타입 제약 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| Concept | 타입이 만족해야 할 조건 (컴파일 타임에 검사) |
| 문법 | template <Concept T> 또는 requires Concept<T> (조합 시 requires A<T> && B<T>) |
| 표준 | std::integral, std::copyable, std::ranges::range, std::input_iterator 등 |
| 효과 | 에러 메시지 개선, 오버로드 분리, API 의도 명확화 |
| Concepts vs SFINAE | C++20 이상에서는 Concepts 사용을 우선하면 가독성·에러 메시지가 좋아짐 |
11. 자주 묻는 질문 (FAQ)
Q. Concept을 만족하지 않으면 언제 에러가 나나요?
A. 컴파일 타임에 납니다. 해당 템플릿이 선택되는 시점에 제약이 검사되므로, 잘못된 타입으로 호출하면 컴파일 에러가 나고, 메시지에 “어떤 Concept을 만족하지 않는지”가 나옵니다.
Q. 정수와 실수 둘 다 받는 함수는 어떻게 하나요?
A.
- 방법 1:
template <std::integral T>와template <std::floating_point T>오버로드를 둘 다 두면,add(1, 2)는 정수용,add(1.0, 2.0)은 실수용이 선택됩니다. - 방법 2: “산술 타입 전부”를 받고 싶다면
std::arithmetic<T>(C++20)를 쓰면 됩니다.std::arithmetic은 integral과 floating_point를 모두 포함합니다.
Q. 표준에 없는 “우리만의 조건”을 걸고 싶어요.
A. requires 표현식으로 concept 이름 = requires(...) { ... }; 형태의 커스텀 Concept을 정의하면 됩니다. 다음 글 #22-2 커스텀 Concepts에서 작성 방법을 다룹니다.
Q. template <typename T>만 쓰면 안 되나요? Concept을 꼭 써야 하나요?
A. 문법상으로는 Concept 없이도 동작합니다. 다만 잘못된 타입을 넘겼을 때 에러가 템플릿 내부 깊은 곳에서 나와 읽기 어렵고, “이 함수가 어떤 타입을 기대하는지”가 코드만 봐서는 불명확해집니다. 라이브러리·공개 API에서는 Concept을 두면 사용자와 유지보수 모두에 도움이 됩니다.
Q. std::ranges::range와 std::input_iterator의 차이는?
A. range는 “컨테이너 전체”를 받을 때 (예: std::vector<int>), iterator는 “반복자 쌍 [first, last)”를 받을 때 사용합니다. std::ranges::range는 내부적으로 begin/end를 가진 타입을 의미하고, std::input_iterator는 “한 번씩 앞으로만 읽을 수 있는 반복자”를 의미합니다.
Q. C++17 이하 프로젝트에서는 Concepts를 쓸 수 없나요?
A. Concepts는 C++20에서만 표준에 포함되었습니다. C++17 이하에서는 #if __cplusplus >= 202002L로 분기해 Concepts 버전과 enable_if 버전을 둘 다 제공하는 방법이 있습니다. 또는 점진적으로 C++20으로 업그레이드하는 것을 고려해 보세요.
Q. Concept을 만족하는지 런타임에 확인할 수 있나요?
A. Concept 검사는 컴파일 타임에만 이루어집니다. 런타임에 “이 타입이 Concept을 만족하는지” 확인하려면 if constexpr (std::integral<T>) 같은 방식으로 분기할 수 있습니다. 다만 이때 T는 컴파일 시점에 알려진 타입이어야 합니다.
Q. 표준 라이브러리에서 Concepts를 쓰나요?
A. C++20 표준 라이브러리의 Ranges 관련 함수들(std::ranges::sort, std::ranges::transform 등)은 Concepts로 제약이 걸려 있습니다. 예: std::ranges::sort는 std::ranges::random_access_range와 std::indirect_strict_weak_order 등을 요구합니다.
12. 구현 체크리스트
Concepts를 사용할 때 다음을 확인해 보세요.
- 표준 개념이 있으면 그걸 먼저 사용 (
std::integral,std::copyable등) - 한 개념만 쓸 때는
template <Concept T>짧은 형태 사용 - 여러 조건 조합 시
requires A<T> && B<T>형태로 명확히 작성 - 오버로드 시
integral과floating_point를 섞어 호출하면 ambiguous 발생 가능 — 캐스팅 또는 공통 타입 오버로드 추가 - 컨테이너를 받을 때는
std::ranges::range, 반복자 쌍을 받을 때는std::input_iterator등 사용 - 에러 메시지가 여전히 길 때 — 제약 불만족이 먼저 나오는지 확인하고, 필요하면 커스텀 Concept으로 조건을 더 구체화
관련 글
- C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]
- C++20 Coroutine | co_await·co_yield로
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ Concepts와 Constraints |