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 <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: 재귀 종료
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
Q1: 조건식 안에서 &&의 단락 평가로 잘못된 표현식을 피할 수 있나요?
A: 아닙니다. if constexpr (std::is_class_v<T> && T::value)에서 T가 int라면, 앞쪽이 거짓이어도 조건식 전체가 템플릿 인자로 치환되는 과정에서 int::value가 만들어져 컴파일 에러가 납니다. 단락 평가는 값 계산을 건너뛸 뿐 표현식의 유효성 검사를 건너뛰지 않기 때문입니다. 이런 경우에는 if constexpr를 두 단계로 중첩하거나, 조건 전체를 안전하게 평가하는 트레잇(std::conjunction_v 등)을 사용해야 합니다.
Q2: 일반 if와 근본적인 차이는?
A: 일반 if는 런타임에 분기하며 양쪽 분기 모두 컴파일 타임에 타입 검사를 통과해야 합니다. if constexpr는 컴파일 타임에 조건이 결정되어 선택되지 않은 분기는 인스턴스화조차 되지 않습니다.
Q3: 런타임 성능에 영향이 있나요?
A: 선택되지 않은 분기는 애초에 코드로 생성되지 않으므로 런타임 분기 비용이 전혀 없습니다.
Q4: SFINAE를 완전히 대체할 수 있나요?
A: 대부분의 경우 더 간단하게 대체할 수 있지만, 오버로드 후보 자체를 제외해야 하는 경우에는 여전히 SFINAE나 concept가 필요합니다.
Q5: 비템플릿 함수에서도 쓸 수 있나요?
A: 조건이 constexpr 값이라면 가능하지만, 함수 매개변수 같은 런타임 값은 조건으로 쓸 수 없습니다.