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 값이라면 가능하지만, 함수 매개변수 같은 런타임 값은 조건으로 쓸 수 없습니다.


같이 보면 좋은 글