C++ constexpr 함수: 컴파일 타임 평가 조건과 C++11·14·17 제약 변화

이 글의 핵심

constexpr 함수가 '항상 컴파일 타임에 실행된다'는 흔한 오해를 바로잡고, 실제 평가 규칙과 C++11/C++14 차이, const와의 차이, 자주 만나는 컴파일 오류의 원인을 실전 코드로 설명합니다.

constexpr 함수란?

constexpr 함수는 “컴파일 타임에 계산될 수도 있고, 런타임에 일반 함수처럼 실행될 수도 있는” 함수입니다. 여기서 실무자들이 가장 많이 오해하는 지점이 있습니다. constexpr을 붙였다고 해서 그 함수 호출이 무조건 컴파일 타임에 평가되는 것은 아니라는 점입니다.

컴파일러는 그 함수의 결과가 컴파일 타임 상수가 반드시 필요한 문맥(배열 크기, 템플릿 비타입 인자, constexpr 변수 초기화, static_assert 등)에서 호출될 때만 상수 평가를 시도합니다. 그 외의 평범한 문맥에서는 최적화 여부와 상관없이 그냥 런타임 함수 호출처럼 컴파일될 수 있습니다. 즉 constexpr은 “컴파일 타임에 계산해도 되는 자격을 준다”는 선언이지, “항상 그렇게 하라”는 강제가 아닙니다. 이 차이를 모르면 왜 같은 함수가 어떤 곳에서는 컴파일 오류를 내고 어떤 곳에서는 멀쩡히 런타임에 돌아가는지 이해할 수 없습니다.

constexpr int square(int x) {
    return x * x;
}

int main() {
    // 컴파일 타임 문맥 — 상수 평가가 강제됨
    constexpr int a = square(5);  // 25, 컴파일 시 계산

    // 런타임 문맥 — 굳이 컴파일 타임에 계산할 필요가 없음
    int x = 10;
    int b = square(x);  // 인자 x가 런타임 변수이므로 런타임 계산
}

a는 constexpr로 선언되었기 때문에 컴파일러가 반드시 상수 평가를 해내야 하고, 실패하면 컴파일 오류가 납니다. 반면 b는 그냥 int이므로 컴파일러가 원하면 최적화 차원에서 상수 폴딩(constant folding)을 할 수도 있지만, 그럴 의무는 없습니다. x가 런타임에만 정해지는 값이라면 애초에 상수 평가 자체가 불가능하므로 일반 함수 호출로 컴파일됩니다.

C++11 vs C++14 vs C++17

C++11에서 constexpr 함수는 사실상 “본문이 return 표현식 하나뿐인 함수”로 제한되었습니다. 반복문, 지역 변수 선언, if/switch 같은 제어문을 쓸 수 없었기 때문에 삼항 연산자와 재귀로 로직을 표현해야 했습니다. 아래 factorial11이 그 전형적인 예입니다. C++14부터는 이 제약이 크게 풀려서 일반 함수처럼 지역 변수와 for/while 루프를 자유롭게 쓸 수 있게 되었습니다.

이 변화는 단순한 문법적 편의가 아닙니다. C++11 스타일의 재귀 기반 constexpr 함수는 컴파일러의 상수 평가기(constant evaluator)가 재귀 호출을 얼마나 깊게 따라갈 수 있는지에 대한 제약(대부분의 컴파일러는 -fconstexpr-depth 같은 옵션으로 기본 512회 정도 제한)에 부딪히기 쉽습니다. 즉 같은 로직이라도 재귀로 짜면 입력값이 커질 때 “constexpr 평가 깊이 초과” 오류가 날 수 있고, C++14 스타일의 반복문으로 짜면 그런 제약에서 훨씬 자유롭습니다.

// C++11: 단일 return문만 (재귀로 표현해야 함)
constexpr int factorial11(int n) {
    return n <= 1 ? 1 : n * factorial11(n - 1);
}

// C++14: 일반 함수처럼 지역 변수와 루프 사용 가능
constexpr int factorial14(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

// C++17: if constexpr 추가 — 템플릿 분기를 컴파일 타임에 완전히 제거
template<typename T>
constexpr auto process(T value) {
    if constexpr (std::is_integral_v<T>) {
        return value * 2;
    } else {
        return value;
    }
}

if constexpr는 이름은 비슷하지만 목적이 다릅니다. 일반 constexpr 함수가 “이 함수 자체를 컴파일 타임에 평가할 수 있게 만드는 것”이라면, if constexpr는 “템플릿 인스턴스화 시점에 조건에 맞지 않는 분기를 아예 컴파일 대상에서 제외시키는 것”입니다. 이 둘을 헷갈리면 템플릿 코드에서 타입이 맞지 않는 분기 때문에 컴파일 오류가 나는 이유를 찾기 어려워집니다.

기본 사용법

// 간단한 계산
constexpr int add(int a, int b) {
    return a + b;
}

constexpr int multiply(int a, int b) {
    return a * b;
}

// 배열 크기로 사용
constexpr int SIZE = add(10, 20);
int arr[SIZE];  // 컴파일 타임 크기

배열 크기, std::array의 템플릿 인자, enum 값처럼 언어 규격상 반드시 컴파일 타임 상수여야 하는 자리에는 일반 함수의 반환값을 넣을 수 없습니다. 이런 자리에 계산된 값을 넣고 싶을 때가 constexpr 함수의 가장 확실한 실용적 용도입니다. 매직 넘버를 하드코딩하는 대신 의미 있는 계산식으로 표현하면서도 런타임 비용은 전혀 들지 않습니다.

실전 예시

예시 1: 수학 함수

constexpr int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

constexpr int power(int base, int exp) {
    int result = 1;
    for (int i = 0; i < exp; i++) {
        result *= base;
    }
    return result;
}

constexpr bool isPrime(int n) {
    if (n <= 1) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;

    for (int i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}

int main() {
    constexpr int fib10 = fibonacci(10);  // 55 (컴파일 타임)
    constexpr int pow = power(2, 10);     // 1024
    constexpr bool prime = isPrime(17);   // true

    std::cout << fib10 << std::endl;
    std::cout << pow << std::endl;
    std::cout << prime << std::endl;
}

여기서 눈여겨볼 부분은 fibonacci가 여전히 재귀로 짜여 있다는 점입니다. n이 작을 때는 문제없지만, 이 함수를 그대로 두고 fibonacci(40)처럼 상수 문맥에서 호출하면 컴파일러가 지수적으로 늘어나는 재귀 호출 트리를 전부 상수 평가해야 합니다. 실행은 되지만 컴파일 시간이 눈에 띄게 늘어나거나, 컴파일러가 정한 평가 스텝 한도(-fconstexpr-steps, MSVC의 /constexpr:steps)를 넘겨 “constexpr evaluation exceeded step limit” 같은 오류를 낼 수 있습니다. 실무에서 constexpr 함수를 짤 때는 “이 함수가 상수 평가 문맥에서 호출될 최대 입력 크기가 얼마나 되는가”를 항상 염두에 두어야 합니다.

실전에서 겪은 문제: 재귀 기반 constexpr 함수로 컴파일 타임 룩업 테이블을 만들다가, 테이블 크기를 32에서 64로 늘렸더니 갑자기 GCC에서 constexpr evaluation depth exceeds maximum이라는 오류가 났던 적이 있습니다. 로컬에서는 잘 빌드되던 코드가 CI에서만 실패해서 처음엔 CI 환경 문제인 줄 알았는데, 알고 보니 CI의 GCC 버전이 로컬보다 기본 -fconstexpr-depth 값이 더 낮게 설정되어 있었습니다. 결국 재귀를 반복문 기반(C++14 스타일)으로 다시 짜서 해결했는데, 이 경험 이후로는 컴파일 타임에 평가될 함수는 처음부터 반복문으로 작성하는 습관을 들였습니다. 재귀 깊이 제한은 컴파일러·버전·플래그마다 다르므로 “내 컴퓨터에서는 됐다”가 전혀 보장이 안 됩니다.

예시 2: 문자열 처리

constexpr size_t strlen_constexpr(const char* str) {
    size_t len = 0;
    while (str[len] != '\0') {
        len++;
    }
    return len;
}

constexpr bool startsWith(const char* str, const char* prefix) {
    while (*prefix != '\0') {
        if (*str != *prefix) {
            return false;
        }
        str++;
        prefix++;
    }
    return true;
}

int main() {
    constexpr size_t len = strlen_constexpr("Hello");  // 5
    constexpr bool starts = startsWith("Hello World", "Hello");  // true

    char buffer[len + 1];  // 컴파일 타임 크기
}

문자열 리터럴은 프로그램 전체에서 정적 저장 기간을 가지는 배열이기 때문에 constexpr 문맥에서 포인터 연산을 해도 안전합니다. 이 패턴은 컴파일 타임에 문자열 길이를 알아야 버퍼 크기를 정할 수 있는 코드에서 유용합니다. 다만 strlen_constexpr에 런타임에만 만들어지는 char*(예: std::string::c_str()가 가리키는 힙 메모리)를 넘기면 애초에 상수 평가 자체가 불가능하므로 컴파일 타임 문맥에서는 사용할 수 없고, 자동으로 런타임 함수로 동작합니다.

예시 3: 해시 함수

constexpr unsigned int hash(const char* str) {
    unsigned int hash = 5381;
    while (*str) {
        hash = ((hash << 5) + hash) + *str;
        str++;
    }
    return hash;
}

int main() {
    // 컴파일 타임 해시
    constexpr unsigned int h1 = hash("apple");
    constexpr unsigned int h2 = hash("banana");

    // switch문에 사용
    const char* fruit = "apple";
    switch (hash(fruit)) {
        case hash("apple"):
            std::cout << "사과" << std::endl;
            break;
        case hash("banana"):
            std::cout << "바나나" << std::endl;
            break;
    }
}

switch의 case 라벨은 언어 규격상 반드시 컴파일 타임 상수여야 하므로, 문자열을 switch로 분기하고 싶을 때 이런 컴파일 타임 해시 함수가 흔히 쓰입니다. 문자열 리터럴을 직접 비교하는 if-else 체인보다 가독성이 좋아지는 대신, 해시 충돌 가능성을 감수해야 한다는 트레이드오프가 있습니다. djb2 계열의 이 해시 함수는 충돌 확률이 낮지만 0은 아니므로, 입력 후보군이 많아질수록(예: 수백 개의 명령어 문자열을 분기) 충돌 검증을 별도로 해두는 편이 안전합니다.

예시 4: 배열 초기화

constexpr int generateValue(int index) {
    return index * index;
}

template<size_t N>
constexpr std::array<int, N> generateArray() {
    std::array<int, N> arr{};
    for (size_t i = 0; i < N; i++) {
        arr[i] = generateValue(i);
    }
    return arr;
}

int main() {
    constexpr auto arr = generateArray<10>();
    // arr = {0, 1, 4, 9, 16, 25, 36, 49, 64, 81}

    for (int x : arr) {
        std::cout << x << " ";
    }
}

이 패턴은 룩업 테이블을 프로그램 실행 시점이 아니라 컴파일 시점에 통째로 채워 넣고 싶을 때 씁니다. 삼각함수 테이블, CRC 테이블, 상태 전이 테이블처럼 값이 고정되어 있고 계산 비용이 있는 데이터를 이렇게 만들면 런타임 초기화 비용이 완전히 사라지고, 결과 배열이 바이너리에 상수 데이터로 박혀 들어갑니다. 다만 N이 매우 커지면(수만 단위) 컴파일 시간과 바이너리 크기가 함께 늘어나므로, “정말 컴파일 타임에 필요한 데이터인지” 먼저 따져봐야 합니다.

constexpr 클래스

class Point {
private:
    int x, y;

public:
    constexpr Point(int x, int y) : x(x), y(y) {}

    constexpr int getX() const { return x; }
    constexpr int getY() const { return y; }

    constexpr int distanceSquared() const {
        return x * x + y * y;
    }
};

int main() {
    constexpr Point p(3, 4);
    constexpr int dist = p.distanceSquared();  // 25

    int arr[dist];  // 컴파일 타임 크기
}

생성자와 멤버 함수에도 constexpr을 붙일 수 있다는 사실은 처음 접하면 낯설게 느껴집니다. 여기서 핵심은 클래스 자체가 리터럴 타입(literal type) 조건을 만족해야 한다는 점입니다. 가상 함수가 없어야 하고(C++20 이전), 모든 비정적 멤버가 리터럴 타입이어야 하며, constexpr 소멸자를 가져야 합니다(C++20부터는 자동으로 조건이 완화되긴 했지만). 이 조건 중 하나라도 깨지면 아무리 생성자에 constexpr을 붙여도 클래스 인스턴스를 상수 문맥에서 만들 수 없다는 컴파일 오류를 만나게 됩니다.

자주 발생하는 문제

문제 1: 비constexpr 함수 호출

int nonConstexpr(int x) {
    return x * 2;
}

// ❌ 비constexpr 함수 호출
constexpr int func(int x) {
    return nonConstexpr(x);  // 상수 평가 시도 시 에러
}

// ✅ constexpr 함수만 호출
constexpr int constexprFunc(int x) {
    return x * 2;
}

constexpr int func(int x) {
    return constexprFunc(x);  // OK
}

주의할 점은 첫 번째 func가 항상 컴파일 오류를 내는 것은 아니라는 사실입니다. func를 런타임 문맥에서만 호출한다면(예: int r = func(5);) 컴파일러는 nonConstexpr 호출을 그냥 평범한 함수 호출로 처리하고 넘어갑니다. 오류는 constexpr int r = func(5);처럼 상수 평가를 강제하는 문맥에서 호출할 때만 발생합니다. 이 비대칭성 때문에 “어제까지 잘 컴파일되던 함수가 오늘 갑자기 에러가 난다”는 상황이 자주 생깁니다. 누군가 그 함수를 상수 문맥에서 처음 호출하는 순간 비로소 본문 전체가 상수 평가 가능한지 검증되기 때문입니다.

이 규칙은 예외 처리에서도 똑같이 나타납니다. constexpr 함수 본문에 throw가 있어도 그 자체로는 컴파일 오류가 아닙니다. 다만 그 함수가 실제로 상수 평가되는 경로에서 throw에 도달하면 그 시점에 컴파일 오류로 보고되고, 런타임 호출 경로에서 같은 throw에 도달하면 평범하게 예외가 던져집니다. 즉 같은 코드 한 줄이 어떤 문맥에서 호출되느냐에 따라 컴파일 오류가 되기도 하고 런타임 예외가 되기도 합니다.

실전에서 겪은 문제: 디버그 목적으로 constexpr 함수 안에 assert(x >= 0)을 넣어둔 적이 있습니다. 평소 런타임 호출에서는 문제없이 동작했는데, 나중에 이 함수를 배열 크기를 정하는 constexpr 표현식에서 호출하도록 코드를 리팩터링하자 갑자기 “assert 표현식이 상수식이 아니다” 계열의 컴파일 오류가 쏟아졌습니다. assert는 매크로로 구현되어 있고 표준 라이브러리 구현에 따라 실패 시 abort()나 스트림 출력 같은 non-constexpr 연산으로 이어지는데, 이런 연산은 값이 실제로 조건을 만족하지 못하는 상수 평가 경로에서만 문제가 됩니다. 결국 assert 대신 표준 라이브러리가 제공하는 상수식 호환 방식(또는 if consteval로 분기)으로 바꿔야 했는데, 이 경험 이후로는 constexpr 함수 내부에 디버그용 코드를 넣을 때 반드시 상수 평가 경로에서도 문제가 없는지 확인하는 습관이 생겼습니다.

문제 2: 정적 변수

// ❌ 정적 변수 (C++11)
constexpr int func() {
    static int count = 0;  // 에러
    return count++;
}

// ✅ C++14부터 허용
constexpr int func14() {
    static int count = 0;  // OK (C++14)
    return count++;
}

다만 C++14가 문법적으로 static 지역 변수를 허용한다고 해서 이 함수가 실제로 상수 평가에 성공하는 것은 아닙니다. static 변수는 호출마다 상태가 유지되는데, 상수 평가는 “같은 입력에 대해 항상 같은 결과가 나와야 한다”는 순수성을 요구하기 때문에, 이런 함수를 실제로 상수 문맥에서 호출하려고 하면 별도의 오류가 발생합니다. 즉 이 예제는 “문법적으로는 허용되지만 실질적으로 상수 평가는 불가능한” 함수를 상수 평가하지 않는 한(런타임 함수로만 쓰는 한) 문제없이 컴파일된다는 뜻으로 이해해야 합니다.

문제 3: 가상 함수

class Base {
public:
    // C++17 이전에는 virtual + constexpr 조합이 금지되었음
    virtual constexpr int getValue() const {
        return 42;
    }
};

// C++20부터 허용 (가상 함수도 constexpr 문맥에서 평가 가능)
class Base20 {
public:
    virtual constexpr int getValue() const {
        return 42;
    }
};

가상 함수와 constexpr의 조합이 오랫동안 막혀 있었던 이유는 동적 디스패치(런타임에 실제 타입을 확인해 어떤 오버라이드를 호출할지 결정하는 메커니즘) 자체가 컴파일 타임 평가 모델과 충돌했기 때문입니다. C++20은 상수 평가기 안에서도 실제 객체의 정적 타입을 추적할 수 있도록 규칙을 정비해서 이 제약을 풀었습니다. 다만 여전히 컴파일러 지원 상황이 갈릴 수 있으므로, 이 기능에 의존하는 코드는 실제 타겟 컴파일러의 C++20 지원 수준을 먼저 확인하는 것이 안전합니다.

문제 4: 런타임 값

constexpr int square(int x) {
    return x * x;
}

int main() {
    int runtime_value;
    std::cin >> runtime_value;

    // ❌ constexpr 변수에 런타임 값
    // constexpr int result = square(runtime_value);  // 에러: runtime_value가 상수식이 아님

    // ✅ 일반 변수
    int result = square(runtime_value);  // OK (런타임 계산)
}

여기서 근본적인 규칙 하나를 짚고 넘어갈 필요가 있습니다. constexpr 함수의 파라미터 자체는 constexpr일 수 없습니다. 파라미터는 호출할 때마다 다른 값이 들어올 수 있는 자리이기 때문에, 함수 정의 시점에는 그 값이 컴파일 타임 상수인지 런타임 값인지 알 수 없습니다. 대신 컴파일러는 “이 함수가 상수 평가 가능한 인자로 호출되면 상수로 평가하고, 그렇지 않으면 런타임 함수로 취급한다”는 이원적인 전략을 씁니다. square(runtime_value)는 인자가 런타임에만 정해지므로 애초에 상수 평가 후보에서 제외되고, 그 결과를 담는 그릇이 constexpr 변수일 때만 컴파일 오류로 드러납니다.

constexpr vs const

// const: 런타임에 초기화될 수도 있는 상수
const int x = getValue();  // 런타임 결정, 이후 변경 불가

// constexpr: 반드시 컴파일 타임에 알려져야 하는 상수
constexpr int y = 42;  // 컴파일 타임 결정

// constexpr 함수 — 조건이 맞으면 상수 평가 가능
constexpr int square(int x) {
    return x * x;
}

const와 constexpr은 종종 “둘 다 상수니까 비슷하다”고 오해받지만 의미하는 바가 다릅니다. const는 “이 변수는 초기화된 이후 값이 바뀌지 않는다”는 뜻일 뿐, 그 초기값이 컴파일 타임에 알려져 있어야 한다는 뜻은 아닙니다. 위 예제의 getValue()가 런타임에 파일을 읽거나 사용자 입력을 받아 값을 반환하는 함수라도 const int x = getValue();는 완전히 유효합니다. 반면 constexpr은 “이 값은 컴파일 타임에 반드시 알려져 있어야 한다”는 훨씬 강한 요구를 겁니다. 배열 크기나 템플릿 인자처럼 언어가 컴파일 타임 상수를 요구하는 자리에는 const만으로는 충분하지 않고 constexpr(또는 컴파일 타임에 알려진 리터럴)이 필요합니다. 실무에서 “왜 const int SIZE = ...로는 배열 크기를 못 정하지?”라는 질문을 받으면, 초기화식이 실제로 컴파일 타임에 계산 가능한지부터 확인해 보는 것이 첫 단계입니다. 대부분의 컴파일러는 초기화식이 상수식이면 const 변수도 배열 크기에 쓸 수 있게 허용하지만, 이는 컴파일러의 편의 확장이지 언어가 보장하는 규칙은 아닙니다.

constexpr과 최적화

// 컴파일 타임 계산
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    // 컴파일 타임에 계산됨 (런타임 비용 0)
    constexpr int fact10 = factorial(10);

    // 최적화된 어셈블리 예시:
    // mov eax, 3628800  (계산 없이 값이 직접 삽입됨)
}

constexpr이 주는 실질적인 이득은 “런타임 CPU 사이클을 0으로 만든다”는 것입니다. 다만 이 이득은 공짜가 아니라 컴파일 시간과 맞바꾸는 것입니다. 상수 평가는 컴파일러 내부의 별도 인터프리터가 담당하는데, 재귀나 반복 횟수가 늘어날수록 그만큼 컴파일 시간이 늘어납니다. 빌드 시간에 민감한 대규모 프로젝트에서는 constexpr 계산을 헤더에 남발하면 증분 빌드 속도가 눈에 띄게 느려질 수 있으므로, “이 값이 정말 여러 번, 여러 파일에서 컴파일 타임에 필요한가”를 먼저 따져보는 편이 좋습니다.

FAQ

Q1: constexpr 함수는 언제 사용?

A:

  • 배열 크기, 템플릿 비타입 인자처럼 언어가 컴파일 타임 상수를 요구하는 자리에 계산된 값을 넣고 싶을 때
  • 룩업 테이블처럼 값이 고정되어 있고 런타임 초기화 비용을 없애고 싶을 때
  • static_assert로 컴파일 타임에 조건을 검증하고 싶을 때

Q2: 런타임에도 사용 가능?

A: 네. 인자가 컴파일 타임 상수가 아니면 컴파일러가 자동으로 런타임 함수 호출로 처리합니다. 같은 함수 정의를 상수 문맥과 런타임 문맥 양쪽에서 재사용할 수 있다는 점이 constexpr의 장점입니다.

Q3: constexpr vs inline?

A:

  • constexpr: 조건이 맞으면 컴파일 타임에 값을 계산해 버리는 것 — 계산 자체를 없앰
  • inline: 함수 호출 오버헤드를 없애기 위해 호출부에 함수 본문을 그대로 삽입하는 것 — 계산은 여전히 런타임에 일어남

둘은 독립적인 개념이라 constexpr inline 조합도 가능하지만, 대부분의 컴파일러가 constexpr 함수를 정의와 동시에 암묵적으로 inline 링크 속성을 부여하므로 실무에서 굳이 같이 쓸 일은 많지 않습니다.

Q4: 모든 함수를 constexpr로?

A: 아니요. 함수 본문에 I/O, 동적 메모리 할당(일부 예외 제외), 비constexpr 함수 호출이 섞여 있으면 애초에 상수 평가가 불가능합니다. 게다가 상수 평가가 불필요한 함수까지 constexpr로 표시하면 컴파일 시간만 늘어나고 실익이 없는 경우도 많습니다.

Q5: C++11 vs C++14 차이?

A: C++11은 본문이 단일 return문으로 제한되어 재귀로만 로직을 표현해야 했고, C++14부터는 지역 변수·반복문·다중 문장을 일반 함수처럼 쓸 수 있게 완화되었습니다.

Q6: constexpr 학습 리소스는?

A:

  • “Effective Modern C++” (Scott Meyers) — Item 15가 constexpr을 다룹니다
  • cppreference.com의 constexpr specifier 문서 — 표준별 변경 이력이 잘 정리되어 있습니다
  • 컴파일러별 릴리스 노트 — 실제 지원 범위는 표준 문서보다 컴파일러 문서가 더 정확할 때가 많습니다

같이 보면 좋은 글