constexpr 기초부터 C++26까지: constexpr 함수·생성자, const와의 차이, 확장된 규칙
들어가며: “컴파일 타임에 계산하고 싶어요”
문제 시나리오
프로젝트를 진행하다 보면 이런 상황을 자주 겪는다:
- 배열 크기를 런타임 변수로 넘기려 했는데
int arr[n]에서 컴파일 에러가 난다 - CRC32, 해시 테이블 같은 값들을 매번 런타임에 계산하는데, 값이 고정되어 있으면 아깝다
- 설정값(버퍼 크기, 타임아웃 등)을 코드에 하드코딩하지 않으며, 컴파일 시점에 한 번만 계산해 두고 싶다
- 템플릿 인자로
std::array<int, 128>처럼 상수를 넘겨야 하는데,getBufferSize()같은 함수 결과를 쓸 수 없다
추가 문제 시나리오
프로토콜 버퍼 크기 고정
네트워크 패킷 헤더가 항상 16바이트이며, 페이로드 최대 크기가 4096바이트일 때, std::array<uint8_t, 16 + 4096>처럼 컴파일 타임에 크기를 정해야 합니다. 16 + 4096을 매크로나 매직 넘버로 쓰지 않고 constexpr 함수로 계산하면 유지보수가 쉬워집니다.
Base64 인코딩 테이블
Base64 인코딩용 64개 문자 룩업 테이블은 실행 시점에 바뀌지 않습니다. constexpr로 컴파일 타임에 테이블을 생성하면 런타임 초기화 비용이 사라지고, .rodata 섹션에 상수로 들어가 메모리 효율도 좋아집니다.
JSON 스키마 검증 상수
최대 필드 개수, 최대 문자열 길이 등이 JSON 스키마에 정의되어 있을 때, 이 값들을 컴파일 타임 상수로 파싱해 std::array 크기나 static_assert에 사용하면, 스키마 변경 시 컴파일 단계에서 오류를 잡을 수 있습니다.
임베디드 메모리 맵
레지스터 오프셋, 메모리 영역 크기, 인터럽트 벡터 개수 등이 보드별로 고정되어 있을 때, constexpr로 보드 타입별 상수를 정의하면 switch 케이스나 템플릿 인자에 바로 쓸 수 있습니다.
단위 테스트용 고정 시드
테스트 재현성을 위해 난수 시드를 고정해야 할 때, constexpr로 시드를 계산해 두면 테스트마다 동일한 시퀀스를 보장할 수 있고, 시드 계산이 컴파일 타임에 끝나 런타임 오버헤드가 없습니다.
C++에서는 배열 크기, 템플릿 비타입 인자, switch 케이스 등이 상수 식(constant expression)이어야 합니다. 즉, 값이 컴파일 시점에 이미 결정되어 있어야 합니다. 런타임에 계산된 값은 이런 곳에 쓸 수 없었다.
constexpr을 사용하면 “이 함수/변수는 컴파일 타임에 계산 가능하다”고 선언할 수 있어서, 상수 식이 필요한 곳에서 그 결과를 그대로 사용할 수 있습니다. constexpr 함수는 인자가 상수 식이면 컴파일 타임에 호출되고, 배열 크기·템플릿 인자·switch 케이스 등에 바로 활용할 수 있습니다. C++14부터는 제약이 완화되어 루프·여러 문이 가능하고, C++20에서는 더 많은 표준 라이브러리가 constexpr로 쓸 수 있어서, 점점 많은 계산을 컴파일 타임으로 옮길 수 있습니다.
목표
- constexpr 변수와 constexpr 함수 문법
- C++14/20에서 완화된 제약
- if constexpr 개요
- 일반적인 에러와 프로덕션 패턴
constexpr 평가 시점 시각화
flowchart TD
A[constexpr 함수 호출] --> B{인자가 상수 식?}
B -->|예| C[컴파일 타임에 평가]
B -->|아니오| D[런타임에 평가]
C --> E[배열 크기, 템플릿 인자 등에 사용 가능]
D --> F[일반 함수처럼 동작]
constexpr vs 매크로 비교
flowchart LR
subgraph macro[매크로]
M1[전처리기 치환] --> M2[타입 검사 없음]
M2 --> M3[디버깅 어려움]
end
subgraph constexpr[constexpr]
C1[컴파일러 평가] --> C2[타입 검사]
C2 --> C3[네임스페이스·스코프]
C3 --> C4[오버로딩·템플릿 조합]
end
이 글을 읽으면
- constexpr로 컴파일 타임 상수를 정의할 수 있습니다.
- constexpr 함수의 제약과 확장을 이해할 수 있습니다.
- 배열 크기·템플릿 인자에 활용할 수 있습니다.
- constexpr vs const 차이를 명확히 구분할 수 있습니다.
- 실무에서 자주 쓰는 패턴(룩업 테이블, 설정 파싱)을 적용할 수 있습니다.
개념을 잡는 비유
템플릿 인자 자리는 붕어빵 틀의 칸 수가 정해지듯, 컴파일 시점에 크기·상수가 박혀 있어야 하는 경우가 많습니다. constexpr·컴파일 타임 계산은 그 값을 미리 찍어내어, 배열 크기와 static_assert 같은 곳에 그대로 얹을 수 있게 해 줍니다.
constexpr 변수
컴파일 타임 상수
constexpr 변수는 컴파일 시점에 값이 정해져야 합니다. 따라서 int arr[MAX]처럼 배열 크기나 std::array<int, MAX>의 템플릿 인자로 그대로 쓸 수 있습니다. const만 쓰면 런타임에 초기화된 “읽기 전용” 변수일 뿐이라 배열 크기로 쓸 수 없으며, constexpr을 쓰면 “이 값은 컴파일 타임에 알려진 상수”임을 컴파일러가 보장합니다. 버퍼 크기·테이블 크기처럼 반드시 상수 식이 필요한 곳에서는 constexpr을 사용하는 것이 좋습니다.
constexpr int MAX = 100;
constexpr double PI = 3.14159265358979;
int arr[MAX]; // OK: 상수 식
std::array<int, MAX> a; // OK
constexpr 변수 초기화 규칙
constexpr 변수는 반드시 리터럴이나 다른 constexpr로 초기화해야 합니다. 런타임 함수 호출 결과로는 초기화할 수 없습니다.
constexpr int a = 42; // OK: 리터럴
constexpr int b = a + 1; // OK: 다른 constexpr
constexpr int c = add(2, 3); // OK: add가 constexpr이고 인자가 상수
int runtime_val = getValue();
constexpr int d = runtime_val; // 컴파일 에러!
constexpr 함수 상세
기본 개념
constexpr 함수는 인자가 상수 식이면 컴파일 타임에 호출할 수 있습니다. add(3, 5)는 리터럴이므로 x는 컴파일 시점에 8로 고정되고, int arr[add(2, 3)]처럼 배열 크기로 쓸 수 있습니다. 런타임에 add(i, j)처럼 호출해도 되고, 그때는 일반 함수처럼 동작합니다. 한 함수로 “상수 식용”과 “런타임용”을 같이 쓸 수 있는 것이 constexpr의 장점입니다.
#include <iostream>
constexpr int add(int a, int b) {
return a + b;
}
int main() {
constexpr int x = add(3, 5); // 컴파일 타임에 8
std::cout << x << "\n";
return 0;
}
실행 결과: 8이 한 줄 출력됩니다.
상세 예시 1: 팩토리얼 (C++14)
C++14부터 constexpr 함수 안에 if, 여러 return, 재귀 등이 허용됩니다. factorial(5)는 5 * factorial(4) → … → 1까지 컴파일 타임에 계산되어 f5는 120이 됩니다. n이 상수이면 컴파일 타임에, 변수이면 런타임에 계산되므로, 같은 함수를 std::array<int, factorial(5)> 같은 곳에도 쓸 수 있습니다.
constexpr unsigned long long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int main() {
constexpr auto f5 = factorial(5); // 120, 컴파일 타임
std::array<int, factorial(5)> arr; // 크기 120
return 0;
}
상세 예시 2: 피보나치
과거에는 template <int N> struct Fib로 재귀 템플릿을 썼지만, constexpr 함수로 같은 계산을 하면 읽기와 유지보수가 쉽습니다.
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 템플릿 메타 대체
template <int N>
struct Fib {
static constexpr int value = fib(N);
};
int main() {
constexpr int f10 = fib(10); // 55
constexpr int f20 = Fib<20>::value; // 6765
return 0;
}
상세 예시 3: 2의 거듭제곱 (루프)
C++14에서는 constexpr 함수 안에 while, for 루프가 허용됩니다. nextPowerOfTwo(100)은 100 이상인 가장 작은 2의 거듭제곱(128)을 반환합니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
// 사용 예
std::array<int, nextPowerOfTwo(100)> buf; // 크기 128
상세 예시 4: 문자열 길이 (C++14)
컴파일 타임에 문자열 리터럴의 길이를 구할 수 있습니다.
constexpr size_t strLen(const char* s) {
size_t len = 0;
while (s[len] != '\0') ++len;
return len;
}
constexpr size_t LEN = strLen("hello"); // 5
std::array<char, strLen("hello") + 1> buf;
상세 예시 5: 조건부 계산
constexpr 함수 안에서 if를 사용해 분기할 수 있습니다.
constexpr int clamp(int value, int min_val, int max_val) {
if (value < min_val) return min_val;
if (value > max_val) return max_val;
return value;
}
constexpr int c = clamp(150, 0, 100); // 100
상세 예시 6: CRC32 룩업 테이블 (완전한 예제)
CRC32는 0–255 각 바이트에 대한 256개 테이블 항목이 필요합니다. constexpr로 컴파일 타임에 전체 테이블을 생성할 수 있습니다.
#include <array>
#include <cstdint>
#include <utility>
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(static_cast<uint32_t>(Is))...}};
}
constexpr auto CRC32_TABLE = make_crc32_table(std::make_index_sequence<256>{});
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;
}
장점: CRC32_TABLE은 컴파일 타임에 한 번만 계산되고, 바이너리의 .rodata에 상수로 저장됩니다.
상세 예시 7: 컴파일 타임 문자열 해시
문자열 리터럴을 컴파일 타임에 해시해 switch 분기에 사용할 수 있습니다.
constexpr unsigned long long hash_fnv1a(const char* str) {
unsigned long long hash = 14695981039346656037ULL;
while (*str) {
hash ^= static_cast<unsigned long long>(*str++);
hash *= 1099511628211ULL;
}
return hash;
}
constexpr auto CMD_HASH = hash_fnv1a("start");
void handle_command(const char* cmd) {
switch (hash_fnv1a(cmd)) {
case CMD_HASH:
break;
default:
break;
}
}
constexpr 변수와 생성자
리터럴 타입
constexpr 변수로 쓰려면 타입이 리터럴 타입(literal type)이어야 합니다. 기본 타입(int, double 등), 배열, constexpr 생성자를 가진 사용자 정의 타입 등이 리터럴 타입입니다. constexpr 생성자를 두면 사용자 정의 타입도 리터럴 타입이 될 수 있습니다.
constexpr 생성자 예시
struct Point {
int x, y;
constexpr Point(int x, int y) : x(x), y(y) {}
constexpr int sum() const { return x + y; }
};
constexpr Point p(1, 2);
constexpr int s = p.sum(); // 3
constexpr 생성자 제약
- 생성자 본문은 비어 있거나, 다른 constexpr 생성자/함수만 호출해야 함
- 모든 멤버를 constexpr로 초기화 가능해야 함
- 가상 상속 불가, 가상 함수 호출 불가 (C++20 이전)
struct Vec3 {
float x, y, z;
constexpr Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
constexpr float lengthSq() const {
return x * x + y * y + z * z;
}
};
constexpr Vec3 v(1.0f, 0.0f, 0.0f);
constexpr float lenSq = v.lengthSq(); // 1.0
consteval (C++20): 반드시 컴파일 타임에
constexpr 함수는 인자가 상수면 컴파일 타임에, 아니면 런타임에 평가될 수 있습니다. C++20의 consteval 함수(즉시 함수)는 호출될 때마다 반드시 컴파일 타임에 평가되어야 하고, 그럴 수 없으면 컴파일 에러입니다.
constexpr int add(int a, int b) { return a + b; }
consteval int mul(int a, int b) { return a * b; }
int main() {
int i = 1, j = 2;
int y = add(i, j); // OK: 런타임 호출
constexpr int z = mul(2, 3); // OK: 컴파일 타임
int w = mul(3, 4); // OK: 인자가 상수라 컴파일 타임에 평가되고 결과만 대입됨
// int v = mul(i, j); // ❌ 에러: i, j는 상수 식이 아님
}
int w = mul(3, 4);처럼 결과를 평범한 변수에 넣어도 인자가 상수면 문제없습니다. 문자열 리터럴도 상수 식이므로 consteval 해시 함수에 "secret_key"를 넘기는 것은 가능하고, 막히는 것은 런타임에 만들어진 문자열(std::string 변수, 입력값)입니다.
consteval이 맞는 곳은 “런타임에 계산되면 곤란한” 값입니다. 문자열 해시로 만든 switch 레이블, 포맷 문자열 검사(std::format이 C++20에서 포맷 문자열을 컴파일 타임에 검사하는 방식), 단위 변환 상수처럼 실수로 런타임 계산이 섞이는 것을 막고 싶을 때 씁니다. 반대로 흔한 오해가 “consteval로 만든 값은 바이너리에 노출되지 않는다”는 것입니다. 컴파일 타임에 계산된 결과는 상수로 실행 파일 안에 그대로 들어가므로, 암호화 키나 비밀값을 숨기는 수단이 되지 못합니다. 해시로 바꾼 값이라도 원본 문자열이 짧으면 역산이 가능하므로, 비밀값은 컴파일 타임 기법이 아니라 배포 시점의 설정·비밀 저장소로 다뤄야 합니다.
constexpr vs const 비교
핵심 차이
| 구분 | const | constexpr |
|---|---|---|
| 의미 | ”변경하지 않음" | "컴파일 타임에 계산 가능” |
| 초기화 시점 | 런타임 가능 | 반드시 컴파일 타임 |
| 상수 식 사용 | 불가 (런타임 초기화 시) | 가능 |
| 배열 크기 | 불가 (런타임 초기화 시) | 가능 |
구체적 예시
int getValue() { return 42; }
void example() {
const int a = 10; // OK: 리터럴로 초기화
const int b = getValue(); // OK: 런타임 초기화
int arr1[a]; // OK: a는 컴파일 타임 상수
int arr2[b]; // 컴파일 에러! b는 런타임 값
constexpr int c = 10; // OK
constexpr int d = getValue(); // 컴파일 에러! getValue()는 constexpr 아님
std::array<int, c> arr3; // OK
std::array<int, d> arr4; // (d가 에러이므로 불가)
}
const + constexpr 조합
constexpr 변수는 기본적으로 const이기도 합니다. 명시적으로 쓰려면:
constexpr int x = 42; // 이미 const이므로 수정 불가
constexpr const int* p = &x; // constexpr 포인터, 가리키는 대상도 const
언제 무엇을 쓸까?
- 배열 크기, 템플릿 인자, switch 케이스 →
constexpr - 런타임에 한 번 설정 후 변경하지 않을 값 →
const - 컴파일 타임에 계산된 값을 여러 곳에서 쓸 때 →
constexpr
C++14/20 확장
C++14
- 여러 return, 루프, 지역 변수 허용
- constexpr 멤버 함수에서 멤버 수정 가능 (C++14)
void반환 타입 불가 → C++14에서 가능
C++20
- constexpr 안에서 동적 할당, try, 가상 호출 등 더 많은 기능 허용
- consteval: 반드시 컴파일 타임에만 평가되는 함수
std::vector,std::string등 많은 표준 라이브러리가 constexpr 지원
if constexpr
if constexpr(조건)은 조건이 컴파일 타임 상수일 때, 참인 갈래만 인스턴스화됩니다. std::is_pointer_v<T>가 true인 갈래에서는 *x만 사용되므로 T가 포인터가 아니어도 그 갈래는 무시되고, false인 갈래에서는 return x만 사용됩니다.
template <typename T>
auto unwrap(T x) {
if constexpr (std::is_pointer_v<T>)
return *x;
else
return x;
}
리터럴 타입·재귀 깊이·표준 버전: constexpr 컴파일 에러 10가지
에러 1: 비리터럴 타입을 constexpr로 사용
원인: constexpr 변수/함수는 리터럴 타입만 다룰 수 있습니다. std::string, std::vector(C++20 이전) 등은 리터럴 타입이 아니었습니다.
// ❌ C++17 이하에서 컴파일 에러
constexpr std::string msg = "hello";
// ✅ C++20에서는 std::string이 constexpr 지원
// ✅ C++17 이하: 고정 크기 배열 사용
constexpr char msg[] = "hello";
에러 2: 런타임 전용 연산 사용
원인: C++14 이하에서 constexpr 함수 안에 동적 할당, 예외, 가상 함수 호출 등을 쓸 수 없습니다.
// ❌ C++14에서 에러
constexpr int bad() {
int* p = new int(42); // 동적 할당 불가
return *p;
}
// ✅ 컴파일 타임에 허용된 연산만 사용
constexpr int good(int x) {
return x * 2;
}
에러 3: constexpr 함수에 비상수 인자 전달
원인: 인자가 상수 식이 아니면 constexpr 함수도 런타임에 실행됩니다. 이건 에러가 아니라 의도된 동작입니다. 다만, 상수 식이 필요한 곳에 런타임 결과를 넣으면 에러가 납니다.
constexpr int add(int a, int b) { return a + b; }
int main() {
int x = 3, y = 5;
constexpr int z = add(x, y); // ❌ 에러: x, y는 상수 식이 아님
int w = add(x, y); // ✅ OK: 런타임 호출
constexpr int v = add(3, 5); // ✅ OK: 상수 인자
return 0;
}
에러 4: 재귀 깊이 초과
원인: 컴파일 타임 재귀가 너무 깊으면 컴파일러 한계에 걸릴 수 있습니다.
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
constexpr int f50 = fib(50); // 일부 컴파일러에서 재귀 한계 초과
해결: 반복문으로 바꾸거나, 룩업 테이블을 사용합니다.
constexpr int fibIter(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int tmp = a + b;
a = b;
b = tmp;
}
return b;
}
에러 5: 포인터/참조 반환
원인: C++14 이하에서 constexpr 함수가 지역 변수의 주소/참조를 반환하면 안 됩니다. 컴파일 타임에는 “주소”가 일정하지 않을 수 있기 때문입니다.
// ❌ 위험
constexpr const int* bad() {
static int x = 42;
return &x; // C++14: OK, 하지만 주의
}
// ✅ 값 반환
constexpr int good() {
return 42;
}
에러 6: static_assert 조건에 런타임 값 사용
원인: static_assert의 조건은 반드시 컴파일 타임 상수여야 합니다.
// ❌ 컴파일 에러
void process(int max_size) {
static_assert(max_size > 0, "max_size must be positive");
}
// ✅ constexpr 또는 템플릿 사용
template <int MaxSize>
void process() {
static_assert(MaxSize > 0, "max_size must be positive");
}
// 또는 constexpr 함수로 검증
constexpr bool validate_size(int n) { return n > 0; }
static_assert(validate_size(100), "");
에러 7: std::array 크기에 constexpr 아닌 식 사용
원인: std::array<T, N>의 N은 반드시 컴파일 타임 상수여야 합니다.
// ❌ 컴파일 에러
int get_size() { return 128; }
std::array<int, get_size()> arr; // get_size()는 constexpr 아님
// ✅ constexpr 함수 사용
constexpr int get_size() { return 128; }
std::array<int, get_size()> arr; // OK
에러 8: C++ 표준 버전 불일치
원인: C++11에서는 constexpr 함수에 return 하나만 허용, C++14부터 루프·여러 문 허용. std::vector constexpr는 C++20부터.
// ❌ C++11에서 컴파일 에러
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) result *= i; // C++11: 루프 불가
return result;
}
// ✅ C++11 호환: 단일 return + 재귀
constexpr int factorial_cpp11(int n) {
return n <= 1 ? 1 : n * factorial_cpp11(n - 1);
}
에러 9: constexpr 함수에서 I/O 또는 전역 상태 접근
원인: constexpr 평가 중에는 std::cout, 파일 입출력, 전역 변수 수정 등이 불가능합니다.
// ❌ 컴파일 에러
constexpr int bad() {
std::cout << "hello"; // I/O 불가
return 42;
}
// ❌ 전역 상태 수정 불가
int global = 0;
constexpr int also_bad() {
global = 1; // 부수 효과 불가
return 42;
}
// ✅ 순수 계산만
constexpr int good(int x) {
return x * 2;
}
에러 10: 컴파일 타임 재귀 한계 초과 (컴파일러별)
원인: GCC/Clang은 constexpr 재귀 깊이에 제한이 있습니다(기본 수백~수천). -fconstexpr-depth=N으로 조정 가능하지만, 반복문으로 전환하는 것이 안전합니다.
// ❌ fib(40) 이상에서 일부 컴파일러 한계
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // 지수적 재귀
}
// ✅ 반복문: 깊이 제한 없음
constexpr int fib_safe(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int next = a + b;
a = b;
b = next;
}
return b;
}
팩토리얼·CRC32 테이블로 본 컴파일 타임 계산의 실행 비용
컴파일 타임 vs 런타임 계산
constexpr로 컴파일 타임에 계산하면 런타임 비용이 0입니다. 다음은 팩토리얼 계산을 1,000,000번 반복한 벤치마크 예시입니다.
#include <chrono>
#include <iostream>
constexpr unsigned long long factorialConstexpr(int n) {
return n <= 1 ? 1 : n * factorialConstexpr(n - 1);
}
unsigned long long factorialRuntime(int n) {
return n <= 1 ? 1 : n * factorialRuntime(n - 1);
}
int main() {
constexpr int N = 15;
constexpr auto precomputed = factorialConstexpr(N);
// 런타임 계산 100만 번
auto start = std::chrono::high_resolution_clock::now();
volatile unsigned long long sum = 0;
for (int i = 0; i < 1'000'000; ++i)
sum += factorialRuntime(N);
auto end = std::chrono::high_resolution_clock::now();
auto runtime_ms = std::chrono::duration<double, std::milli>(end - start).count();
// 컴파일 타임 결과 사용 (100만 번 접근)
start = std::chrono::high_resolution_clock::now();
sum = 0;
for (int i = 0; i < 1'000'000; ++i)
sum += precomputed;
end = std::chrono::high_resolution_clock::now();
auto constexpr_ms = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "Runtime factorial(15) x 1M: " << runtime_ms << " ms\n";
std::cout << "Constexpr result x 1M: " << constexpr_ms << " ms\n";
return 0;
}
실제 측정 결과 (Windows 11, MinGW GCC 10.3, 두 번 실행). 이전 버전의 “예상 결과” 표는 근거가 없어 직접 잰 값으로 바꿨습니다.
| 방식 | -O2 | -O0 |
|---|---|---|
런타임 factorialRuntime(15) × 1,000,000 | 6.7~9.2ms | 30ms |
| constexpr로 미리 계산한 값 사용 × 1,000,000 | 0.43~0.47ms | 0.62ms |
컴파일 타임에 계산해 두면 런타임에는 상수를 더하기만 하므로 이 경우 약 15~20배 빨랐습니다. 다만 이 차이는 “재귀 호출 15단계 × 100만 번”이라는 인위적인 반복에서 나온 것이고, 실제 프로그램에서 한 번 계산하는 상수라면 체감할 수 없는 크기입니다. constexpr의 실용적인 이점은 속도보다 잘못된 값이 컴파일 에러로 드러난다(static_assert), 초기화 순서 문제가 없다, 룩업 테이블을 실행 파일에 넣어 시작 시간을 줄인다 쪽에 있습니다. 또 컴파일러는 constexpr이 아닌 함수도 인자가 상수면 최적화 단계에서 계산해 버리는 경우가 많아서, 벤치마크를 만들 때 인자를 volatile로 숨기지 않으면 두 방식이 같은 시간으로 나오기도 합니다.
벤치마크 2: CRC32 테이블 초기화 vs constexpr
CRC32를 런타임에 테이블을 초기화하는 방식과 constexpr로 컴파일 타임에 생성하는 방식을 비교합니다.
#include <chrono>
#include <cstdint>
#include <array>
#include <iostream>
#include <utility>
// 런타임 테이블 초기화 (전통적 방식)
uint32_t crc32_table_runtime[256];
void init_crc32_table() {
for (uint32_t i = 0; i < 256; ++i) {
uint32_t crc = i;
for (int j = 0; j < 8; ++j)
crc = (crc >> 1) ^ (0xEDB88320u & -(crc & 1));
crc32_table_runtime[i] = crc;
}
}
// constexpr 테이블 (컴파일 타임)
constexpr uint32_t crc32_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(std::index_sequence<Is...>) {
return std::array<uint32_t, 256>{{crc32_entry(Is)...}};
}
constexpr auto CRC32_TABLE = make_crc32(std::make_index_sequence<256>{});
int main() {
const int ITERS = 10'000'000;
auto t1 = std::chrono::high_resolution_clock::now();
init_crc32_table();
volatile uint32_t v1 = 0;
for (int i = 0; i < ITERS; ++i) {
uint32_t crc = 0xFFFFFFFFu;
for (int j = 0; j < 64; ++j)
crc = crc32_table_runtime[(crc ^ (j & 0xFF)) & 0xFF] ^ (crc >> 8);
v1 = crc;
}
auto t2 = std::chrono::high_resolution_clock::now();
auto t3 = std::chrono::high_resolution_clock::now();
volatile uint32_t v2 = 0;
for (int i = 0; i < ITERS; ++i) {
uint32_t crc = 0xFFFFFFFFu;
for (int j = 0; j < 64; ++j)
crc = CRC32_TABLE[(crc ^ (j & 0xFF)) & 0xFF] ^ (crc >> 8);
v2 = crc;
}
auto t4 = std::chrono::high_resolution_clock::now();
auto ms_runtime = std::chrono::duration<double, std::milli>(t2 - t1).count();
auto ms_constexpr = std::chrono::duration<double, std::milli>(t4 - t3).count();
std::cout << "Runtime init+CRC: " << ms_runtime << " ms\n";
std::cout << "Constexpr table+CRC: " << ms_constexpr << " ms\n";
return 0;
}
예상 결과 (Apple M1, GCC 13 기준): constexpr 테이블은 초기화 비용이 0이므로 프로그램 시작 시점 지연이 없습니다.
벤치마크 3: nextPowerOfTwo 및 메모리 배치
| 버퍼 크기 | std::array (constexpr 크기) | std::vector (런타임 크기) |
|---|---|---|
| 할당 | 없음 (스택/전역) | 힙 할당 1회 |
| 해제 | 없음 | 힙 해제 |
| 캐시 | 지역성 좋음 | 포인터 간접 1회 |
룩업 테이블 vs 런타임 계산
CRC32, 삼각함수 등 고정 테이블이 필요한 경우, constexpr로 컴파일 타임에 테이블을 만들면 런타임 계산을 완전히 제거할 수 있습니다.
// 컴파일 타임에 sin 테이블 생성 (예시)
constexpr int TABLE_SIZE = 360;
constexpr double PI = 3.14159265358979;
constexpr double toRad(int deg) {
return deg * PI / 180.0;
}
// C++14: std::array + 인덱스 시퀀스 활용 (예시 구조)
template <size_t... Is>
constexpr auto make_sin_table(std::index_sequence<Is...>) {
// 각 Is에 대해 sin(toRad(Is)) 계산 후 배열 생성
return std::array<double, sizeof...(Is)>{{static_cast<double>(Is) * PI / 180.0...}};
}
constexpr auto SIN_TABLE = make_sin_table(std::make_index_sequence<TABLE_SIZE>{});
실제 프로덕션에서는 std::array와 std::make_index_sequence를 조합해 constexpr 룩업 테이블을 만드는 패턴을 자주 사용합니다.
룩업 테이블, 설정 파싱, 타입 ID: constexpr가 실무에 들어가는 자리
패턴 1: 컴파일 타임 룩업 테이블
CRC32, 해시, 인코딩 테이블 등을 컴파일 타임에 생성합니다.
// 0-255에 대한 parity 비트 테이블 (예시)
constexpr int parity(uint8_t n) {
int p = 0;
while (n) {
p ^= (n & 1);
n >>= 1;
}
return p;
}
template <size_t... Is>
constexpr auto makeParityTable(std::index_sequence<Is...>) {
return std::array<int, sizeof...(Is)>{{parity(static_cast<uint8_t>(Is))...}};
}
constexpr auto PARITY_TABLE = makeParityTable(std::make_index_sequence<256>{});
// 사용
int p = PARITY_TABLE[42]; // 런타임 비용: 배열 접근 1번
패턴 2: 설정값 컴파일 타임 파싱
버퍼 크기, 타임아웃 등을 문자열 리터럴에서 컴파일 타임에 파싱할 수 있습니다.
constexpr int parseSize(const char* s) {
int result = 0;
while (*s >= '0' && *s <= '9') {
result = result * 10 + (*s - '0');
++s;
}
return result;
}
constexpr int BUF_SIZE = parseSize("4096"); // 4096
std::array<char, BUF_SIZE> buffer;
패턴 3: 타입별 상수
템플릿과 constexpr를 조합해 타입에 따라 다른 상수를 사용합니다.
template <typename T>
struct TypeTraits;
template <>
struct TypeTraits<int> {
static constexpr size_t max_digits = 10;
};
template <>
struct TypeTraits<long long> {
static constexpr size_t max_digits = 19;
};
template <typename T>
std::array<char, TypeTraits<T>::max_digits + 1> to_string_buffer;
패턴 4: 안전한 배열 크기
입력 상수 이상인 2의 거듭제곱을 컴파일 타임에 구해 버퍼 크기로 사용합니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
// 100 이상인 가장 작은 2의 거듭제곱 = 128
std::array<int, nextPowerOfTwo(100)> buf;
패턴 5: 프로토콜 버퍼 크기 조합
헤더 + 페이로드 크기를 컴파일 타임에 조합해 패킷 버퍼를 정의합니다.
constexpr size_t PACKET_HEADER_SIZE = 16;
constexpr size_t MAX_PAYLOAD_SIZE = 4096;
constexpr size_t PACKET_BUFFER_SIZE = PACKET_HEADER_SIZE + MAX_PAYLOAD_SIZE;
std::array<uint8_t, PACKET_BUFFER_SIZE> packet_buffer;
// 또는
constexpr size_t total_size() { return PACKET_HEADER_SIZE + MAX_PAYLOAD_SIZE; }
std::array<uint8_t, total_size()> packet_buffer;
패턴 6: consteval로 강제 컴파일 타임 평가 (C++20)
consteval은 반드시 컴파일 타임에만 평가되는 함수입니다. constexpr는 상수 인자일 때 컴파일 타임, 그렇지 않으면 런타임인데, consteval은 런타임 호출 자체가 불가능합니다.
consteval int must_be_compile_time(int x) {
return x * 2;
}
constexpr int a = must_be_compile_time(21); // OK: 42
// int b = must_be_compile_time(rand()); // ❌ 컴파일 에러: rand()는 런타임
사용처: 암호화 키 파생, 보안 상수 등 “절대 런타임에 노출되면 안 되는” 값에 유용합니다.
패턴 7: 컴파일 타임 타입 ID
타입별 고유 ID를 컴파일 타임에 부여하는 패턴입니다.
template <typename T>
struct type_id {
static constexpr const char* name = __PRETTY_FUNCTION__; // GCC/Clang
// MSVC: __FUNCSIG__
};
// type_id<int>::name != type_id<double>::name
// 디버깅, 로깅, 직렬화 타입 식별에 활용
패턴 8: static_assert와 constexpr 검증
설정값이 유효한 범위인지 컴파일 타임에 검증합니다.
constexpr int MAX_CONNECTIONS = 1024;
constexpr int MIN_CONNECTIONS = 1;
constexpr bool valid_connections(int n) {
return n >= MIN_CONNECTIONS && n <= MAX_CONNECTIONS;
}
static_assert(valid_connections(MAX_CONNECTIONS), "MAX_CONNECTIONS must be valid");
static_assert(valid_connections(512), "512 should be valid");
// 잘못된 값이 들어오면 컴파일 시점에 에러
버퍼 크기 계산과 재귀 템플릿을 constexpr 함수로 바꾸기
배열 크기
nextPowerOfTwo(100)은 100 이상인 가장 작은 2의 거듭제곱(128)을 반환합니다. n이 상수이면 while도 컴파일 타임에 풀려서, std::array<int, nextPowerOfTwo(100)>의 크기가 128로 고정됩니다. 버퍼 크기를 “입력 상수 이상인 2의 거듭제곱”으로 맞출 때 이런 constexpr 함수를 쓰면, 템플릿 메타 없이도 컴파일 타임 상수를 얻을 수 있습니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
std::array<int, nextPowerOfTwo(100)> buf;
템플릿 메타 프로그래밍 대체
과거에는 template <int N> struct Fib로 재귀 템플릿을 썼지만, constexpr int fib(int n)으로 같은 계산을 함수로 쓰면 읽기와 유지보수가 쉽습니다. Fib<N>::value가 필요하면 fib(N)을 constexpr 변수에 담거나 std::integral_constant에 넣어 쓰면 됩니다. 값 메타프로그래밍은 constexpr 함수로 대체하는 편이 현대 C++에서는 일반적입니다.
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
template <int N>
struct Fib { static constexpr int value = fib(N); };
같이 보면 좋은 글
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++ constexpr 고급 가이드 | constexpr 컨테이너·알고리즘·문자열·new/delete 실전
- C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
- C++ type traits 활용
constexpr 변수·함수·생성자 요약표
| 항목 | 내용 |
|---|---|
| constexpr 변수 | 컴파일 타임 상수 |
| constexpr 함수 | 상수 식에서 호출 가능 (인자가 상수면 컴파일 타임) |
| constexpr 생성자 | 사용자 정의 타입을 리터럴 타입으로 만듦 |
| const vs constexpr | const는 “불변”, constexpr은 “컴파일 타임 계산 가능” |
| C++14 | 루프·여러 문장 허용 |
| C++20 | 동적 할당 등 더 많은 것 허용 |
| if constexpr | 컴파일 타임 분기 |
| 프로덕션 패턴 | 룩업 테이블, 설정 파싱, 타입별 상수 |
자주 묻는 질문 (FAQ)
Q. const 변수와 constexpr 변수는 무엇이 다른가요?
A. const는 초기화 뒤에 값을 바꿀 수 없다는 뜻일 뿐이라 const int n = read_config();처럼 런타임 값으로도 초기화할 수 있습니다. constexpr 변수는 반드시 컴파일 타임 상수 표현식으로 초기화되어야 하고, 그 대신 배열 크기, 템플릿 인자, static_assert 조건처럼 컴파일 타임 값이 필요한 자리에 쓸 수 있습니다. constexpr 변수는 암묵적으로 const이므로, 값이 컴파일 시점에 정해진다면 constexpr로 선언해 의도를 드러내는 편이 좋습니다.
Q. constexpr 함수가 느리지 않나요?
A. 인자가 상수 식이면 컴파일 타임에 계산되므로 런타임 비용은 0입니다. 인자가 변수면 일반 함수처럼 런타임에 실행됩니다.
Q. constexpr와 매크로의 차이는?
A. 매크로는 전처리 단계에서 텍스트 치환이며, constexpr는 타입 검사·네임스페이스·스코프를 갖는 실제 C++ 함수/변수입니다. 디버깅, 오버로딩, 템플릿과의 조합이 constexpr 쪽이 유리합니다.
Q. 컴파일 시간이 늘어나지 않나요?
A. constexpr 계산이 많으면 컴파일 시간이 증가할 수 있습니다. 특히 큰 룩업 테이블(수천 항목)이나 깊은 재귀는 영향을 줄 수 있으므로, 필요할 때만 사용하고 반복문으로 전환하는 것을 권장합니다.
constexpr 적용 체크리스트
실무에서 constexpr를 도입할 때 확인할 항목입니다.
- 배열/버퍼 크기가 컴파일 시점에 고정인가? →
constexpr변수 또는 함수 사용 - 룩업 테이블(CRC, 해시, 인코딩)이 고정인가? → constexpr +
std::make_index_sequence패턴 - 설정값이 문자열 리터럴에서 파싱 가능한가? →
parseSize("4096")같은 constexpr 파서 - C++ 표준 버전이 C++14 이상인가? (루프·여러 문 사용 시)
- 재귀 깊이가 컴파일러 한계를 넘지 않는가? → 반복문으로 전환 검토
- static_assert로 상수 검증이 필요한가? → constexpr 조건 함수 활용
constexpr로 상수·배열 크기·템플릿 인자를 컴파일 타임에 계산할 수 있습니다. 다음으로 컴파일 타임 프로그래밍(#26-2)를 읽어보면 좋습니다. 다음 글: [C++ 실전 가이드 #26-2] 컴파일 타임 프로그래밍 기법: 템플릿 메타와 constexpr 조합 이전 글: [C++ 실전 가이드 #25-3] 커스텀 Range 작성: range 개념을 만족하는 타입 만들기