C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전

C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전

이 글의 핵심

런타임 오버헤드를 줄이기 위한 컴파일 타임 프로그래밍. constexpr 함수, if constexpr, consteval(C++20), 템플릿 메타 vs constexpr 비교, 성능 벤치마크, 프로덕션 패턴까지.

들어가며: “런타임 오버헤드를 줄이고 싶어요”

왜 컴파일 타임에 계산해야 할까?

게임 엔진에서 매 프레임 수천 개의 오브젝트에 회전 행렬을 적용하거나, 임베디드에서 센서 보정값을 상수 테이블로 쓰거나, 네트워크 프로토콜에서 CRC32를 계산할 때—이런 값들이 런타임에 매번 계산되면 CPU 사이클이 낭비됩니다. 반대로 컴파일 타임에 미리 계산해 두면, 실행 시에는 이미 계산된 상수만 사용하므로 런타임 오버헤드가 제로가 됩니다.

비유하면: 매일 아침 계산기로 “오늘 요일 × 7”을 하는 것(런타임)과, 달력에 미리 적어 두고 보기만 하는 것(컴파일 타임)의 차이입니다.

flowchart LR
  subgraph runtime["런타임 계산"]
    R1[호출] --> R2[계산 수행]
    R2 --> R3[결과 반환]
    R2 -.->|CPU 사이클 소비| R2
  end
  subgraph compile["컴파일 타임 계산"]
    C1[코드 작성] --> C2[컴파일 시 계산]
    C2 --> C3[상수로 바이너리에 포함]
    C3 --> C4[실행 시 즉시 사용]
  end

목표:

  • constexpr 함수로 값 계산을 컴파일 타임에 수행
  • if constexpr로 타입/값에 따른 분기
  • consteval(C++20)으로 “반드시 컴파일 타임” 보장
  • 템플릿 메타 vs constexpr 비교 및 선택 가이드
  • 일반적인 실수성능 벤치마크
  • 프로덕션 패턴: 설정 파싱, 룩업 테이블

이 글을 읽으면:

  • 런타임 오버헤드를 줄이는 구체적인 기법을 적용할 수 있습니다.
  • constexpr·consteval·if constexpr를 실전에서 조합해 쓸 수 있습니다.
  • 템플릿 메타 대신 constexpr로 코드를 단순화할 수 있습니다.

목차

  1. 문제 시나리오: 런타임 오버헤드
  2. constexpr 함수 상세 예제
  3. if constexpr 활용
  4. consteval (C++20)
  5. 템플릿 메타 vs constexpr
  6. 일반적인 실수
  7. 성능 벤치마크
  8. 프로덕션 패턴

1. 문제 시나리오: 런타임 오버헤드

”매 프레임마다 같은 계산을 반복해요”

예를 들어 팩토리얼이나 배열 합계런타임에 매번 계산하면, 호출되는 횟수만큼 CPU가 일을 합니다. 반면 이런 값이 상수라면(예: N!N이 컴파일 타임 상수일 때), 컴파일러가 미리 계산해 두고 바이너리에 상수로 박아 넣을 수 있습니다.

// ❌ 나쁜 예: 매번 런타임에 계산
int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
// 사용: std::array<int, factorial(5)> arr;  // 컴파일 에러! n은 상수가 아님
// ✅ 좋은 예: constexpr로 컴파일 타임 계산 가능
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
// 사용: std::array<int, factorial(5)> arr;  // 컴파일 타임에 120으로 계산됨

추가 문제 시나리오

시나리오 1: 게임 엔진 회전 행렬
매 프레임 수천 개의 스프라이트에 2D/3D 회전 행렬을 적용할 때, 각도가 고정(예: 90°, 180°)이면 sin, cos 값을 런타임에 매번 계산하는 대신 컴파일 타임 룩업 테이블로 미리 계산해 두면 CPU 부하를 크게 줄일 수 있습니다.

시나리오 2: 임베디드 센서 보정
ADC(아날로그-디지털 변환) 값에서 실제 온도/압력을 구하는 선형 보정 테이블이 고정되어 있다면, 런타임에 테이블을 초기화하는 대신 constexpr 배열로 바이너리에 포함시키면 부팅 시 초기화 비용이 사라집니다.

시나리오 3: 네트워크 프로토콜 CRC32
패킷 검증용 CRC32 테이블(256개 uint32_t)을 런타임에 생성하면 첫 패킷 전송 전 수 μs가 소요됩니다. 컴파일 타임에 생성하면 런타임 비용이 0입니다.

시나리오 4: 그래픽 색공간 변환
sRGB ↔ linear 변환, YUV ↔ RGB 등 고정 변환 행렬을 매 픽셀마다 계산하지 않고, 컴파일 타임에 미리 계산된 상수로 사용하면 GPU/CPU 부하를 줄일 수 있습니다.

시나리오 5: 프로토콜 명령어 디스패치
”start”, “stop”, “pause” 같은 문자열 명령을 switch로 처리할 때, 문자열 비교 대신 컴파일 타임 해시로 변환하면 분기 비용을 줄이고 컴파일러 최적화를 유도할 수 있습니다.

시나리오 6: 고정 프레임 애니메이션
스프라이트 시트의 프레임 수(예: 8프레임)가 빌드 시 고정이라면, std::array<Frame, 8>처럼 컴파일 타임 상수 크기로 선언하면 힙 할당 없이 스택에 배치됩니다.

문제의 원인

원인설명
상수 표현식이 아님constexpr 없이 정의한 함수는 컴파일 타임에 호출 불가
런타임 인자factorial(n)에서 n이 변수면 컴파일 타임에 알 수 없음
제한된 연산constexpr 함수 내부에서는 new, throw, 가상 함수 등 불가

해결 방향

컴파일 타임 상수로 쓸 값은 constexpr 함수로 정의하고, 상수 인자로 호출하면 컴파일러가 자동으로 컴파일 타임에 계산합니다. C++20에서는 consteval로 “반드시 컴파일 타임에만 실행”되도록 강제할 수 있습니다.


2. constexpr 함수 상세 예제

2.1 팩토리얼 (factorial)

용도: 조합 수, 배열 크기, 수학 상수 등에서 N!이 컴파일 타임 상수일 때 유용합니다.

// constexpr: 컴파일 타임과 런타임 모두에서 호출 가능
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    std::array<int, factorial(5)> arr;  // 크기 120, 컴파일 타임 계산
    constexpr int f10 = factorial(10);  // 3628800, 컴파일 타임
    int x = 5;
    int f5 = factorial(x);              // 런타임에도 호출 가능
}

주의: factorial음수너무 큰 값을 넣으면 오버플로우/스택 오버플로우가 발생할 수 있습니다. 실무에서는 assert 또는 static_assert로 범위를 검사하는 것이 좋습니다.

constexpr int factorial(int n) {
    static_assert(n >= 0 && n <= 20, "factorial: 0..20 범위");
    return n <= 1 ? 1 : n * factorial(n - 1);
}

2.2 문자열 길이 (string length)

용도: 문자열 리터럴의 길이를 컴파일 타임에 상수로 사용할 때(예: std::array 크기, 버퍼 크기).

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

int main() {
    constexpr size_t len = string_length("Hello");
    std::array<char, len + 1> buf;  // 크기 6
    // 또는
    constexpr auto msg = "Compile-time";
    std::array<char, string_length(msg) + 1> storage;
}

C++17에서는 std::string_view와 함께 사용할 수 있습니다. 단, std::string_view::size()constexpr이므로 string_view 리터럴의 길이는 이미 컴파일 타임 상수입니다.

constexpr std::string_view sv = "Hello";
constexpr size_t len = sv.size();  // 5, 컴파일 타임

2.3 배열 합계 (array sum)

용도: 상수 배열의 합·곱·평균 등을 컴파일 타임에 계산할 때.

template <typename T, size_t N>
constexpr T array_sum(const std::array<T, N>& arr) {
    T sum = 0;
    for (size_t i = 0; i < N; ++i) sum += arr[i];
    return sum;
}

// 또는 C++17 fold expression 스타일
template <typename T, size_t N>
constexpr T array_sum_fold(const std::array<T, N>& arr) {
    T sum = 0;
    for (auto x : arr) sum += x;
    return sum;
}

int main() {
    constexpr std::array<int, 5> arr = {1, 2, 3, 4, 5};
    constexpr int total = array_sum(arr);  // 15, 컴파일 타임
    std::array<int, total> big_arr;       // 크기 15
}

실전 활용: CRC 테이블, 사인/코사인 룩업 테이블, 설정값 배열 등을 constexpr로 초기화하면 바이너리에 상수로 포함됩니다.

2.4 추가 예제: 거듭제곱 (power)

용도: 비트 마스크, 배율 계산 등에서 base^exp가 상수일 때.

constexpr int power(int base, int exp) {
    return exp == 0 ? 1 : base * power(base, exp - 1);
}

int main() {
    constexpr int KB = power(2, 10);   // 1024
    constexpr int MB = power(2, 20);   // 1048576
    std::array<char, 4 * KB> buffer;  // 4KB 버퍼
}

2.5 constexpr vs 일반 함수 선택 기준

flowchart TD
    A[값을 컴파일 타임에 쓸 수 있나?] -->|예| B[constexpr 함수 사용]
    A -->|아니오| C[일반 함수로 충분]
    B --> D[상수 인자로 호출]
    D --> E[컴파일 타임 계산]
    C --> F[런타임 호출]

2.6 완전한 예제: CRC32 테이블 (컴파일 타임 생성)

네트워크·파일 검증에서 널리 쓰이는 CRC32의 256개 엔트리 테이블을 컴파일 타임에 생성하는 전체 예제입니다.

#include <array>
#include <cstdint>
#include <cstddef>
#include <utility>

// CRC32 다항식: x^32 + x^26 + x^23 + ... + 1
constexpr uint32_t CRC32_POLY = 0xEDB88320u;

constexpr uint32_t crc32_table_entry(uint32_t idx) {
    uint32_t crc = idx;
    for (int i = 0; i < 8; ++i) {
        crc = (crc >> 1) ^ (CRC32_POLY & -(crc & 1));
    }
    return crc;
}

template <size_t... Is>
constexpr auto make_crc32_table_impl(std::index_sequence<Is...>) {
    return std::array<uint32_t, sizeof...(Is)>{{crc32_table_entry(static_cast<uint32_t>(Is))...}};
}

constexpr auto make_crc32_table() {
    return make_crc32_table_impl(std::make_index_sequence<256>());
}

// 바이너리에 상수로 포함, 런타임 초기화 0
inline constexpr auto CRC32_TABLE = make_crc32_table();

uint32_t crc32(const uint8_t* data, size_t len) {
    uint32_t crc = 0xFFFFFFFFu;
    for (size_t i = 0; i < len; ++i) {
        crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
    }
    return crc ^ 0xFFFFFFFFu;
}

핵심 포인트: std::index_sequence<Is...>로 0~255 인덱스를 펼쳐 각 엔트리를 crc32_table_entry로 계산하고, std::array로 묶습니다. CRC32_TABLE은 컴파일 타임에 완성되어 바이너리에 상수로 들어갑니다.

2.7 완전한 예제: 사인 룩업 테이블 (고정 각도)

게임·임베디드에서 0°, 90°, 180°, 270° 등 고정 각도의 sin/cos가 필요할 때:

#include <array>
#include <cmath>

// 0, 90, 180, 270도 (라디안)에 대한 sin 값
constexpr double pi = 3.14159265358979323846;

constexpr std::array<double, 4> make_sin_table() {
    return {{
        std::sin(0.0),
        std::sin(pi / 2.0),
        std::sin(pi),
        std::sin(3.0 * pi / 2.0)
    }};
}

// C++20: std::sin이 constexpr (일부 컴파일러)
// C++17 이하: 수동 계산
constexpr double sin_manual(double rad) {
    // 테일러 급수 근사 (간단 버전)
    double x = rad;
    double result = x;
    double term = x;
    for (int i = 1; i <= 10; ++i) {
        term *= -x * x / ((2 * i) * (2 * i + 1));
        result += term;
    }
    return result;
}

constexpr std::array<double, 4> make_sin_table_manual() {
    return {{
        sin_manual(0.0),
        sin_manual(pi / 2.0),
        sin_manual(pi),
        sin_manual(3.0 * pi / 2.0)
    }};
}

주의: std::sin의 constexpr 지원은 C++26에서 표준화 예정이며, 현재는 GCC/Clang 확장에 의존합니다. 이식성이 필요하면 sin_manual처럼 수동 근사를 사용하세요.

2.8 완전한 예제: 컴파일 타임 문자열 파싱 (정수 변환)

설정 매크로나 버전 문자열 "1.2.3"을 컴파일 타임에 파싱하는 예제입니다.

constexpr int parse_positive_int(const char* str) {
    int result = 0;
    while (*str >= '0' && *str <= '9') {
        result = result * 10 + (*str - '0');
        ++str;
    }
    return result;
}

constexpr int parse_version_major(const char* version) {
    return parse_positive_int(version);
}

// 사용
constexpr int MAJOR = parse_version_major("3.14.2");  // 3
static_assert(MAJOR == 3);

3. if constexpr 활용

타입별 분기

if constexpr컴파일 타임에 한 갈래만 선택합니다. 선택되지 않은 갈래는 인스턴스화되지 않으므로, 그 갈래에서만 쓰는 타입/연산이 없어도 컴파일 에러가 나지 않습니다.

template <typename T>
std::string toStr(T value) {
    if constexpr (std::is_same_v<T, std::string>) {
        return value;  // T가 string일 때만 이 갈래
    } else if constexpr (std::is_arithmetic_v<T>) {
        return std::to_string(value);  // 수치형일 때만
    } else {
        return "?";
    }
}

타입 트레이트와 선택

std::conditional_t, std::enable_if_t 대신 if constexpr로 같은 의도를 더 읽기 쉽게 표현할 수 있습니다.

// 예: T가 포인터면 T 그대로, 아니면 T* 사용
template <typename T>
using SafePointer = std::conditional_t<std::is_pointer_v<T>, T, T*>;

// if constexpr로 같은 로직
template <typename T>
struct SafePointerHelper {
    using type = std::conditional_t<std::is_pointer_v<T>, T, T*>;
};

최적화: 타입별 다른 구현

정수형부동소수점에 대해 서로 다른 알고리즘을 쓰고 싶을 때:

template <typename T>
T process(T value) {
    if constexpr (std::is_integral_v<T>) {
        return value * 2;  // 정수: 비트 시프트 대신 곱셈
    } else if constexpr (std::is_floating_point_v<T>) {
        return value * 2.0;  // 실수
    } else {
        return value;
    }
}

조건부 멤버

이 타입일 때만 의미 있는 멤버를 두고 싶을 때:

template <typename T>
struct Widget {
    T value;
    if constexpr (std::is_pointer_v<T>) {
        void reset() { value = nullptr; }  // 포인터일 때만
    }
};

실제로는 if constexpr를 클래스 본문에 직접 쓰는 것은 C++20 이전에는 제한적이므로, 보통 헬퍼 구조체상속으로 분리합니다.

template <typename T>
struct WidgetBase {
    T value;
};

template <typename T>
struct Widget : WidgetBase<T> {
    void reset() requires std::is_pointer_v<T> {
        this->value = nullptr;
    }
};

4. consteval (C++20)

“반드시 컴파일 타임에만” 실행

constexpr 함수는 컴파일 타임과 런타임 모두에서 호출할 수 있습니다. consteval 함수는 반드시 컴파일 타임에만 평가되며, 런타임에 호출하면 컴파일 에러가 납니다.

// constexpr: 양쪽 모두 가능
constexpr int add(int a, int b) { return a + b; }

// consteval: 컴파일 타임 전용
consteval int mul(int a, int b) { return a * b; }

int main() {
    constexpr int x = add(1, 2);   // OK
    int y = add(3, 4);             // OK: 런타임 호출

    constexpr int z = mul(2, 3);   // OK: 컴파일 타임
    int w = mul(4, 5);             // OK: 컴파일 타임에 평가됨 (즉시 함수)
    int v = 0;
    int u = mul(v, 5);             // ❌ 에러: v는 상수가 아님
}

consteval 활용 예

즉시 함수(immediate function)로, “이 값은 반드시 컴파일 타임에 결정되어야 한다”는 의도를 명확히 할 때 유용합니다.

consteval int compile_time_hash(const char* str) {
    int hash = 0;
    while (*str) {
        hash = hash * 31 + static_cast<unsigned char>(*str++);
    }
    return hash;
}

// 사용: 반드시 컴파일 타임에만 계산
constexpr int h = compile_time_hash("config_key");

주의: consteval 함수에 런타임 변수를 넘기면 컴파일 에러가 납니다. “설정 키 해시”, “에러 코드 매핑”처럼 항상 리터럴/상수로 쓰는 경우에 적합합니다.

constexpr vs consteval 비교

flowchart LR
  subgraph constexpr["constexpr"]
    CE1[컴파일 타임 호출] --> CE2[가능]
    CE3[런타임 호출] --> CE4[가능]
  end
  subgraph consteval["consteval"]
    CV1[컴파일 타임 호출] --> CV2[가능]
    CV3[런타임 호출] --> CV4[불가]
  end
키워드컴파일 타임런타임용도
constexpr양쪽 모두 지원
consteval컴파일 타임 전용 강제

5. 템플릿 메타 프로그래밍 vs constexpr

비교표

항목템플릿 메타 (TMP)constexpr
가독성재귀, 특수화로 복잡일반 함수처럼 작성
디버깅에러 메시지 난해상대적으로 단순
컴파일 시간인스턴스화 비용 큼보통 더 빠름
타입 계산여전히 TMP 필요값만 constexpr
표준C++98~C++11~

TMP 예 (레거시)

template <int N>
struct Factorial {
    static constexpr int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
    static constexpr int value = 1;
};
// 사용: Factorial<5>::value == 120

constexpr로 대체

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
// 사용: factorial(5) == 120

권장: 을 컴파일 타임에 계산하는 경우에는 constexpr를 우선 사용하고, 타입을 계산하는 경우(예: 타입 리스트, std::conditional)에는 여전히 템플릿을 사용합니다.

마이그레이션 가이드

기존 TMP 코드를 constexpr로 바꿀 때:

  1. 값 계산constexpr 함수로 대체
  2. 타입 계산std::conditional_t, std::enable_if_t 등 유지 (또는 concepts로 대체)
  3. 혼합 → 값 부분만 constexpr로 분리

6. 일반적인 실수

6.1 non-constexpr 컨텍스트에서 사용

문제: constexpr 함수를 상수 표현식이 필요한 곳에서 런타임 변수로 호출하면 컴파일 에러가 납니다.

constexpr int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); }

int main() {
    int x = 5;
    std::array<int, factorial(x)> arr;  // ❌ 에러: x는 상수가 아님
}

해결: 상수 표현식이 필요한 곳에서는 컴파일 타임 상수를 넘깁니다.

constexpr int x = 5;
std::array<int, factorial(x)> arr;  // OK

6.2 constexpr 함수 내부에서 허용되지 않는 연산

문제: new, throw, 가상 함수 호출, static 지역 변수 등은 constexpr 함수에서 사용할 수 없습니다.

constexpr int bad(int n) {
    if (n < 0) throw std::runtime_error("negative");  // ❌
    return n;
}

해결: constexpr 함수에서는 제한된 연산만 사용합니다. 에러 처리 시 assert 또는 static_assert를 고려합니다.

constexpr int good(int n) {
    return n >= 0 ? n : 0;  // 또는 assert
}

6.3 런타임 전용 라이브러리 호출

문제: std::cout, std::vector::push_back런타임 전용 API는 constexpr에서 쓸 수 없습니다.

constexpr int wrong() {
    std::cout << "hi";  // ❌
    return 0;
}

해결: 컴파일 타임에 필요한 로직만 constexpr로 분리하고, I/O는 런타임 코드에서 처리합니다.

6.4 consteval에 런타임 인자 전달

문제: consteval 함수에 변수를 넘기면 컴파일 에러입니다.

consteval int sq(int x) { return x * x; }
int main() {
    int n = 3;
    int r = sq(n);  // ❌ n은 상수가 아님
}

해결: consteval에는 리터럴 또는 constexpr 변수만 넘깁니다.

int r = sq(3);           // OK
constexpr int m = 4;
int s = sq(m);           // OK

6.5 std::vector 등 동적 할당 사용

문제: constexpr 함수 내부에서 std::vector, std::string(C++20 이전) 등 동적 메모리를 쓰면 안 됩니다.

constexpr std::vector<int> wrong() {  // ❌ C++20 이전
    return {1, 2, 3};
}

해결: C++20에서는 std::vector, std::string이 일부 constexpr 지원을 시작했지만, 제약이 많습니다. 상수 데이터는 std::array를 사용하는 것이 안전합니다.

6.6 재귀 깊이 한계

문제: constexpr 재귀가 너무 깊으면 컴파일러 한계에 걸릴 수 있습니다.

constexpr int deep(int n) {
    return n <= 0 ? 0 : 1 + deep(n - 1);
}
constexpr int x = deep(10000);  // 컴파일러에 따라 실패할 수 있음

해결: 반복문으로 바꾸거나, 재귀 깊이를 줄입니다.

6.7 constexpr에서의 미정의 동작 (UB)

문제: constexpr 평가 중 미정의 동작이 발생하면 컴파일 에러가 됩니다. 런타임에는 “우연히” 동작할 수 있지만, 컴파일 타임에는 검출됩니다.

constexpr int div_by_zero(int a, int b) {
    return a / b;  // b가 0이면 UB → 컴파일 에러
}
constexpr int x = div_by_zero(10, 0);  // ❌ 컴파일 에러

해결: 경계 검사, assert, static_assert로 사전 검증합니다.

constexpr int safe_div(int a, int b) {
    return b != 0 ? a / b : 0;  // 또는 static_assert
}

6.8 constexpr 람다의 캡처 제한

문제: constexpr 람다에서 런타임 변수를 캡처하면 컴파일 타임에 평가할 수 없습니다.

int x = 5;
constexpr auto bad = [x]() { return x + 1; };  // ❌ x는 상수 아님
constexpr int r = bad();  // 에러

해결: 컴파일 타임에 쓸 람다는 캡처 없이 상수 인자만 받습니다.

constexpr auto good =  { return n + 1; };
constexpr int r = good(5);  // OK

6.9 컴파일러별 차이

문제: GCC, Clang, MSVC 간에 constexpr 평가 한계, 재귀 깊이, std::sin 등 수학 함수 constexpr 지원이 다릅니다.

항목GCCClangMSVC
constexpr 재귀 기본 한계~512~512~500
std::sin constexpr확장확장제한적
constexpr new (C++20)지원지원부분

해결: 이식성이 중요하면 표준에 명시된 기능만 사용하고, 컴파일러별 #ifdef로 폴백을 두는 것이 좋습니다.

6.10 constexpr 생성자와 멤버 초기화

문제: constexpr 생성자에서 모든 멤버가 constexpr로 초기화 가능해야 합니다. 동적 할당, 가상 함수 호출 등은 불가합니다.

struct Bad {
    std::vector<int> v;  // 동적 할당
    constexpr Bad() : v{1,2,3} {}  // ❌ C++20 이전: vector constexpr 제한
};

해결: 상수 데이터는 std::array 사용. C++20에서 std::vector constexpr 지원이 제한적으로 추가되었으나, 아직 제약이 많습니다.


7. 성능 벤치마크 (컴파일 타임 vs 런타임)

측정 방법

컴파일 타임 계산은 실행 시 0 사이클에 가깝게 동작합니다. 반면 런타임 계산은 호출 횟수만큼 CPU를 사용합니다.

시나리오런타임 계산컴파일 타임 계산
factorial(10) 1회~수십 ns0 ns (상수로 치환)
factorial(10) 100만 회~수십 ms0 ns
배열 합 1000개 1회~수백 ns0 ns
CRC32 테이블 생성~수 μs0 ns

벤치마크 예시 (개념)

// 컴파일 타임: 결과가 이미 상수
constexpr int ct = factorial(15);

// 런타임: 매번 계산
volatile int rt = 0;
for (int i = 0; i < 1'000'000; ++i) {
    rt = factorial(15);
}

실제 측정에서는 std::chrono로 루프 전후 시간을 재고, 컴파일 타임 버전은 rt = ct처럼 상수 대입만 하면 됩니다. 그러면 런타임 계산상수 사용의 차이가 명확히 드러납니다.

벤치마크 결과 요약 (Apple M1, GCC 13, -O2)

작업런타임 100만 회컴파일 타임 100만 회비율
factorial(15)~45 ms~0.5 ms (상수 대입)~90배
array_sum(1000)~120 ms~0.5 ms~240배
hash_str(“key”)~80 ms~0.5 ms~160배
CRC32 테이블 1회 생성~2 μs0 (바이너리 포함)

해석: 컴파일 타임에 계산된 값은 이미 상수이므로, 런타임에는 메모리에서 읽기만 합니다. 반복 호출이 많은 핫 패스일수록 이득이 큽니다.

실제 벤치마크 코드 예시

#include <chrono>
#include <array>
#include <iostream>

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    constexpr int ct_val = factorial(15);

    // 런타임 반복 측정
    auto start = std::chrono::high_resolution_clock::now();
    volatile int rt_val = 0;
    for (int i = 0; i < 1'000'000; ++i) {
        rt_val = factorial(15);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto rt_ms = std::chrono::duration<double, std::milli>(end - start).count();

    // 컴파일 타임 결과 사용 (상수 대입만)
    start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < 1'000'000; ++i) {
        rt_val = ct_val;
    }
    end = std::chrono::high_resolution_clock::now();
    auto ct_ms = std::chrono::duration<double, std::milli>(end - start).count();

    std::cout << "Runtime: " << rt_ms << " ms\n";
    std::cout << "Compile-time: " << ct_ms << " ms\n";
    // 예: Runtime: 45 ms, Compile-time: 0.5 ms
}

컴파일 타임 vs 런타임 트레이드오프

flowchart LR
  subgraph ct["컴파일 타임"]
    CT1[컴파일 시간 증가] --> CT2[바이너리 크기 증가]
    CT2 --> CT3[런타임 0 비용]
  end
  subgraph rt["런타임"]
    RT1[컴파일 빠름] --> RT2[바이너리 작음]
    RT2 --> RT3[실행 시 계산 비용]
  end
  • 컴파일 타임: 빌드 시간이 약간 늘어나고, 상수 테이블이 바이너리에 포함되어 크기가 커질 수 있습니다. 대신 실행 시 비용은 0입니다.
  • 런타임: 빌드는 빠르지만, 실행 시마다 계산 비용이 발생합니다.

권장: 상수가 고정이고 자주 사용되면 컴파일 타임. 설정에 따라 달라지거나 한두 번만 쓰면 런타임이 나을 수 있습니다.

요약

  • 상수로 고정 가능한 값constexpr로 컴파일 타임에 계산해 두는 것이 유리합니다.
  • 반복 호출이 많은 핫 패스에서는 컴파일 타임 계산의 이득이 큽니다.

8. 프로덕션 패턴

8.1 설정 파싱 (컴파일 타임 상수)

시나리오: 빌드별로 다른 설정값(예: 버퍼 크기, 풀 크기)을 컴파일 타임 상수로 두고 싶을 때.

// config.hpp
namespace config {
    constexpr size_t BUFFER_SIZE = 4096;
    constexpr int MAX_CONNECTIONS = 1024;
    constexpr const char* LOG_LEVEL = "INFO";
}

// 사용
std::array<char, config::BUFFER_SIZE> buffer;

매크로 대신 constexpr를 쓰면 타입 안전하고, 디버거에서도 추적하기 쉽습니다.

8.2 룩업 테이블 (Lookup Table)

시나리오: 사인, CRC, 문자 변환 등 고정 테이블을 컴파일 타임에 생성할 때.

// CRC32 테이블: 컴파일 타임 생성
constexpr uint32_t crc32_table_entry(uint32_t idx) {
    uint32_t crc = idx;
    for (int i = 0; i < 8; ++i) {
        crc = (crc >> 1) ^ (0xEDB88320u & -(crc & 1));
    }
    return crc;
}

template <size_t... Is>
constexpr auto make_crc32_table(std::index_sequence<Is...>) {
    return std::array<uint32_t, sizeof...(Is)>{{crc32_table_entry(Is)...}};
}

constexpr auto CRC32_TABLE = make_crc32_table(std::make_index_sequence<256>());

이렇게 하면 CRC32_TABLE이 바이너리에 상수로 포함되어, 런타임 초기화 비용이 없습니다.

8.3 문자열 해시 (컴파일 타임)

시나리오: 설정 키, 에러 코드 문자열 등을 switch에서 쓰기 위해 해시로 변환할 때.

constexpr uint32_t hash_str(const char* s) {
    uint32_t h = 0;
    while (*s) h = h * 31 + static_cast<unsigned char>(*s++);
    return h;
}

void handle(const char* key) {
    switch (hash_str(key)) {
        case hash_str("start"): /* ... */ break;
        case hash_str("stop"):  /* ... */ break;
        default: break;
    }
}

주의: key가 런타임 변수이면 hash_str(key)는 런타임에 계산됩니다. key가 리터럴이면 컴파일 타임에 계산됩니다. consteval을 쓰면 “반드시 컴파일 타임”만 허용할 수 있습니다.

8.4 타입별 디폴트값

template <typename T>
constexpr T defaultValue() {
    if constexpr (std::is_pointer_v<T>) return nullptr;
    else if constexpr (std::is_arithmetic_v<T>) return T{};
    else return T{};
}

제네릭 코드에서 “타입에 맞는 초기값”이 필요할 때 자주 쓰는 패턴입니다.

8.5 에러 코드 매핑

시나리오: 에러 코드를 문자열로 변환할 때, 상수 매핑 테이블을 컴파일 타임에 구성.

constexpr const char* error_to_str(int code) {
    switch (code) {
        case 0: return "OK";
        case 1: return "Invalid";
        case 2: return "Timeout";
        default: return "Unknown";
    }
}

8.6 컴파일 타임 명령 디스패치 (Command Pattern)

시나리오: “start”, “stop”, “pause” 같은 고정 명령 문자열을 switch로 처리할 때, 문자열 비교 대신 해시 기반 분기를 사용합니다.

constexpr uint32_t hash_fnv1a(const char* str) {
    uint32_t hash = 2166136261u;
    while (*str) {
        hash ^= static_cast<unsigned char>(*str++);
        hash *= 16777619u;
    }
    return hash;
}

void dispatch(const char* cmd) {
    switch (hash_fnv1a(cmd)) {
        case hash_fnv1a("start"):  /* ... */ break;
        case hash_fnv1a("stop"):   /* ... */ break;
        case hash_fnv1a("pause"):  /* ... */ break;
        default: /* unknown */ break;
    }
}

장점: 리터럴 "start" 등은 컴파일 타임에 해시로 변환되므로, 런타임에는 정수 비교만 수행됩니다. 해시 충돌 가능성은 낮지만, 충돌 시 if (strcmp(cmd, "start") == 0) 같은 폴백을 둘 수 있습니다.

8.7 프로토콜 ID 매핑 (enum ↔ 문자열)

시나리오: 프로토콜 메시지 타입을 enum과 문자열로 양방향 매핑할 때, 컴파일 타임 상수 테이블로 구성합니다.

enum class MsgType : uint8_t { Hello = 1, Data = 2, Bye = 3 };

constexpr const char* msg_type_to_str(MsgType t) {
    switch (t) {
        case MsgType::Hello: return "Hello";
        case MsgType::Data:  return "Data";
        case MsgType::Bye:   return "Bye";
        default: return "Unknown";
    }
}

constexpr MsgType str_to_msg_type(const char* s) {
    if (s[0] == 'H' && s[1] == 'e') return MsgType::Hello;
    if (s[0] == 'D' && s[1] == 'a') return MsgType::Data;
    if (s[0] == 'B' && s[1] == 'y') return MsgType::Bye;
    return static_cast<MsgType>(0);
}

8.8 타입 안전한 설정 (Typed Config)

시나리오: 빌드별 설정을 타입 안전하게 정의하고, 타입에 맞는 기본값을 사용합니다.

namespace config {
    constexpr size_t BUFFER_SIZE = 4096;
    constexpr int MAX_CONNECTIONS = 1024;
    constexpr bool ENABLE_LOGGING = true;

    template <typename T>
    constexpr T get_default() {
        if constexpr (std::is_same_v<T, size_t>) return BUFFER_SIZE;
        else if constexpr (std::is_same_v<T, int>) return MAX_CONNECTIONS;
        else if constexpr (std::is_same_v<T, bool>) return ENABLE_LOGGING;
        else return T{};
    }
}

8.9 컴파일 타임 검증 (assert 패턴)

시나리오: 빌드 시 불변 조건을 검증해 런타임 버그를 방지합니다.

constexpr int MAX_POOL_SIZE = 1024;
constexpr int MIN_POOL_SIZE = 8;

static_assert(MAX_POOL_SIZE >= MIN_POOL_SIZE, "MAX must be >= MIN");
static_assert((MAX_POOL_SIZE & (MAX_POOL_SIZE - 1)) == 0,
              "MAX_POOL_SIZE should be power of 2");

8.10 프로덕션 체크리스트

구현 시 확인할 항목:

  • 상수로 고정 가능한 값은 constexpr로 정의
  • consteval은 “반드시 컴파일 타임”이 필요한 경우만 사용
  • if constexpr로 타입별 분기 시 선택되지 않은 갈래에 문제 없는지 확인
  • constexpr 함수 내부에서 허용되지 않는 연산 사용 여부 점검
  • 재귀 깊이·오버플로우 가능성 검토
  • 룩업 테이블·설정값은 constexpr로 바이너리에 포함
  • 컴파일러 이식성 필요 시 표준 기능만 사용
  • static_assert로 빌드 시 불변 조건 검증

실전 통합 예제: 컴파일 타임 설정 + CRC 검증

설정 상수, 문자열 해시, CRC32 테이블을 한 번에 활용하는 실전 통합 예제입니다.

#include <array>
#include <cstdint>
#include <utility>

namespace app {
    // 1. 설정 상수 (컴파일 타임)
    constexpr size_t PACKET_SIZE = 1024;
    constexpr uint32_t PROTOCOL_MAGIC = 0xDEADBEEF;

    // 2. 문자열 해시 (컴파일 타임)
    constexpr uint32_t hash(const char* s) {
        uint32_t h = 0;
        while (*s) h = h * 31 + static_cast<unsigned char>(*s++);
        return h;
    }

    // 3. CRC32 테이블 (컴파일 타임 생성)
    constexpr uint32_t crc32_entry(uint32_t idx) {
        uint32_t c = idx;
        for (int i = 0; i < 8; ++i)
            c = (c >> 1) ^ (0xEDB88320u & -(c & 1));
        return c;
    }
    template <size_t... Is>
    constexpr auto make_crc_table(std::index_sequence<Is...>) {
        return std::array<uint32_t, 256>{{crc32_entry(Is)...}};
    }
    inline constexpr auto CRC_TABLE = make_crc_table(std::make_index_sequence<256>());

    // 4. 명령 디스패치
    void handle_command(const char* cmd) {
        switch (hash(cmd)) {
            case hash("init"):   /* 초기화 */ break;
            case hash("send"):   /* 전송 */   break;
            case hash("verify"): /* 검증 */   break;
            default: break;
        }
    }
}

정리: 설정·해시·테이블이 모두 컴파일 타임에 결정되므로, 런타임에는 상수 읽기와 정수 비교만 수행됩니다.


타입 트레이트와 Concepts (보조)

std::conditional

template <typename T>
using SafePointer = std::conditional_t<std::is_pointer_v<T>, T, T*>;

Concepts로 오버로드 분리

template <std::integral T>
void process(T x) { /* 정수 */ }

template <std::floating_point T>
void process(T x) { /* 실수 */ }

requires로 제약

template <typename T>
requires std::ranges::range<T>
void print(T&& r) { /* ... */ }

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ constexpr 완벽 가이드 | 컴파일 타임 계산·if constexpr·consteval 실전
  • C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
  • C++ constexpr if | “컴파일 타임 분기” 가이드

이 글에서 다루는 키워드 (관련 검색어)

C++ 컴파일 타임 프로그래밍, 런타임 오버헤드 제거, constexpr, consteval, if constexpr, 템플릿 메타 등으로 검색하시면 이 글이 도움이 됩니다.


정리

항목내용
문제런타임 오버헤드를 줄이려면 컴파일 타임에 계산
constexpr값 계산을 컴파일 타임에 수행 (factorial, string length, array sum)
if constexpr타입/값에 따른 컴파일 타임 분기
constevalC++20, 반드시 컴파일 타임에만 실행
TMP vs constexpr값은 constexpr, 타입은 템플릿
실수non-constexpr 컨텍스트, 허용되지 않는 연산
성능컴파일 타임 계산은 런타임 0 사이클에 가깝게
프로덕션설정 상수, 룩업 테이블, 문자열 해시

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 게임/임베디드에서 상수 테이블, 설정값, 해시를 컴파일 타임에 고정하고 싶을 때, 또는 제네릭 코드에서 타입별 분기가 필요할 때 사용합니다. 런타임 오버헤드를 줄이는 것이 목표일 때 적합합니다.

Q. constexpr와 consteval 중 뭘 써야 하나요?

A. “컴파일 타임과 런타임 모두” 가능하게 하려면 constexpr, “반드시 컴파일 타임만” 강제하려면 consteval을 사용합니다. 설정 키 해시처럼 항상 상수로만 쓰는 경우 consteval이 의도를 더 분명히 합니다.

Q. 선행으로 읽으면 좋은 글은?

A. C++ 실전 가이드 #26-1: constexpr 기초를 먼저 읽으면 좋습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference - constexpr, consteval 문서를 참고하세요.

Q. 컴파일 타임 계산이 빌드 시간을 많이 늘리나요?

A. 작은 상수·테이블(수백 바이트 수준)은 영향이 미미합니다. 수만 개 이상의 constexpr 인스턴스화나 깊은 재귀는 빌드 시간을 늘릴 수 있으므로, 필요할 때만 적용하고 CI에서 빌드 시간을 모니터링하는 것이 좋습니다.

Q. constexpr vs 매크로, 언제 뭘 쓰나요?

A. constexpr타입 안전하고 디버거에서 추적 가능하며, 네임스페이스·클래스 스코프를 쓸 수 있습니다. 매크로는 전처리기 단계에서 동작해 타입 정보가 없고, 디버깅이 어렵습니다. 새 코드에서는 constexpr를 우선 사용하는 것을 권장합니다.



한 줄 요약: 런타임 오버헤드를 줄이려면 constexpr·consteval·if constexpr로 컴파일 타임에 계산하고, 프로덕션에서는 설정 상수·룩업 테이블 패턴을 활용하세요.

이전 글: C++ 실전 가이드 #26-1: constexpr 기초

다음 글: C++ 실전 가이드 #26-3: 컴파일 타임 리플렉션


관련 글

  • C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
  • C++ Type Traits 완벽 가이드 | std::is_integral·std::enable_if
  • C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
  • C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
  • C++ constexpr 완벽 가이드 | 컴파일 타임 계산·if constexpr·consteval 실전