C++ SFINAE vs Concepts | 언제 마이그레이션해야 할까
이 글의 핵심
SFINAE 메커니즘 자체보다 SFINAE와 C++20 Concepts가 실무에서 어떻게 다르게 동작하는지, 왜 기존 SFINAE 코드를 전면 마이그레이션하기 어려운지, Concepts가 만능이 아닌 경우는 무엇인지를 다룹니다.
이 글은 SFINAE의 내부 동작 원리 자체를 처음부터 설명하지 않습니다. 치환 실패의 정확한 메커니즘과 enable_if를 반환 타입/템플릿 인자/함수 인자 중 어디에 두느냐에 따른 차이는 C++ SFINAE 가이드와 C++ Type Traits 가이드에서 이미 상세히 다뤘습니다. 여기서는 SFINAE와 C++20 Concepts가 실무에서 어떻게 다르게 동작하는지, 그리고 기존 SFINAE 기반 코드를 Concepts로 언제·어떻게 옮겨야 하는지에 집중합니다. 두 기법 모두 “템플릿 인자가 특정 조건을 만족할 때만 오버로드를 활성화한다”는 목표는 같지만, 그 목표를 달성하는 방식의 차이가 컴파일 에러 메시지의 가독성, 코드 자체의 문서화 가치, 그리고 마이그레이션 난이도에 직접적인 영향을 줍니다.
SFINAE란? (간단 복습)
Substitution Failure Is Not An Error
- 템플릿 인자 치환 실패는 에러가 아님
- 컴파일러가 다른 오버로드를 찾음
아래 예제는 뒤에서 Concepts 버전과 직접 비교하기 위한 기준점입니다. enable_if가 정수/실수 타입에 따라 서로 다른 오버로드를 활성화·비활성화하는 전형적인 패턴이며, 이 패턴 자체는 15년 넘게 C++ 라이브러리 코드 전반에 퍼져 있습니다. 이후 섹션에서 같은 문제를 Concepts로 풀면 무엇이 달라지는지 비교합니다.
// 정수 타입용
template<typename T>
typename enable_if<is_integral<T>::value, T>::type
process(T value) {
return value * 2;
}
// 실수 타입용
template<typename T>
typename enable_if<is_floating_point<T>::value, T>::type
process(T value) {
return value * 3.0;
}
int main() {
cout << process(10) << endl; // 20 (정수)
cout << process(3.14) << endl; // 9.42 (실수)
}
enable_if
enable_if를 반환 타입, 템플릿 인자, 함수 인자 중 어디에 배치하느냐에 따라 오버로드 해석 순서와 재사용성이 미묘하게 달라진다는 점은 enable_if 가이드에서 다뤘으므로 여기서는 반복하지 않습니다. 이 글에서 짚어야 할 핵심은, 아래 세 스타일 모두 제약 조건이 함수 선언부 밖(반환 타입이나 템플릿 파라미터 목록의 트릭)에 숨어 있다는 사실입니다. 함수를 호출하는 쪽에서 시그니처만 보고는 “이 함수가 정수 타입만 받는다”는 의도를 곧바로 읽어내기 어렵고, enable_if_t<...>라는 타입 표현식을 눈으로 해석해야 비로소 제약이 드러납니다. 이것이 뒤에서 다룰 Concepts와의 가독성 차이의 출발점입니다.
#include <type_traits>
using namespace std;
// C++11 스타일
template<typename T>
typename enable_if<is_integral<T>::value, void>::type
printType(T value) {
cout << value << " is integral" << endl;
}
// C++14 스타일 (간결)
template<typename T>
enable_if_t<is_integral<T>::value, void>
printType2(T value) {
cout << value << " is integral" << endl;
}
// C++17 스타일 (더 간결)
template<typename T, enable_if_t<is_integral_v<T>, int> = 0>
void printType3(T value) {
cout << value << " is integral" << endl;
}
type_traits
type_traits는 SFINAE 조건을 조립하는 재료입니다. 아래 목록은 이후 섹션에서 SFINAE 버전과 Concepts 버전을 대조할 때 계속 등장하므로 참고용으로 다시 정리해 둡니다. type_traits 각각의 세부 동작(예: decay가 배열을 포인터로 바꾸는 규칙, is_base_of의 다중 상속 처리)은 Type Traits 가이드를 참고하십시오.
// 타입 체크
is_integral<int>::value // true
is_floating_point<double>::value // true
is_pointer<int*>::value // true
is_const<const int>::value // true
// 타입 변환
remove_const<const int>::type // int
add_pointer<int>::type // int*
decay<int[10]>::type // int*
// 타입 관계
is_same<int, int>::value // true
is_base_of<Base, Derived>::value // true
is_convertible<int, double>::value // true
Concepts (C++20)
Numeric이라는 이름 자체가 이미 문서입니다. 호출자는 add 함수의 시그니처 template<Numeric T> T add(T a, T b)만 보고도 “숫자 타입만 받는다”는 의도를 바로 파악할 수 있습니다. 반면 앞서의 enable_if 버전은 시그니처만으로는 제약을 알 수 없고 함수 본문 위의 반환 타입 선언까지 읽어야 합니다. 이 차이는 사소해 보이지만, 수백 개의 오버로드가 얽힌 실제 라이브러리 헤더를 유지보수할 때는 결정적입니다 — 제약 조건이 이름으로 드러나면 코드 리뷰어도, 반년 뒤의 자신도 헤더 전체를 다시 읽지 않고 함수 목록만 훑어도 제약을 파악할 수 있습니다.
#include <concepts>
// 기본 concept
template<typename T>
concept Numeric = integral<T> || floating_point<T>;
template<Numeric T>
T add(T a, T b) {
return a + b;
}
// 커스텀 concept
template<typename T>
concept Printable = requires(T t) {
{ cout << t } -> convertible_to<ostream&>;
};
template<Printable T>
void print(T value) {
cout << value << endl;
}
int main() {
cout << add(1, 2) << endl; // OK
cout << add(1.5, 2.5) << endl; // OK
// cout << add("a", "b"); // 에러
}
requires 표현식
requires 절과 requires 표현식은 이름이 비슷해서 혼동하기 쉽지만 역할이 다릅니다. requires 절은 “이 concept 조합을 만족해야 한다”는 조건을 함수 선언에 붙이는 문법이고, requires 표현식은 “이런 식으로 쓸 수 있어야 한다”는 표현식 유효성 자체를 검사해 bool을 만들어내는 문법입니다. HasSize처럼 t.size()가 size_t로 변환 가능한지를 검사하는 패턴은, 사실 SFINAE 시절 decltype 기반 트릭(뒤의 “실전 예시” 섹션 참고)이 하던 일을 언어 차원에서 표준화한 것입니다. 차이는 SFINAE 버전이 별도의 헬퍼 구조체와 test(int)/test(...) 오버로드 쌍을 요구했다면, requires 표현식은 그 전체를 한 블록 안에 직접 쓸 수 있다는 점입니다 — 코드량이 줄어드는 것을 넘어, “무엇을 검사하는지”가 시각적으로 한눈에 들어옵니다.
// requires 절
template<typename T>
requires integral<T> || floating_point<T>
T multiply(T a, T b) {
return a * b;
}
// requires 표현식
template<typename T>
concept HasSize = requires(T t) {
{ t.size() } -> convertible_to<size_t>;
};
template<HasSize T>
void printSize(const T& container) {
cout << "크기: " << container.size() << endl;
}
실전 예시
아래 네 가지 예시는 실무에서 자주 마주치는 “이 타입이 이런 연산을 지원하는가”를 검사하는 패턴을 SFINAE와 Concepts 양쪽으로 구현해 나란히 비교한 것입니다. 코드 줄 수 차이보다 눈여겨봐야 할 것은, SFINAE 버전은 검사 로직(test(int)/test(...) 오버로드 쌍, decltype 컴마 연산자 트릭)과 실제 비즈니스 로직이 분리된 별도의 구조체로 존재하는 반면, Concepts 버전은 검사 로직 자체가 함수 시그니처 옆에 나란히 선언되어 “이 함수가 어떤 타입을 받는가”를 코드 상에서 바로 추적할 수 있다는 점입니다.
예시 1: 컨테이너 타입 체크
is_container의 SFINAE 구현은 test(int)와 test(...)라는 두 오버로드 중 declval<U>().begin()과 .end()가 유효한 표현식일 때만 첫 번째 오버로드가 치환에 성공하도록 만드는 전형적인 “expression SFINAE” 패턴입니다. 이 패턴은 표준 라이브러리 구현체 곳곳(<type_traits>, <iterator>) 에서 실제로 쓰이지만, 처음 보는 사람은 왜 test가 두 개이고 왜 인자가 int와 ...인지부터 이해해야 합니다. Concepts 버전의 requires(T t) { t.begin(); t.end(); }는 같은 검사를 “이 표현식들이 컴파일되면 된다”는 문장 그대로 표현합니다.
// SFINAE 버전
template<typename T>
struct is_container {
private:
template<typename U>
static auto test(int) -> decltype(
declval<U>().begin(),
declval<U>().end(),
true_type{}
);
template<typename>
static false_type test(...);
public:
static constexpr bool value = decltype(test<T>(0))::value;
};
template<typename T>
enable_if_t<is_container<T>::value, void>
print(const T& container) {
for (const auto& item : container) {
cout << item << " ";
}
cout << endl;
}
// Concepts 버전 (C++20)
template<typename T>
concept Container = requires(T t) {
t.begin();
t.end();
};
template<Container T>
void printContainer(const T& container) {
for (const auto& item : container) {
cout << item << " ";
}
cout << endl;
}
예시 2: 함수 호출 가능 체크
이 예시가 실무에서 특히 중요한 이유는, is_callable 같은 트레이트가 콜백 인터페이스를 받는 라이브러리(이벤트 핸들러, 스레드 풀, 콜백 기반 비동기 API)에서 반드시 필요하기 때문입니다. SFINAE 버전은 가변 인자 템플릿(typename... Args)까지 얽히면서 test의 시그니처가 한 줄로 읽히지 않을 정도로 복잡해집니다. Concepts 버전인 Callable<Func, Args...>는 표준 라이브러리의 std::invocable과 사실상 같은 역할을 하며, C++20 이후로는 직접 이런 concept을 만드는 대신 std::invocable<Func, Args...>를 그대로 쓰는 편이 낫습니다 — 표준 concept은 값 전달/참조 전달, cv-qualifier까지 고려된 완성도 높은 정의이기 때문입니다.
// SFINAE
template<typename Func, typename... Args>
struct is_callable {
private:
template<typename F, typename... A>
static auto test(int) -> decltype(
declval<F>()(declval<A>()...),
true_type{}
);
template<typename, typename...>
static false_type test(...);
public:
static constexpr bool value = decltype(test<Func, Args...>(0))::value;
};
// Concepts
template<typename Func, typename... Args>
concept Callable = requires(Func f, Args... args) {
f(args...);
};
template<Callable<int, int> Func>
int apply(Func f, int a, int b) {
return f(a, b);
}
int main() {
auto add = [](int a, int b) { return a + b; };
cout << apply(add, 1, 2) << endl; // 3
}
예시 3: 산술 연산 지원 체크
Arithmetic concept은 requires 표현식 안에서 { a + b } -> convertible_to<T> 형태의 복합 요구사항(compound requirement)을 사용합니다. 괄호 안 표현식이 컴파일되는지뿐 아니라, 그 결과 타입이 T로 변환 가능한지까지 함께 검사합니다. SFINAE로 같은 걸 하려면 decltype(declval<T>() + declval<T>())의 결과를 is_convertible에 다시 통과시키는 이중 검사를 손으로 짜야 하는데, 실무에서는 이 이중 검사를 누락해서 “덧셈은 되지만 결과가 엉뚱한 타입으로 변하는” 타입을 실수로 통과시키는 버그를 종종 봅니다. Concepts는 이 검사를 문법 차원에서 강제하므로 그런 누락이 구조적으로 줄어듭니다.
// Concepts
template<typename T>
concept Arithmetic = requires(T a, T b) {
{ a + b } -> convertible_to<T>;
{ a - b } -> convertible_to<T>;
{ a * b } -> convertible_to<T>;
{ a / b } -> convertible_to<T>;
};
template<Arithmetic T>
T average(const vector<T>& values) {
T sum = T{};
for (const T& v : values) {
sum = sum + v;
}
return sum / values.size();
}
int main() {
vector<int> ints = {1, 2, 3, 4, 5};
cout << average(ints) << endl; // 3
vector<double> doubles = {1.5, 2.5, 3.5};
cout << average(doubles) << endl; // 2.5
}
예시 4: 비교 가능 타입
clamp는 표준 라이브러리에도 std::clamp로 이미 존재하지만, 직접 제약 조건을 다는 이유를 보여주는 좋은 예시입니다. Comparable concept 없이 템플릿만 썼다면 clamp는 <, >, == 연산자가 없는 타입에도 인스턴스화를 시도하다가, 함수 본문 안의 value < min 지점에서야 실패합니다. 이때 에러는 clamp 내부의 한 줄을 가리키며, 정작 문제는 호출자가 넘긴 타입에 있는데도 에러 위치는 라이브러리 코드 안입니다. Comparable T로 제약을 걸면 실패 지점이 즉시 “이 타입은 Comparable을 만족하지 않는다”는 호출부로 옮겨 옵니다.
template<typename T>
concept Comparable = requires(T a, T b) {
{ a < b } -> convertible_to<bool>;
{ a > b } -> convertible_to<bool>;
{ a == b } -> convertible_to<bool>;
};
template<Comparable T>
T clamp(T value, T min, T max) {
if (value < min) return min;
if (value > max) return max;
return value;
}
int main() {
cout << clamp(5, 0, 10) << endl; // 5
cout << clamp(-5, 0, 10) << endl; // 0
cout << clamp(15, 0, 10) << endl; // 10
}
SFINAE vs Concepts
abs 예시는 두 접근법의 표면적인 차이(줄 수, 가독성)를 잘 보여주지만, 실무 마이그레이션에서 진짜 문제가 되는 건 이렇게 단순한 함수가 아니라 여러 개의 SFINAE 오버로드가 서로 겹치는 상황입니다. C++는 여러 오버로드가 동시에 유효할 때 “가장 특수화된” 것을 고르는 부분 순서화(partial ordering) 규칙을 갖고 있고, SFINAE 기반 라이브러리 코드는 종종 이 규칙에 암묵적으로 의존해서 오버로드 우선순위를 조작합니다.
저희 팀에서 사내 직렬화 라이브러리를 C++17 SFINAE에서 C++20 Concepts로 옮기던 중 겪은 일입니다. 원래 코드에는 is_container<T>를 만족하는 타입을 위한 오버로드와, has_serialize_method<T>를 만족하는 타입을 위한 오버로드가 enable_if로 각각 분기되어 있었는데, 두 조건을 동시에 만족하는 타입(컨테이너이면서 serialize() 멤버도 있는 래퍼 클래스)에 대해서는 템플릿 인자 목록에 더 특수화된 조건을 가진 오버로드 쪽으로 부분 순서화가 자연스럽게 정리되어 있었습니다. 이걸 각각 Container concept과 HasSerialize concept로 그대로 옮기고 나니, 두 concept을 동시에 만족하는 타입에서 어느 오버로드가 선택되는지가 원래와 달라졌습니다. Concepts도 부분 순서화 규칙을 갖고 있지만 SFINAE의 enable_if 조합이 만들어내던 우선순위와 정확히 일치하지 않았던 것입니다. 결국 Container && !HasSerialize처럼 배타 조건을 명시적으로 추가해서 우선순위를 다시 못 박아야 했습니다. 이 경험 이후로는 오버로드가 두 개 이상 겹치는 SFINAE 코드를 옮길 때 “예전 오버로드 해석 순서를 그대로 재현해야 하는가”부터 먼저 확인하고, 겹치는 concept들 사이의 우선순위를 명시적인 논리 조합으로 다시 적어두는 걸 원칙으로 삼고 있습니다.
// SFINAE (복잡)
template<typename T>
typename enable_if<
is_integral<T>::value && is_signed<T>::value,
T
>::type
abs(T value) {
return value < 0 ? -value : value;
}
// Concepts (간결)
template<typename T>
concept SignedIntegral = integral<T> && signed_integral<T>;
template<SignedIntegral T>
T abs(T value) {
return value < 0 ? -value : value;
}
전면 마이그레이션이 어려운 실무적 이유
“Concepts가 더 낫다면 기존 SFINAE 코드를 전부 바꾸면 되지 않나”라는 질문을 자주 받는데, 실제로는 다음 세 가지 이유로 전면 마이그레이션이 쉽지 않습니다.
첫째, 컴파일러 지원 버전 제약입니다. 사내/오픈소스를 막론하고 여전히 C++14나 C++17을 최소 지원 버전으로 명시한 라이브러리가 많고, 이런 코드베이스에서는 애초에 concept/requires 키워드 자체를 쓸 수 없습니다. 매크로로 분기하는 방법도 있지만(#if __cpp_concepts >= 201907L), 이러면 SFINAE 버전과 Concepts 버전을 동시에 유지보수해야 하는 이중 부담이 생깁니다.
둘째, 위에서 겪은 것처럼 SFINAE로만 자연스럽게 표현되는 오버로드 우선순위 트릭이 실제 코드베이스 곳곳에 스며 있습니다. enable_if를 반환 타입에 두는지, 템플릿 파라미터에 두는지, 기본 인자에 두는지에 따라 부분 순서화 결과가 미묘하게 달라지는 걸 의도적으로 활용한 코드는, Concepts로 그대로 옮기는 것만으로는 동일한 동작을 보장하지 못합니다. 옮기려면 우선순위를 명시적인 논리 조합(&&, !)으로 다시 설계해야 하고, 이는 기계적 치환이 아니라 사실상 재설계에 가깝습니다.
셋째, 서드파티 라이브러리와의 경계입니다. Boost나 오래된 사내 유틸리티가 이미 enable_if 기반 트레이트를 인터페이스로 노출하고 있다면, 그 경계를 넘나드는 코드는 두 스타일을 섞어 쓸 수밖에 없습니다. 이 경우 공개 API 표면만 Concepts로 감싸고 내부 구현은 기존 트레이트를 재사용하는 절충이 현실적입니다.
복합 Concepts
concept은 다른 concept을 조합해서 새 concept을 만들 수 있습니다. 이 조합 가능성이야말로 Concepts가 SFINAE보다 나은 또 하나의 이유입니다. NumericContainer는 Container와 Number라는 이미 이름 붙은 두 개념을 &&로 합친 것뿐이지만, SFINAE로 같은 걸 하려면 두 트레이트의 ::value를 각각 꺼내 && 하는 새 트레이트 구조체를 또 만들어야 합니다. 이름이 있는 concept을 쌓아 올리는 방식은 복잡한 제약을 작은 단위로 쪼개 재사용하게 해주므로, 제약 조건이 늘어날수록 SFINAE 대비 유지보수 이점이 커집니다.
template<typename T>
concept Number = integral<T> || floating_point<T>;
template<typename T>
concept SignedNumber = Number<T> && signed_integral<T>;
template<typename T>
concept Container = requires(T t) {
typename T::value_type;
{ t.begin() } -> same_as<typename T::iterator>;
{ t.end() } -> same_as<typename T::iterator>;
{ t.size() } -> convertible_to<size_t>;
};
template<typename T>
concept NumericContainer = Container<T> && Number<typename T::value_type>;
자주 발생하는 문제
문제 1: SFINAE 실패
T::value_type처럼 멤버 타입에 직접 접근하는 코드는 T가 int처럼 멤버가 없는 타입일 때 컴파일 자체가 깨집니다. 문제는 이게 “치환 실패”가 아니라 “함수 본문 안에서의 하드 에러”라는 점입니다. SFINAE는 오직 함수 시그니처(반환 타입, 템플릿 인자, 함수 인자 목록)에서 발생하는 치환 실패만 봐줍니다. 함수 본문 안에서 발생하는 실패는 봐주지 않으므로, get처럼 본문에서 T::value_type을 쓰는 코드는 애초에 시그니처 레벨에서 제약을 걸어야 합니다. 이 구분(시그니처 vs 본문)을 놓쳐서 “SFINAE가 안 먹힌다”고 헤매는 경우를 실무에서 자주 봅니다.
// ❌ SFINAE 실패
template<typename T>
typename T::value_type get(T container) { // T가 int면?
return container[0];
}
// ✅ enable_if 사용
template<typename T>
enable_if_t<is_class<T>::value, typename T::value_type>
get(T container) {
return container[0];
}
문제 2: Concept 순환 의존
concept은 constexpr bool처럼 평가되는 값이 아니라 컴파일 타임에 재귀적으로 전개되는 정의이므로, A가 B를 참조하고 B가 다시 A를 참조하면 컴파일러는 어느 쪽도 먼저 평가를 끝낼 수 없어 에러를 냅니다. SFINAE 트레이트에서도 구조적으로 같은 문제가 발생할 수 있지만, concept 문법은 서로 다른 헤더에 나눠 정의된 concept들을 조합하는 일이 많아서 순환이 생기기 더 쉽습니다. 새 concept을 기존 concept 위에 쌓아 올릴 때는 의존 방향을 한쪽으로만 흐르게 설계하는 습관이 필요합니다.
// ❌ 순환 의존
template<typename T>
concept A = B<T>;
template<typename T>
concept B = A<T>;
// ✅ 명확한 정의
template<typename T>
concept A = integral<T>;
template<typename T>
concept B = A<T> && signed_integral<T>;
문제 3: 과도한 제약
sizeof(T) == 4처럼 구현 세부사항(4바이트 정수라는 크기 가정)을 concept 안에 박아 넣으면, 플랫폼에 따라 int가 4바이트가 아닐 수도 있는 환경(임베디드 타깃 등)에서 이 concept은 아무 타입도 만족시키지 못하는 죽은 코드가 됩니다. concept의 이름은 “의도”를 드러내야 하는데, 구현 세부사항이 이름 뒤에 숨어 있으면 나중에 그 concept을 재사용하려는 사람이 왜 특정 타입이 걸러지는지 알 수 없습니다. 제약은 항상 “이 함수가 실제로 필요로 하는 최소 조건”으로 좁혀야지, “지금 당장 테스트에 쓰는 타입”에 맞춰 좁히면 안 됩니다.
// ❌ 너무 제약적
template<typename T>
concept StrictNumber = integral<T> && sizeof(T) == 4;
// ✅ 적절한 제약
template<typename T>
concept Number = integral<T> || floating_point<T>;
에러 메시지 개선
SFINAE와 Concepts의 실질적 차이가 가장 극명하게 드러나는 지점이 바로 에러 메시지입니다. SFINAE는 “이 오버로드가 왜 안 맞는지”를 컴파일러에게 알려주지 않습니다 — 치환이 실패하면 그 오버로드는 그냥 후보 목록에서 조용히 빠질 뿐이고, 모든 오버로드가 실패하면 컴파일러는 “일치하는 오버로드가 없다”는 결과만 보고합니다. 반면 concept은 제약 조건이 선언에 명시적으로 남아 있으므로, 컴파일러가 “어떤 concept의 어떤 조건을 만족하지 못했는지”를 직접 짚어줄 수 있습니다.
과거 SFINAE 기반 직렬화 라이브러리를 디버깅하던 때가 기억에 남습니다. 어떤 타입을 넘겼더니 컴파일 에러가 300줄 넘게 쏟아졌는데, 그 대부분이 “후보 함수가 아님(candidate function not viable)“이라는 문구와 함께 관련 없어 보이는 오버로드 십여 개를 나열하는 내용이었습니다. 진짜 원인 — 해당 타입에 operator<<가 정의되어 있지 않다는 것 — 을 찾으려면 에러 로그를 위에서부터 하나씩 걷어내며 “이 오버로드는 왜 후보인지, 왜 탈락했는지”를 손으로 추적해야 했고, 결국 각 후보의 enable_if 조건을 하나씩 static_assert로 분리해서 이진 탐색하듯 원인을 좁혀야 했습니다. 같은 라이브러리를 나중에 C++20 Concepts로 다시 짜고 나서 똑같이 operator<<가 없는 타입을 넘겨봤더니, 에러 메시지는 단 몇 줄로 줄었고 “Printable concept의 { cout << t } -> convertible_to<ostream&> 요구사항을 만족하지 않습니다”라는 문장이 그대로 찍혔습니다. 원인을 찾는 데 걸린 시간이 수십 분에서 수초로 줄어든 경험이었고, 이후로는 새 프로젝트에서 제약이 필요한 템플릿 코드를 짤 때 C++20 이상이 가능하다면 망설이지 않고 concept부터 씁니다.
// SFINAE (에러 메시지 복잡)
template<typename T>
enable_if_t<is_integral<T>::value, void>
process(T value) {
// ...
}
// Concepts (에러 메시지 명확)
template<integral T>
void process(T value) {
// ...
}
// 커스텀 에러 메시지
template<typename T>
concept Positive = requires(T t) {
requires t > 0; // "requires t > 0 is not satisfied"
};
Concepts가 만능이 아닌 경우
Concepts가 대부분의 상황에서 SFINAE보다 낫다고 해서, SFINAE 스타일의 사고방식이 완전히 필요 없어지는 것은 아닙니다. 위 HasSize, Container, Callable concept들을 다시 보면, 정의 자체가 결국 requires 표현식 안에 “이 표현식이 컴파일되는가”를 검사하는 형태입니다. 즉 concept의 몸통 안에서는 여전히 SFINAE와 동일한 질문 — 타입이 무엇인지가 아니라 특정 표현식이 유효한지 — 을 던지고 있습니다. concept은 이 질문에 이름을 붙이고 언어 차원의 문법을 제공했을 뿐, “표현식 유효성 검사”라는 근본 개념 자체를 없애지는 않았습니다.
이 사실이 실무에서 중요해지는 경우가 있습니다. 타입 자체의 카테고리(정수인지, 부동소수점인지, 클래스인지)가 아니라 “이 타입에 이 연산이 이 순서로, 이 오버로드 해석 결과로 작동하는가”처럼 미묘한 표현식 유효성을 검사해야 할 때는, concept의 requires 표현식 안에 사실상 SFINAE 시절의 decltype 트릭을 그대로 옮겨 적어야 하는 경우가 생깁니다. 예를 들어 어떤 타입에 operator+가 정의되어 있는지가 아니라, 그 operator+가 특정 인자 조합에 대해서만 SFINAE로 비활성화되어 있는 서드파티 타입을 다뤄야 한다면, concept 쪽에서도 결국 그 표현식이 컴파일되는지를 그대로 검사하는 수밖에 없습니다. 또한 concept은 컴파일 타임 상수 표현식만 다루므로, 런타임 값에 따라 달라지는 제약(런타임 다형성과 결합된 타입 소거 패턴 등)은 애초에 concept의 영역이 아니라 별도의 런타임 디스패치로 풀어야 합니다. 즉 Concepts는 SFINAE가 하던 일을 더 읽기 좋게 포장한 것이지, SFINAE가 다루던 문제 영역 자체를 완전히 대체한 것은 아닙니다.
FAQ
Q1: SFINAE vs Concepts?
A: C++20 이상이면 Concepts를 사용하세요. 더 읽기 쉽고 에러 메시지가 명확합니다.
Q2: 언제 SFINAE를 사용하나요?
A:
- C++17 이하
- 레거시 코드베이스
- 라이브러리 호환성
Q3: Concepts는 성능에 영향을 주나요?
A: 아니요. 컴파일 타임에만 체크되므로 런타임 오버헤드가 없습니다.
Q4: Concepts를 어떻게 배우나요?
A:
- 표준 concepts 사용 (integral, floating_point 등)
- 간단한 커스텀 concept 작성
- 복합 concept 작성
Q5: SFINAE 디버깅은?
A:
- static_assert 사용
- Compiler Explorer 활용
- 에러 메시지 주의 깊게 읽기
Q6: Concepts 학습 리소스는?
A:
- cppreference.com
- “C++20: The Complete Guide”
- Compiler Explorer (godbolt.org)
같이 보면 좋은 글
- SFINAE 패턴 정리: enable_if, void_t, Detection Idiom과 Concepts로 옮길 시점
- C++ enable_if
- C++ <type_traits>로 타입 검사·변환하기
- C++ 메타프로그래밍의 진화: Template에서 Constexpr, 그리고 Reflection까지
- C++ 메타프로그래밍 고급 | SFINAE·Concepts·constexpr·타입 트레이트 가이드
- C++ Expression Templates