C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
들어가며: “런타임 오버헤드를 줄이고 싶어요”
왜 컴파일 타임에 계산해야 할까?
게임 엔진에서 고정된 각도의 회전 값을 쓰거나, 임베디드에서 센서 보정값을 상수 테이블로 두거나, 네트워크 코드에서 CRC32 테이블을 만들 때를 생각해 봅시다. 입력이 빌드 시점에 이미 정해져 있는 값인데도 런타임에 계산하면, 실행할 때마다(또는 부팅할 때마다) 불필요한 일을 하게 됩니다. 컴파일 타임에 미리 계산해 두면 실행 파일에는 결과 상수만 들어가므로, 그 계산에 대한 런타임 비용이 사라집니다.
비유하자면 매번 계산기를 두드리는 것(런타임)과 미리 계산해 둔 표를 보기만 하는 것(컴파일 타임)의 차이입니다.
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)로 컴파일 타임 평가를 강제하기
- 템플릿 메타프로그래밍과 constexpr의 비교
- 자주 하는 실수와 성능을 측정할 때의 함정
- 설정 상수, 룩업 테이블, 문자열 해시 같은 실전 패턴
매 프레임 반복되는 같은 계산: 런타임 오버헤드
예를 들어 팩토리얼이나 배열 합계를 런타임에 계산하면 호출되는 횟수만큼 CPU가 일을 합니다. 반면 인자가 컴파일 타임 상수라면 컴파일러가 미리 계산해 두고 결과를 바이너리에 상수로 넣을 수 있습니다. 더 중요한 차이는, constexpr가 없으면 그 결과를 배열 크기나 템플릿 인자처럼 상수 표현식이 필요한 자리에 쓸 수 없다는 점입니다.
// ❌ 나쁜 예: 매번 런타임에 계산
int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
// 사용: std::array<int, factorial(5)> arr; // 컴파일 에러! constexpr 함수가 아님
// ✅ 좋은 예: constexpr로 컴파일 타임 계산 가능
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
// 사용: std::array<int, factorial(5)> arr; // 컴파일 타임에 120으로 계산됨
추가 문제 시나리오
게임 엔진 회전 행렬
스프라이트를 정해진 몇 가지 각도(예: 16방향)로만 회전시킨다면, sin, cos 값을 매번 계산하는 대신 컴파일 타임 룩업 테이블로 미리 만들어 둘 수 있습니다.
임베디드 센서 보정
ADC(아날로그-디지털 변환) 값에서 실제 온도나 압력을 구하는 보정 테이블이 고정되어 있다면, 런타임에 테이블을 채우는 대신 constexpr 배열로 만들어 둘 수 있습니다. 이렇게 하면 부팅 시 초기화 코드가 사라지고, 테이블이 RAM이 아니라 플래시(.rodata)에 놓일 수 있어 RAM이 작은 MCU에서 특히 유리합니다.
네트워크 프로토콜 CRC32
패킷 검증용 CRC32 테이블(256개 uint32_t)을 런타임에 생성하려면 초기화 시점을 관리해야 하고, 여러 스레드가 동시에 첫 호출을 할 때의 동기화도 신경 써야 합니다. 컴파일 타임에 생성하면 이런 문제가 아예 없습니다.
그래픽 색공간 변환
YUV ↔ RGB 변환 계수나 8비트 sRGB → linear 변환 테이블(256개 항목)처럼 입력 범위가 작고 고정된 변환은 컴파일 타임에 테이블로 만들어 두면 픽셀마다 거듭제곱을 계산하지 않아도 됩니다.
프로토콜 명령어 디스패치
“start”, “stop”, “pause” 같은 문자열 명령은 C++에서 switch로 바로 분기할 수 없습니다. 리터럴 쪽 해시를 컴파일 타임에 계산해 두면 case 레이블로 쓸 수 있습니다.
고정 프레임 애니메이션
스프라이트 시트의 프레임 수(예: 8프레임)가 빌드 시 고정이라면, std::array<Frame, 8>처럼 컴파일 타임 상수 크기로 선언해 힙 할당 없이 객체 안에 바로 담을 수 있습니다.
문제의 원인
| 원인 | 설명 |
|---|---|
| 상수 표현식이 아님 | constexpr 없이 정의한 함수는 상수 표현식에서 호출할 수 없음 |
| 런타임 인자 | factorial(n)에서 n이 일반 변수면 컴파일 타임에 값을 알 수 없음 |
| 허용되지 않는 연산 | 상수 평가 중에는 입출력, 실제로 실행되는 throw, 미정의 동작 등이 불가 (허용 범위는 표준 버전마다 넓어짐) |
해결 방향
컴파일 타임 상수로 쓸 값은 constexpr 함수로 정의하고, 결과를 constexpr 변수나 배열 크기처럼 상수 표현식이 필요한 자리에서 받습니다. 그래야 컴파일 타임 평가가 보장됩니다. 일반 변수에 받으면 상수 인자여도 컴파일러가 최적화로 접을 수 있을 뿐 보장은 없습니다. C++20에서는 consteval로 함수 자체를 컴파일 타임 전용으로 만들 수 있습니다.
팩토리얼부터 CRC32·사인 테이블까지 constexpr 함수 예제
팩토리얼 (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); // 런타임에도 호출 가능
}
int로는 12!까지만 표현할 수 있고 13!부터는 부호 있는 정수 오버플로(미정의 동작)입니다. 범위를 검사하고 싶을 때 함수 안에서 static_assert(n <= 12)처럼 쓰고 싶어지지만, n은 함수 파라미터라서 상수 표현식이 아니므로 컴파일되지 않습니다. 대신 범위를 벗어나면 throw하는 방법을 씁니다. throw는 constexpr 함수 안에 있어도 되고, 상수 평가 중에 그 줄이 실제로 실행될 때만 컴파일 에러가 됩니다.
#include <stdexcept>
constexpr int factorial(int n) {
if (n < 0 || n > 12) throw std::out_of_range("factorial: 0..12");
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int ok = factorial(10); // 3628800
// constexpr int bad = factorial(13); // 컴파일 에러: throw가 평가됨
런타임에 범위를 벗어난 값으로 호출하면 일반 예외가 던져집니다. 참고로 이 예제에서 throw를 빼더라도 constexpr int x = factorial(13);은 컴파일 에러가 납니다. 상수 평가 중의 정수 오버플로는 미정의 동작이고, 컴파일러는 상수 평가에서 미정의 동작을 진단해야 하기 때문입니다.
문자열 길이 (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를 쓰면 됩니다. 생성자와 size()가 모두 constexpr이라 리터럴로 만든 string_view의 길이는 컴파일 타임 상수입니다.
constexpr std::string_view sv = "Hello";
constexpr size_t len = sv.size(); // 5, 컴파일 타임
배열 합계 (array sum)
상수 배열의 합, 곱, 평균 등을 컴파일 타임에 계산할 때 씁니다. C++14부터 constexpr 함수 안에서 루프와 지역 변수 변경이 허용되어 일반 함수처럼 쓸 수 있습니다.
template <typename T, size_t N>
constexpr T array_sum(const std::array<T, N>& arr) {
T sum = 0;
for (auto x : arr) sum += x; // std::array의 begin/end도 C++17부터 constexpr
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로 초기화하면 바이너리에 상수 데이터로 포함됩니다.
추가 예제: 거듭제곱 (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 버퍼
}
constexpr vs 일반 함수 선택 기준
flowchart TD
A[값을 컴파일 타임에 쓸 수 있나?] -->|예| B[constexpr 함수 사용]
A -->|아니오| C[일반 함수로 충분]
B --> D[상수 인자로 호출]
D --> E[컴파일 타임 계산]
C --> F[런타임 호출]
완전한 예제: CRC32 테이블 (컴파일 타임 생성)
네트워크·파일 검증에서 널리 쓰이는 CRC32(IEEE 802.3, zlib과 같은 방식)의 256개 엔트리 테이블을 컴파일 타임에 생성하는 예제입니다.
#include <array>
#include <cstdint>
#include <cstddef>
#include <utility>
// CRC32 다항식 x^32 + x^26 + x^23 + ... + 1 (0x04C11DB7)을 비트 반전한 값
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은 컴파일 타임에 완성되어 바이너리에 상수로 들어갑니다. -(crc & 1)은 최하위 비트가 1이면 모든 비트가 1인 마스크를, 0이면 0을 만드는 분기 없는 표현입니다. C++17부터는 index_sequence 없이 constexpr 함수 안에서 std::array를 만들고 루프로 채워 반환해도 됩니다. 결과가 맞는지는 "123456789"의 CRC32가 0xCBF43926인지로 확인하는 것이 관례입니다.
완전한 예제: 사인 룩업 테이블 (고정 각도)
게임이나 임베디드에서 정해진 각도의 sin 값이 필요할 때의 예입니다. 표준 std::sin은 C++23까지 constexpr가 아니므로 이식성 있게 쓰려면 직접 근사 함수를 만들어야 합니다.
#include <array>
#include <cmath>
// 0, 90, 180, 270도 (라디안)에 대한 sin 값
constexpr double pi = 3.14159265358979323846;
// ❌ 이식성 없음: std::sin은 C++23까지 constexpr가 아님.
// GCC는 내장 함수 확장으로 받아 주지만 Clang/MSVC에서는 컴파일 에러
// 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) }};
// }
// ✅ 직접 구현한 근사 함수
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)
}};
}
C++26에서는 <cmath>의 삼각함수 대부분이 constexpr로 바뀌었지만(P1383), 그 전까지는 GCC의 확장에만 기댈 수 있습니다. 이식성이 필요하면 sin_manual처럼 직접 근사하되, 테일러 급수는 0에서 멀어질수록 오차가 커지므로 실제로는 인자를 [-π, π] 범위로 줄인 뒤 계산하는 것이 좋습니다. 컴파일 타임 계산이라 비용을 걱정할 필요가 없으니 항 수를 넉넉히 두어도 됩니다.
완전한 예제: 컴파일 타임 문자열 파싱 (정수 변환)
설정 매크로나 버전 문자열 "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);
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 "?";
}
}
타입 트레이트와 선택
if constexpr는 문장(값과 동작)을 고르는 도구이고, 타입 자체를 고르지는 못합니다. 타입 별칭을 조건에 따라 정하려면 여전히 std::conditional_t가 필요합니다. 반면 함수 오버로드를 std::enable_if_t로 나누던 코드는 if constexpr 하나로 합칠 수 있는 경우가 많습니다.
// 타입 선택: conditional_t (if constexpr로는 불가)
template <typename T>
using SafePointer = std::conditional_t<std::is_pointer_v<T>, T, T*>;
// 동작 선택: enable_if로 두 오버로드를 만들던 코드를 하나로
template <typename T>
auto deref_or_self(T v) {
if constexpr (std::is_pointer_v<T>) return *v;
else return v;
}
최적화: 타입별 다른 구현
정수형과 부동소수점에 서로 다른 알고리즘이 필요한 대표적인 예는 비교입니다. 정수는 ==로 비교하면 되지만 부동소수점은 오차를 허용해야 합니다.
#include <algorithm>
#include <cmath>
#include <limits>
template <typename T>
bool almost_equal(T a, T b) {
if constexpr (std::is_floating_point_v<T>) {
T scale = std::max(std::abs(a), std::abs(b));
return std::abs(a - b) <= std::numeric_limits<T>::epsilon() * scale * 4;
} else {
return a == b;
}
}
조건부 멤버
특정 타입일 때만 의미 있는 멤버 함수를 두고 싶을 때가 있습니다. 이때 아래처럼 쓰고 싶어지지만, if constexpr는 함수 본문 안의 문장이라서 클래스 본문에는 어느 표준 버전에서도 쓸 수 없습니다.
// ❌ 컴파일 에러: 클래스 본문에는 if constexpr를 쓸 수 없음
template <typename T>
struct Widget {
T value;
if constexpr (std::is_pointer_v<T>) {
void reset() { value = nullptr; }
}
};
C++20에서는 멤버 함수에 requires 절을 붙여 조건을 만족할 때만 그 함수가 존재하게 만듭니다. C++17 이하라면 조건별로 특수화한 기반 클래스를 상속하는 방식으로 분리합니다.
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;
}
};
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 활용 예
“이 값은 반드시 컴파일 타임에 결정되어야 한다”는 의도를 코드로 강제할 때 유용합니다.
#include <cstdint>
consteval uint32_t compile_time_hash(const char* str) {
// int로 계산하면 몇 글자만 넘어도 부호 있는 오버플로(UB)가 나고,
// 상수 평가 중의 UB는 컴파일 에러가 되므로 부호 없는 타입을 씀
uint32_t hash = 0;
while (*str) {
hash = hash * 31 + static_cast<unsigned char>(*str++);
}
return hash;
}
// 사용: 반드시 컴파일 타임에만 계산
constexpr uint32_t h = compile_time_hash("config_key");
consteval 함수에 런타임 변수를 넘기면 컴파일 에러가 납니다. 그래서 설정 키 해시나 포맷 문자열 검사처럼 항상 리터럴이나 상수로만 쓰는 경우에 적합합니다. C++20의 std::format이 포맷 문자열 오류를 컴파일 타임에 잡는 것도 consteval 생성자를 이용한 기법입니다.
constexpr vs consteval 비교
flowchart LR
subgraph constexpr[constexpr]
CE1[컴파일 타임 호출] --> CE2[가능]
CE3[런타임 호출] --> CE4[가능]
end
subgraph consteval[consteval]
CV1[컴파일 타임 호출] --> CV2[가능]
CV3[런타임 호출] --> CV4[불가]
end
| 키워드 | 컴파일 타임 | 런타임 | 용도 |
|---|---|---|---|
constexpr | ✅ | ✅ | 양쪽 모두 지원 |
consteval | ✅ | ❌ | 컴파일 타임 전용 강제 |
템플릿 메타 프로그래밍 vs constexpr
비교표
| 항목 | 템플릿 메타 (TMP) | constexpr |
|---|---|---|
| 가독성 | 재귀, 특수화로 복잡 | 일반 함수처럼 작성 |
| 디버깅 | 에러 메시지 난해 | 상대적으로 단순 |
| 컴파일 비용 | 값마다 클래스 인스턴스화 | 보통 더 가벼움 |
| 타입 계산 | 가능 | 불가 (값만 계산) |
| 표준 | C++98~ | C++11~ (C++14부터 루프 허용) |
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 함수로 바꾸고, 타입 계산은 그대로 두거나 enable_if로 오버로드를 가르던 부분만 concepts로 바꾸는 식으로 나눠서 진행하면 됩니다. Factorial<N>::value 방식은 N마다 새 클래스가 인스턴스화되어 컴파일러 메모리에 남기 때문에, 큰 N에서는 constexpr 함수보다 컴파일이 무겁습니다.
상수 문맥의 런타임 변수, 금지 연산, 재귀 한계 같은 실수
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
상수 평가 중에 허용되지 않는 연산
문제: constexpr 함수 안에 무엇을 쓸 수 있는지는 표준 버전마다 크게 달라서 헷갈리기 쉽습니다. C++20부터는 가상 함수 호출, try-catch, 평가 안에서 할당하고 해제하는 new/delete가 허용되었고, C++23부터는 static constexpr 지역 변수도 쓸 수 있습니다. throw 문은 C++14부터 constexpr 함수 안에 둘 수 있지만, 상수 평가 중에 실제로 실행되면 컴파일 에러가 됩니다.
constexpr int checked(int n) {
if (n < 0) throw std::invalid_argument("negative"); // 함수 정의는 OK
return n;
}
constexpr int a = checked(5); // OK
// constexpr int b = checked(-1); // 컴파일 에러: 상수 평가 중 throw 실행
int c = checked(-1); // 런타임 호출: 예외 발생
해결: 이 성질을 오히려 활용할 수 있습니다. 컴파일 타임에는 잘못된 인자를 컴파일 에러로, 런타임에는 예외로 알려 주는 검증 함수가 됩니다. assert도 같은 방식으로 동작하므로 constexpr 함수 안에서 쓸 수 있습니다.
런타임 전용 라이브러리 호출
문제: std::cout 같은 입출력은 상수 평가 중에 실행할 수 없습니다.
constexpr int wrong() {
std::cout << "hi"; // ❌
return 0;
}
해결: 컴파일 타임에 필요한 로직만 constexpr로 분리하며, I/O는 런타임 코드에서 처리합니다.
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
std::vector 등 동적 할당 사용
문제: C++17까지는 constexpr 함수 안에서 std::vector, std::string을 쓸 수 없었습니다.
constexpr std::vector<int> make() { // C++17: ❌, C++20: 함수 정의는 OK
return {1, 2, 3};
}
constexpr int n = make().size(); // C++20: OK (평가 안에서 할당·해제가 끝남)
// constexpr auto v = make(); // C++20에서도 ❌: 할당된 메모리가 런타임까지 남음
해결: C++20에서는 std::vector와 std::string을 constexpr 함수 안에서 쓸 수 있지만, 할당한 메모리는 같은 상수 평가 안에서 해제되어야 합니다(transient allocation). 그래서 결과를 constexpr 변수로 남기려면 마지막에 std::array로 복사해 반환하는 패턴을 씁니다.
재귀 깊이 한계
문제: constexpr 재귀가 너무 깊으면 컴파일러 한계에 걸립니다. GCC와 Clang의 기본 재귀 깊이 한도는 512이고, 각각 -fconstexpr-depth=, MSVC는 /constexpr:depth로 조정할 수 있습니다.
constexpr int deep(int n) {
return n <= 0 ? 0 : 1 + deep(n - 1);
}
constexpr int x = deep(10000); // 기본 설정에서는 깊이 한도 초과로 실패
해결: C++14 이후에는 반복문으로 바꾸는 것이 가장 간단합니다. 반복문도 전체 연산 횟수 한도(GCC -fconstexpr-ops-limit, Clang -fconstexpr-steps)가 있으므로, 수백만 번 이상 반복하는 계산은 컴파일 타임에 두지 않는 편이 낫습니다.
constexpr에서의 미정의 동작 (UB)
문제: 상수 평가 중에 미정의 동작(0으로 나누기, 부호 있는 정수 오버플로, 배열 범위 밖 접근 등)이 발생하면 컴파일 에러가 됩니다. 런타임에는 우연히 동작하던 코드도 컴파일 타임에는 검출됩니다. 이 성질 덕분에 constexpr 단위 테스트(static_assert로 함수 결과를 검사)는 간단한 UB 검출기 역할도 합니다.
constexpr int div_by_zero(int a, int b) {
return a / b; // b가 0이면 UB → 컴파일 에러
}
constexpr int x = div_by_zero(10, 0); // ❌ 컴파일 에러
해결: 함수 안에서 경계를 검사합니다. 파라미터는 상수 표현식이 아니므로 static_assert로는 검사할 수 없고, 조건문이나 assert를 씁니다.
constexpr int safe_div(int a, int b) {
return b != 0 ? a / b : 0;
}
constexpr 람다의 캡처 제한
문제: 람다에서 런타임 변수를 캡처하면 그 람다 객체는 constexpr 변수가 될 수 없습니다.
int x = 5;
constexpr auto bad = [x]() { return x + 1; }; // ❌ x는 상수 아님
constexpr int r = bad(); // 에러
해결: 컴파일 타임에 쓸 람다는 캡처 없이 인자로 값을 받습니다. C++17부터 조건을 만족하는 람다는 자동으로 constexpr가 됩니다.
constexpr auto good = [](int n) { return n + 1; };
constexpr int r = good(5); // OK
컴파일러별 차이
문제: GCC, Clang, MSVC는 constexpr 평가 한도와 확장 지원이 서로 다릅니다.
| 항목 | GCC | Clang | MSVC |
|---|---|---|---|
| 재귀 깊이 기본 한도 | 512 | 512 | 512 |
| 연산 횟수 한도 옵션 | -fconstexpr-ops-limit | -fconstexpr-steps | /constexpr:steps |
std::sin 등 constexpr (C++26 이전) | 내장 함수 확장으로 허용 | 불가 | 불가 |
해결: 이식성이 중요하면 표준에 명시된 기능만 쓰고, 한 컴파일러에서만 통과하는 코드가 들어오지 않도록 CI에서 여러 컴파일러로 빌드합니다. GCC에서만 개발하다 보면 std::sin을 constexpr 테이블에 쓴 코드가 아무 문제 없이 통과해서, 나중에 Clang 빌드를 추가할 때 한꺼번에 깨지는 일이 흔합니다.
constexpr 생성자와 멤버 초기화
문제: 타입을 constexpr 변수로 쓰려면 모든 멤버가 상수 평가 안에서 초기화될 수 있어야 하고, 결과에 런타임까지 남는 동적 할당이 없어야 합니다.
// 타입 정의
struct Bad {
std::vector<int> v; // 동적 할당
constexpr Bad() : v{1,2,3} {} // C++17: ❌, C++20: 생성자는 OK지만
// constexpr Bad b; 처럼 변수로 남길 수는 없음
};
해결: 컴파일 타임에 만들어 런타임까지 남길 상수 데이터는 std::array를 사용합니다.
컴파일 타임 계산은 얼마나 빠른가: 측정법과 최적화 함정
측정 방법
컴파일 타임에 계산한 값은 실행 시 상수를 읽는 비용만 들고, 런타임 계산은 호출 횟수만큼 CPU를 씁니다. 다만 이 차이를 측정하려고 하면 생각보다 까다롭습니다.
벤치마크 예시 (개념)
// 컴파일 타임: 결과가 이미 상수
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처럼 상수 대입만 하도록 비교하면 차이가 보일 것 같지만, 실제로는 그렇지 않은 경우가 많습니다.
측정할 때 흔한 함정: 최적화가 둘 다 상수로 만든다
위 예시를 -O2로 그대로 빌드하면 두 루프의 시간이 거의 같게 나옵니다. factorial(15)는 인자가 상수라서, constexpr를 붙이지 않았어도 컴파일러가 최적화 단계에서 계산을 접어(constant folding) 상수 대입으로 바꾸기 때문입니다. 컴파일 타임 계산의 이득이 실제로 드러나는 건 컴파일러가 스스로 접을 수 없는 경우입니다.
- 계산이 복잡하거나 커서 최적화기가 포기하는 경우(큰 룩업 테이블 생성, 반복 횟수가 많은 루프)
- 값이 여러 번역 단위에서 쓰여 인라인이 안 되는 경우
-O0디버그 빌드처럼 최적화가 꺼진 환경(게임 개발에서 디버그 빌드 프레임레이트가 문제 될 때)
그래서 “몇 배 빠르다”는 수치보다, constexpr 변수로 받아 컴파일 타임 평가를 강제하는 것 자체가 핵심입니다. constexpr int v = f(15);로 쓰면 컴파일 타임 계산이 보장되고, 계산이 불가능하면 컴파일 에러로 알려 줍니다. int v = f(15);는 최적화 여부에 따라 달라질 뿐 보장이 없습니다. 런타임 비용을 제대로 비교하려면 인자를 런타임 값으로 만들어야 합니다.
int main(int argc, char**) {
volatile int n = 15 + (argc - 1); // 컴파일러가 값을 알 수 없게
// ... 이 n으로 factorial(n)을 루프에서 호출해야 진짜 런타임 계산이 측정됨
}
컴파일 타임 vs 런타임 트레이드오프
flowchart LR
subgraph ct[컴파일 타임]
CT1[컴파일 시간 증가] --> CT2[바이너리 크기 증가]
CT2 --> CT3[런타임 0 비용]
end
subgraph rt[런타임]
RT1[컴파일 빠름] --> RT2[바이너리 작음]
RT2 --> RT3[실행 시 계산 비용]
end
컴파일 타임 계산은 빌드 시간을 늘리고, 상수 테이블이 바이너리에 그대로 들어가 크기를 키웁니다. 예를 들어 64KB짜리 룩업 테이블은 코드 몇 줄로 런타임에 만들 수 있지만, 컴파일 타임에 만들면 실행 파일이 64KB 커지고 그만큼 로딩과 캐시에도 부담이 됩니다. 반대로 런타임 계산은 빌드는 가볍지만 실행할 때마다 비용이 듭니다. 값이 고정되어 있고 자주 쓰이며 크기가 작다면 컴파일 타임이 유리하고, 설정에 따라 달라지거나 테이블이 크다면 런타임 초기화가 나을 수 있습니다.
설정 상수, 문자열 해시 디스패치, enum↔문자열 매핑 패턴
설정 파싱 (컴파일 타임 상수)
빌드별로 다른 설정값(버퍼 크기, 풀 크기 등)을 컴파일 타임 상수로 두고 싶을 때 씁니다.
// 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를 쓰면 타입이 있고 네임스페이스 규칙을 따르며, 디버거에서도 이름으로 값을 볼 수 있습니다. 헤더의 네임스페이스 범위 constexpr 변수는 암시적으로 내부 링키지를 가지므로, 여러 번역 단위에서 같은 주소를 보장하려면 C++17의 inline constexpr를 씁니다.
룩업 테이블 (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이 바이너리에 상수로 포함되어 런타임 초기화가 필요 없습니다.
컴파일 타임 정렬 (C++20)
C++20부터 std::sort를 포함한 대부분의 <algorithm>이 constexpr입니다. 정렬된 상수 테이블이 필요할 때 직접 버블 정렬을 짤 필요 없이 표준 알고리즘을 그대로 씁니다. 이렇게 만든 테이블은 런타임에 std::binary_search로 바로 조회할 수 있습니다.
#include <algorithm>
#include <array>
constexpr auto make_sorted() {
std::array<int, 5> a{5, 2, 8, 1, 9};
std::sort(a.begin(), a.end()); // C++20: constexpr std::sort
return a;
}
constexpr auto kSorted = make_sorted();
static_assert(kSorted[0] == 1 && kSorted[4] == 9);
C++17 이하라면 constexpr 함수 안에서 직접 삽입 정렬 등을 구현해야 합니다(C++17의 constexpr 함수는 루프와 지역 변수 변경을 허용하므로 가능합니다).
문자열 해시 (컴파일 타임)
설정 키나 에러 코드 문자열을 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;
}
}
switch (hash_str(key))의 key는 런타임 값이므로 이쪽 해시는 런타임에 계산되고, case 레이블의 해시만 컴파일 타임에 계산됩니다. 그래서 이 함수는 consteval이 아니라 constexpr여야 합니다. 두 case 레이블의 해시가 우연히 같으면 중복 레이블로 컴파일 에러가 나므로 레이블끼리의 충돌은 빌드 때 드러나지만, 런타임 입력이 다른 명령과 해시가 같은 경우는 막을 수 없습니다.
타입별 디폴트값
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{};
}
제네릭 코드에서 “타입에 맞는 초기값”이 필요할 때 자주 쓰는 패턴입니다.
에러 코드 매핑
에러 코드를 문자열로 바꾸는 함수를 constexpr로 두면 static_assert로 매핑을 검증할 수 있습니다.
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";
}
}
컴파일 타임 명령 디스패치 (Command Pattern)
“start”, “stop”, “pause” 같은 고정 명령 문자열을 switch로 처리할 때, 문자열 비교 대신 해시 기반 분기를 사용합니다. 아래는 분포가 더 고른 FNV-1a 해시를 쓴 버전입니다.
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" 등은 컴파일 타임에 해시로 변환되므로 런타임에는 입력의 해시를 한 번 계산하고 정수로 분기합니다. 다만 해시가 같다고 문자열이 같은 것은 아닙니다. 외부에서 들어온 입력을 처리한다면 각 case 안에서 strcmp(cmd, "start") == 0으로 한 번 더 확인해야, 우연히 해시가 같은 다른 문자열이 명령으로 실행되는 일을 막을 수 있습니다.
프로토콜 ID 매핑 (enum ↔ 문자열)
프로토콜 메시지 타입을 enum과 문자열로 양방향 매핑하는 예입니다. 아래 str_to_msg_type은 앞 두 글자만 보므로 “Help”도 Hello로 인식하는 단순화된 예라는 점에 주의하세요. 실제로는 std::string_view 비교를 쓰는 편이 안전합니다.
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);
}
타입 안전한 설정 (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{};
}
}
컴파일 타임 검증 (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");
실전 통합 예제: 컴파일 타임 설정 + 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;
}
}
}
설정 상수, case 레이블의 해시, CRC 테이블은 모두 컴파일 타임에 결정되므로, 런타임에는 입력 명령의 해시 계산과 정수 비교, 테이블 읽기만 남습니다.
타입 트레이트와 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 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
- C++17 if constexpr: 컴파일 타임 분기로 템플릿 단순화하기와 static_assert(false) 문제
- C++ type traits 활용
- C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
자주 묻는 질문 (FAQ)
Q. 템플릿 안에서 if constexpr 대신 일반 if를 쓰면 왜 컴파일 에러가 나나요?
A. 일반 if는 조건이 컴파일 타임에 결정되더라도 양쪽 분기를 모두 인스턴스화합니다. 그래서 T가 int일 때 사용되지 않는 분기에 value.size() 같은 코드가 있으면 그 분기 때문에 컴파일이 실패합니다. if constexpr는 선택되지 않은 분기를 인스턴스화하지 않으므로 타입별로 다른 연산을 쓸 수 있으며, 이 효과는 템플릿 안에서만 적용된다는 점도 기억해야 합니다.
Q. constexpr와 consteval 중 뭘 써야 하나요?
A. “컴파일 타임과 런타임 모두” 가능하게 하려면 constexpr, “반드시 컴파일 타임만” 강제하려면 consteval을 사용합니다. 설정 키 해시처럼 항상 상수로만 쓰는 경우 consteval이 의도를 더 분명히 합니다.
Q. 컴파일 타임 계산이 빌드 시간을 많이 늘리나요?
A. 작은 상수나 테이블은 영향이 거의 없습니다. 상수 평가는 컴파일러 안의 인터프리터가 수행하므로 반복이 수백만 번에 이르는 계산이나 큰 테이블 생성은 빌드 시간을 눈에 띄게 늘릴 수 있고, 특히 헤더에 두면 그 헤더를 포함하는 모든 번역 단위에서 다시 계산됩니다. Clang의 -ftime-trace로 어느 부분이 오래 걸리는지 확인할 수 있습니다.
Q. constexpr vs 매크로, 언제 뭘 쓰나요?
A. constexpr는 타입이 있고 디버거에서 추적할 수 있으며 네임스페이스·클래스 범위를 따릅니다. 매크로는 전처리기 단계에서 텍스트로 치환되어 타입 정보가 없고 디버깅이 어렵습니다. 새 코드에서는 constexpr를 우선 쓰고, #if로 코드를 조건부 컴파일해야 하는 경우처럼 전처리기 단계의 값이 필요할 때만 매크로를 씁니다.
이전 글: C++ 실전 가이드 #26-1: constexpr 기초
다음 글: C++ 실전 가이드 #26-3: 컴파일 타임 리플렉션