C++17 if constexpr: 컴파일 타임 분기로 템플릿 단순화하기와 static_assert(false) 문제
이 글의 핵심
if constexpr는 C++17에서 도입된 컴파일 타임 분기문으로, 선택되지 않은 분기는 아예 인스턴스화되지 않아 타입이 다른 템플릿 인자에 따라 서로 다른 코드를 검사 없이 건너뛸 수 있습니다. 이 글은 일반 if와 실제로 컴파일 결과가 어떻게 다른지, SFINAE보다 왜 더 읽기 쉬운지, 그리고 실무에서 자주 걸려 넘어지는 함정을 정리합니다.
if constexpr란?
if constexpr는 조건이 컴파일 타임에 이미 결정되어 있을 때, 선택되지 않은 분기의 코드를 아예 컴파일 대상에서 제외하는 C++17의 분기문입니다.
template<typename T>
auto getValue(T value) {
if constexpr (std::is_pointer_v<T>) {
return *value; // 포인터
} else {
return value; // 값
}
}
T가 포인터가 아닌 타입으로 인스턴스화되면 *value 쪽 분기는 컴파일러가 아예 존재하지 않는 것처럼 취급합니다. 그래서 T가 int일 때 *value가 문법적으로 말이 안 되는데도(정수는 역참조할 수 없으므로) 컴파일 에러가 나지 않습니다 — 일반 if였다면 이 코드는 양쪽 분기 모두 타입 검사를 통과해야 하므로 즉시 컴파일 에러가 났을 것입니다. 이 “선택되지 않은 분기는 인스턴스화조차 되지 않는다”는 성질이 if constexpr의 핵심이자, SFINAE나 태그 디스패치 없이도 타입별로 다른 코드를 짤 수 있게 해주는 이유입니다.
기본 사용
template<typename T>
void print(T value) {
if constexpr (std::is_integral_v<T>) {
std::cout << "정수: " << value << std::endl;
} else if constexpr (std::is_floating_point_v<T>) {
std::cout << "실수: " << value << std::endl;
} else {
std::cout << "기타: " << value << std::endl;
}
}
print(10); // "정수: 10"
print(3.14); // "실수: 3.14"
print("Hi"); // "기타: Hi"
print<int>로 인스턴스화되면 컴파일러는 사실상 std::cout << "정수: " << value << std::endl; 한 줄만 있는 함수를 생성합니다. 나머지 두 분기는 그 인스턴스에서는 코드가 아예 생성되지 않으므로, 런타임에 조건을 세 번 비교하는 일반 if-else if-else와 달리 실행 시점의 분기 비용이 전혀 없습니다.
실전 예시
예시 1: 타입별 처리
#include <iostream>
#include <type_traits>
#include <string>
template<typename T>
std::string toString(T value) {
if constexpr (std::is_same_v<T, bool>) {
return value ? "true" : "false";
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else if constexpr (std::is_convertible_v<T, std::string>) {
return value;
} else {
return "unknown";
}
}
int main() {
std::cout << toString(true) << std::endl; // "true"
std::cout << toString(42) << std::endl; // "42"
std::cout << toString("Hello") << std::endl; // "Hello"
}
bool을 먼저 검사하는 순서가 중요합니다. bool은 std::is_arithmetic_v도 참이기 때문에, 순서를 바꿔 is_arithmetic_v를 먼저 검사하면 bool 값이 std::to_string을 거쳐 "1"/"0"으로 출력되어 의도한 "true"/"false"가 나오지 않습니다. 여러 조건이 겹칠 수 있는 타입 트레잇을 조합할 때는 항상 “더 구체적인 조건을 먼저” 검사하는 순서를 지켜야 합니다.
예시 2: 재귀 종료
#include <iostream>
template<typename T, typename... Rest>
void print(T first, Rest... rest) {
std::cout << first;
if constexpr (sizeof...(rest) > 0) {
std::cout << ", ";
print(rest...); // 재귀
} else {
std::cout << std::endl;
}
}
int main() {
print(1, 2, 3, 4, 5); // 1, 2, 3, 4, 5
}
가변 인자 템플릿의 재귀 종료 조건을 옛날 방식으로 짜려면 인자가 0개인 경우를 처리하는 별도의 오버로드 함수를 하나 더 선언해야 했습니다. if constexpr를 쓰면 그 종료 조건용 오버로드 없이 함수 하나로 재귀와 종료를 모두 표현할 수 있어, C++17 이후로는 가변 인자 템플릿 재귀 패턴이 눈에 띄게 짧아졌습니다.
예시 3: 최적화
template<typename T>
T multiply(T a, T b) {
if constexpr (std::is_integral_v<T>) {
// 정수: 비트 시프트 최적화
if (b == 2) return a << 1;
if (b == 4) return a << 2;
}
return a * b;
}
사실 이 “최적화”는 교육용 예시일 뿐 실무에서는 효과가 없습니다. 최적화 컴파일러는 상수 곱셈을 이미 시프트나 lea 명령으로 바꿔 주고, 오히려 런타임 if 두 개가 추가되어 느려질 수 있습니다. 또 C++20 이전에는 음수를 왼쪽 시프트하는 것이 정의되지 않은 동작이라, 부호 있는 정수에 이 코드를 쓰면 표준상 위험하기도 합니다.
여기서 안쪽의 if (b == 2)는 일반 런타임 if라는 점에 주의해야 합니다. if constexpr는 T가 정수 타입인지(컴파일 타임 정보)만 걸러내고, b의 실제 값(런타임 정보)에 따른 분기는 여전히 일반 if로 처리해야 합니다. 이 둘을 혼동해서 런타임 값에 if constexpr를 쓰려고 하면 다음 절의 “문제 1”과 같은 컴파일 에러를 만나게 됩니다.
예시 4: SFINAE 대체
// SFINAE (복잡)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
process(T value) {
return value * 2;
}
// if constexpr (간단)
template<typename T>
auto process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else {
return value;
}
}
SFINAE 버전은 정수가 아닌 타입에 대한 오버로드를 별도로 하나 더 작성해야 하지만, if constexpr 버전은 함수 하나에 두 경우를 모두 담을 수 있습니다. 다만 이 둘이 완전히 동등하지는 않습니다 — SFINAE는 오버로드 해석 단계에서 후보를 걸러내므로 오버로드 집합 전체에 영향을 주지만, if constexpr는 함수 본문 안에서만 분기하므로 특정 타입에 대해 함수 자체를 오버로드 후보에서 제외하고 싶은 경우(예: 태그 기반 디스패치)에는 여전히 SFINAE나 concept가 필요합니다.
if vs if constexpr
template<typename T>
auto func(T value) {
// 런타임 if: 모든 분기 컴파일
if (std::is_integral_v<T>) {
// value.length(); // 에러: int에 없음
}
// 컴파일 타임 if: 선택된 분기만 컴파일
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else {
return value.length(); // OK: 정수 아닐 때만
}
}
이 예제가 if constexpr의 존재 이유를 가장 명확하게 보여줍니다. 일반 if (std::is_integral_v<T>)는 조건 자체는 컴파일 타임에 참/거짓이 정해져 있지만, if라는 문법 자체는 두 분기 모두를 “존재하는 코드”로 취급해 타입 검사를 통과시켜야 합니다. T가 int라면 value.length()는 애초에 말이 안 되는 호출이라 컴파일이 실패합니다. if constexpr로 바꾸면 T가 정수일 때 else 분기는 인스턴스화되지 않으므로 value.length()가 존재하지 않는 멤버 함수여도 문제가 되지 않습니다.
자주 발생하는 문제
문제 1: 비템플릿 함수에서는 런타임 값을 조건으로 쓸 수 없음
// 일반 함수에서 사용
void func(int x) {
if constexpr (x > 0) { // 에러: x는 런타임 값
// ...
}
}
// constexpr 값
constexpr int MAX = 100;
void func() {
if constexpr (MAX > 0) { // OK
// ...
}
}
if constexpr의 조건은 반드시 컴파일 타임에 값이 확정된 상수 표현식이어야 합니다. 함수 매개변수 x는 함수가 호출되기 전까지는 값을 알 수 없는 런타임 값이므로, 템플릿 매개변수가 아닌 일반 함수 매개변수를 조건에 넣으면 컴파일 에러가 납니다. if constexpr를 처음 쓸 때 가장 흔히 걸려 넘어지는 지점이 바로 이 부분으로, “컴파일 타임에 확정되어야 한다”는 제약을 잊고 일반 값을 넣었다가 에러 메시지를 보고서야 원인을 이해하는 경우가 많습니다.
문제 2: 버려진 분기라도 검사되는 경우
template<typename T>
auto func(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else {
return value.foo(); // T에 의존: func<int>에서는 인스턴스화되지 않으므로 OK
// undeclared_helper(); // T와 무관한 이름: 템플릿 정의 시점에 바로 에러
}
}
void g() {
int x = 0;
if constexpr (false) {
// x.foo(); // 템플릿 밖에서는 버려진 분기도 완전히 검사되어 에러
}
}
여기서 오해하기 쉬운 부분은 “선택되지 않은 분기는 인스턴스화되지 않는다”는 규칙이 템플릿 매개변수에 의존하는 표현식에만 적용된다는 것입니다. value.foo()처럼 T에 따라 존재 여부가 달라지는 멤버 호출은 인스턴스화 시점까지 검사가 미뤄지므로, func<int>(10)은 else 분기를 버리고 문제없이 컴파일됩니다. 반면 undeclared_helper()처럼 T와 무관한 이름은 템플릿을 정의하는 시점(1단계 이름 탐색)에 검사되므로 어떤 분기에 있든 에러가 나고, 괄호가 맞지 않는 것 같은 문법 오류도 마찬가지입니다. 템플릿 밖에서 쓴 if constexpr (false)는 더 엄격해서, 버려진 분기라도 모든 코드가 일반 코드처럼 완전히 검사됩니다. 그래서 if constexpr를 전처리기 #if처럼 “특정 플랫폼에만 있는 함수 호출을 숨기는” 용도로 쓰면, 템플릿 밖에서는 그 함수가 선언되지 않은 플랫폼에서 컴파일 에러가 납니다. 플랫폼별 코드 제거가 목적이라면 여전히 #if가 맞는 도구입니다.
문제 3: 중첩
template<typename T>
void func(T value) {
if constexpr (std::is_pointer_v<T>) {
if constexpr (std::is_const_v<std::remove_pointer_t<T>>) {
// const 포인터
} else {
// 비const 포인터
}
}
}
if constexpr는 얼마든지 중첩할 수 있고, 각 단계는 독립적으로 평가됩니다. 다만 중첩이 3단 이상으로 깊어지면 타입 조합의 경우의 수가 기하급수적으로 늘어나 가독성이 급격히 떨어지므로, 이 시점부터는 타입 트레잇을 조합한 별도의 헬퍼 변수(constexpr bool isConstPointer = ...)로 조건을 추출해 이름을 붙여주는 편이 유지보수에 유리합니다.
문제 4: 반환 타입이 분기마다 달라질 수 있음
template<typename T>
auto func(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2; // int
} else {
return value * 2.0; // double
}
}
// 반환 타입이 T에 따라 다름
auto 반환 타입과 if constexpr를 함께 쓰면, 인스턴스화되는 분기의 반환 타입이 곧 함수의 반환 타입이 됩니다. 인스턴스화되지 않은 분기의 반환 타입은 아예 고려 대상이 아니므로, 두 분기의 반환 타입이 서로 다르더라도(int vs double) 컴파일 에러가 나지 않습니다. 이건 편리하지만, 호출하는 쪽에서 반환 타입이 템플릿 인자에 따라 조용히 바뀐다는 사실을 인지하지 못하면 예상과 다른 타입 변환이 일어날 수 있으므로, API 경계에서는 반환 타입을 명시하는 편이 안전한 경우가 많습니다.
문제 5: 마지막 else에 static_assert(false)를 넣으면 항상 에러
지원하지 않는 타입이 들어오면 컴파일을 멈추고 싶어서 이렇게 쓰는 경우가 많습니다.
template<typename T>
void encode(T v) {
if constexpr (std::is_integral_v<T>) { /* ... */ }
else if constexpr (std::is_floating_point_v<T>) { /* ... */ }
else {
static_assert(false, "unsupported type"); // C++20까지: 아무도 encode(std::string)을 안 불러도 에러
}
}
encode(1), encode(2.0)만 호출해도 GCC 10(-std=c++20)에서 static assertion failed: unsupported type이 납니다. 버려진 분기는 인스턴스화되지 않지만, static_assert(false)의 조건은 템플릿 매개변수에 의존하지 않는 식이라 템플릿을 정의하는 시점에 바로 평가되기 때문입니다. 해결은 조건을 T에 의존하게 만드는 것입니다.
template<class> inline constexpr bool always_false = false;
template<typename T>
void encode(T v) {
if constexpr (std::is_integral_v<T>) { /* ... */ }
else if constexpr (std::is_floating_point_v<T>) { /* ... */ }
else {
static_assert(always_false<T>, "unsupported type"); // 실제로 그 타입으로 인스턴스화될 때만 에러
}
}
이제 encode(std::string("x"))처럼 지원하지 않는 타입으로 부를 때만 에러가 납니다(같은 GCC 10에서 확인). 이 우회가 워낙 흔해서 C++23(P2593)은 템플릿 안의 static_assert(false)를 인스턴스화 시점까지 미루도록 규칙을 바꿨고, GCC 13·Clang 17부터 지원합니다. 여러 컴파일러·표준 버전으로 빌드하는 코드라면 당분간 always_false<T> 방식이 안전합니다.
문제 6: 컴파일 타임에 꺼진 로그도 인자는 계산된다
constexpr bool DEBUG = false;
template<typename... Args>
void log(Args&&... args) {
if constexpr (DEBUG) {
(std::cout << ... << args) << '\n'; // DEBUG == false면 이 코드는 생성되지 않음
}
}
log("state=", dumpState()); // 그래도 dumpState()는 호출된다
if constexpr로 로그 본문을 없애는 패턴은 널리 쓰이지만, 없어지는 것은 함수 본문뿐입니다. 호출하는 쪽의 인자는 함수에 들어가기 전에 평가되므로, 위 코드에서 dumpState()는 DEBUG가 false여도 매번 실행됩니다(카운터를 두고 확인하면 1회 호출이 그대로 찍힙니다). 인자 계산이 비싸거나 부수 효과가 있다면 호출 자체를 if constexpr (DEBUG) { log(...); }로 감싸거나, 인자 대신 람다를 넘겨 로그가 켜졌을 때만 호출하도록 해야 합니다.
활용 패턴
// 1. 타입 변환
template<typename T>
auto convert(T value) {
if constexpr (std::is_same_v<T, std::string>) {
return value;
} else {
return std::to_string(value);
}
}
// 2. 컨테이너 처리
template<typename Container>
void process(Container& c) {
if constexpr (requires { c.reserve(10); }) {
c.reserve(100); // vector, string
}
}
// 3. 최적화
template<size_t N>
void func() {
if constexpr (N < 10) {
// 작은 N: 간단한 구현
} else {
// 큰 N: 최적화된 구현
}
}
두 번째 패턴(requires { c.reserve(10); })은 C++20 concept 문법을 if constexpr와 결합한 예시로, “이 컨테이너가 reserve를 지원하면 호출하고, 아니면 건너뛴다”는 조건을 별도의 타입 트레잇을 직접 작성하지 않고도 표현할 수 있습니다. std::vector나 std::string처럼 reserve가 있는 타입과 std::list처럼 없는 타입 모두를 받는 제네릭 함수를 짤 때 실무에서 자주 쓰이는 패턴입니다. C++17에서는 같은 검사를 하려면 std::void_t로 감지(detection) 트레잇을 직접 만들어야 했는데, requires 식은 그 보일러플레이트를 한 줄로 줄여 줍니다. 첫 번째 패턴은 T가 std::to_string을 지원하지 않는 타입(사용자 정의 클래스 등)이면 else 분기에서 긴 오버로드 해석 실패 메시지가 나므로, 앞의 “문제 5”처럼 지원 타입을 명시적으로 걸러 읽기 쉬운 오류를 내 주는 편이 사용자에게 친절합니다.
템플릿 특수화 대신 쓸 때 잃는 것: 확장 가능성
타입별로 다른 동작을 만들 때 if constexpr 체인은 한 함수 안에서 모든 경우가 보여 읽기 쉽습니다. 다만 템플릿 특수화와 결정적으로 다른 점이 하나 있습니다.
// 특수화 방식: 라이브러리 사용자가 자기 타입을 나중에 추가할 수 있다
template<typename T> struct Serializer; // 라이브러리
template<> struct Serializer<int> { static void write(int); };
template<> struct Serializer<MyType> { static void write(const MyType&); }; // 사용자 코드, 다른 파일
// if constexpr 방식: 새 타입을 지원하려면 이 함수 자체를 고쳐야 한다
template<typename T>
void write(const T& v) {
if constexpr (std::is_same_v<T, int>) { /* ... */ }
else if constexpr (std::is_same_v<T, std::string>) { /* ... */ }
}
if constexpr 체인은 닫힌 목록입니다. 지원 타입을 한 곳에서 관리하는 내부 구현에는 이쪽이 낫지만, 사용자가 자기 타입에 대한 동작을 끼워 넣어야 하는 공개 API(직렬화, 해시, 포매터 등)라면 특수화나 ADL로 찾는 커스터마이제이션 포인트가 맞습니다. std::hash나 std::formatter가 특수화 방식인 이유이기도 합니다. 두 방식을 섞어, 공개 진입점은 특수화로 열어 두고 각 특수화 안의 세부 구현만 if constexpr로 정리하는 구성이 자주 쓰입니다.
제가 if constexpr 체인을 쓸 때 가장 조심하는 부분은 조건이 겹치는 순서입니다. 앞의 toString 예제에서 bool을 is_arithmetic_v보다 먼저 검사해야 했던 것처럼, const char*는 is_pointer_v에도 is_convertible_v<T, std::string>에도 걸리고, std::string_view는 문자열처럼 보이지만 std::string으로 암묵 변환되지 않습니다. 체인이 길어질수록 “이 타입이 몇 번째 분기로 가는가”를 머릿속으로 계산하기 어려워지므로, 대표 타입마다 static_assert로 기대하는 결과를 검증하는 작은 테스트를 두면 순서를 바꿨을 때의 회귀를 컴파일 단계에서 잡을 수 있습니다. 또 조건에 쓰는 타입은 대개 std::decay_t<T>나 std::remove_cvref_t<T>로 정규화해야 합니다. 전달 참조(T&&)로 받은 함수에서 T는 int&가 될 수 있고, std::is_same_v<int&, int>는 거짓이기 때문입니다.
참고 자료
FAQ
조건식 안에서 &&의 단락 평가로 잘못된 표현식을 피할 수 있나요?
아닙니다. if constexpr (std::is_class_v<T> && T::value)에서 T가 int라면, 앞쪽이 거짓이어도 조건식 전체가 템플릿 인자로 치환되는 과정에서 int::value가 만들어져 컴파일 에러가 납니다. 단락 평가는 값 계산을 건너뛸 뿐 표현식의 유효성 검사를 건너뛰지 않기 때문입니다. 이런 경우에는 if constexpr를 두 단계로 중첩하거나, 조건 전체를 안전하게 평가하는 트레잇(std::conjunction_v 등)을 사용해야 합니다.
일반 if와 근본적인 차이는?
일반 if는 런타임에 분기하며 양쪽 분기 모두 컴파일 타임에 타입 검사를 통과해야 합니다. if constexpr는 컴파일 타임에 조건이 결정되어 선택되지 않은 분기는 인스턴스화조차 되지 않습니다.
SFINAE를 완전히 대체할 수 있나요?
대부분의 경우 더 간단하게 대체할 수 있지만, 오버로드 후보 자체를 제외해야 하는 경우에는 여전히 SFINAE나 concept가 필요합니다.