C++ 상수 초기화: constexpr·constinit으로 정적 초기화 순서 문제 피하기
이 글의 핵심
서로 다른 번역 단위의 전역 객체가 초기화 순서에 의존하면 프로그램 시작 시점에 알 수 없는 오류가 날 수 있습니다. constexpr과 constinit의 차이, constinit이 정적 변수에만 쓰이는 이유, constexpr 함수가 항상 컴파일 타임에 실행되지는 않는다는 점을 짚어 초기화 단계를 통제하는 방법을 설명합니다.
상수 초기화란?
상수 초기화(constant initialization) 는 컴파일 타임에 값이 정해지는 초기화입니다. constexpr 함수, constexpr if와 함께 사용하며, 값 초기화·0 초기화와 구분해 두면 좋습니다.
constexpr int x = 10; // 상수 초기화 (컴파일 타임)
int y = compute(); // 동적 초기화 (런타임)
여기서 흔히 오해하는 점이 있습니다. 상수 초기화는 constexpr 키워드가 있어야만 일어나는 것이 아닙니다. 정적 저장 기간 변수의 초기값이 상수 표현식이면 int limit = 10;처럼 평범하게 써도 컴파일러는 상수 초기화를 해야 합니다. constexpr은 “이 값은 반드시 컴파일 타임 상수여야 한다”는 요구를 추가하고 변수를 const로 만들 뿐입니다. 반대로 int y = compute();는 compute가 constexpr 함수이고 인자가 상수라면 컴파일러가 상수 초기화로 처리할 수도 있지만(표준이 허용하는 최적화), 보장되지는 않습니다. 이 “보장되지 않음”을 없애는 것이 C++20 constinit의 역할입니다.
왜 필요한가?:
- 성능: 런타임 초기화 비용 제거
- 안전성: 정적 초기화 순서 문제(Static Initialization Order Fiasco) 방지
- 최적화: 컴파일러가 더 적극적으로 최적화 가능
// 동적 초기화: 프로그램 시작 시 함수 호출
int config = loadConfig(); // 런타임 비용
// 상수 초기화: 바이너리에 값 내장
constexpr int config = 100; // 비용 없음
constexpr 변수
constexpr int MAX = 100;
constexpr double PI = 3.14159;
constexpr const char* MSG = "Hello";
// 컴파일 타임 계산
constexpr int square(int x) {
return x * x;
}
constexpr int result = square(5); // 25
constexpr의 특징:
- 컴파일 타임 평가: 값이 바이너리에 직접 내장됨
- 불변성: 런타임에 수정 불가
- 타입 안전: 컴파일러가 타입 검사
// 컴파일러가 생성하는 코드 (개념적)
// constexpr int result = square(5);
// ↓
// int result = 25; // 함수 호출 없음
실무 활용:
// ✅ 배열 크기
constexpr size_t BUFFER_SIZE = 1024;
char buffer[BUFFER_SIZE];
// ✅ switch case
enum class Status { OK = 0, ERROR = 1 };
constexpr int STATUS_OK = static_cast<int>(Status::OK);
switch (code) {
case STATUS_OK: // 컴파일 타임 상수 필요
break;
}
// ✅ 템플릿 인자
template<int N>
struct Array { int data[N]; };
constexpr int SIZE = 10;
Array<SIZE> arr; // OK
실전 예시
예시 1: 배열 크기
constexpr size_t SIZE = 10;
int arr[SIZE]; // OK: 컴파일 타임 상수
void func(int n) {
// int arr2[n]; // 에러: VLA (C++에서 비표준)
}
예시 2: 정적 변수
struct Config {
int width;
int height;
};
constexpr Config defaultConfig = {800, 600};
int main() {
Config cfg = defaultConfig;
}
예시 3: constinit (C++20)
// constinit: 상수 초기화 강제
constinit int x = 10; // OK: 컴파일 타임
// constinit int y = compute(); // 에러: compute()가 상수 표현식이 아님
// GCC: error: 'constinit' variable 'y' does not have a constant initializer
// constexpr vs constinit
constexpr int a = 10; // 컴파일 타임 상수
constinit int b = 10; // 상수 초기화, 런타임 수정 가능
int main() {
// a = 20; // 에러: constexpr
b = 20; // OK: constinit
}
constexpr vs constinit 비교:
| 특징 | constexpr | constinit |
|---|---|---|
| 초기화 시점 | 컴파일 타임 | 컴파일 타임 |
| 런타임 수정 | ❌ 불가 | ✅ 가능 |
| 사용 위치 | 모든 변수 | 정적/전역 변수만 |
| 주 용도 | 컴파일 타임 상수 | 정적 초기화 순서 보장 |
constinit 실무 활용:
// 전역 설정: 초기화는 컴파일 타임, 런타임에 수정 가능
constinit int g_maxConnections = 100;
void updateConfig(int newMax) {
g_maxConnections = newMax; // OK: 런타임 수정
}
// ✅ 정적 초기화 순서 문제 방지
// config.h — 두 파일이 공유하는 값은 상수로 헤더에
inline constexpr int kBaseConnections = 42;
// file1.cpp
constinit int g_config = kBaseConnections;
// file2.cpp
constinit int g_derived = kBaseConnections + 1; // 두 값 모두 로드 시점에 이미 확정
// ❌ 다른 전역 "변수"를 읽는 것은 상수 표현식이 아니라 constinit 불가
// extern int g_config;
// constinit int g_bad = g_config + 1; // 에러: g_config는 const가 아님
constinit의 진짜 가치는 컴파일러가 대신 검사해 준다는 데 있습니다. 누군가 리팩터링하며 초기값을 loadDefault() 같은 비-constexpr 함수 호출로 바꾸면, constinit이 없을 때는 조용히 동적 초기화로 바뀌어 초기화 순서 버그가 생길 수 있는 상태가 되지만, constinit이 있으면 그 자리에서 빌드가 깨집니다. 저는 다른 번역 단위의 정적 객체 생성자에서 참조될 가능성이 있는 전역(로깅 레벨, 기능 플래그, 뮤텍스 등)에는 습관적으로 constinit을 붙이는데, 값이 상수라는 것을 문서로 남기는 것보다 훨씬 확실하기 때문입니다. std::mutex의 기본 생성자가 constexpr이라서 constinit std::mutex g_mtx;가 가능하다는 점도 실무에서 유용합니다.
예시 4: 정적 초기화 순서
// 같은 번역 단위 안에서는
constexpr int x = 10;
constexpr int y = x + 1; // 상수 초기화: 순서 문제 자체가 없음
int a = compute();
int b = a + 1; // 동적 초기화지만 같은 파일 안에서는 정의 순서대로 초기화됨
// 문제는 "서로 다른 .cpp 파일" 사이의 동적 초기화 순서
같은 번역 단위 안의 동적 초기화는 정의된 순서대로 진행된다고 표준이 보장합니다. 순서가 정해지지 않는 것은 서로 다른 번역 단위 사이이며, 이 순서는 대개 링커에 넘긴 오브젝트 파일 순서에 따라 결정되므로 빌드 스크립트에서 파일 순서만 바뀌어도 동작이 달라질 수 있습니다. 증상은 보통 main에 들어가기 전의 크래시나, 전역 컨테이너가 비어 있는 것처럼 보이는 현상으로 나타납니다.
정적 초기화 순서 문제(Static Initialization Order Fiasco)란?:
서로 다른 번역 단위(translation unit, .cpp 파일)에 있는 전역 변수의 초기화 순서는 정의되지 않음입니다. 동적 초기화를 사용하면 한 변수가 다른 변수를 참조할 때 초기화되지 않은 값을 읽을 수 있습니다.
// logger.cpp
Logger globalLogger; // 동적 초기화
// config.cpp
extern Logger globalLogger;
Config globalConfig = loadConfig(globalLogger); // globalLogger가 초기화 안됐을 수 있음!
해결 방법:
// ✅ 방법 1: constexpr 사용 (가능한 경우)
// logger.cpp
constexpr int LOG_LEVEL = 2;
// config.cpp
constexpr int CONFIG_LEVEL = LOG_LEVEL + 1; // 순서 보장
// ✅ 방법 2: 함수 지역 static (Construct On First Use)
Logger& getLogger() {
static Logger logger; // 첫 호출 시 초기화 (C++11부터 스레드 안전)
return logger;
}
// config.cpp
Config globalConfig = loadConfig(getLogger()); // 안전
두 번째 방법은 초기화 순서 문제를 해결하지만 소멸 순서 문제는 남깁니다. 프로그램 종료 시 정적 객체는 생성의 역순으로 소멸하므로, 다른 전역 객체의 소멸자가 이미 소멸한 logger를 호출하면 미정의 동작이 됩니다. 종료 시점의 로그가 가끔 깨지거나 크래시가 난다면 이 경우를 의심해 볼 만합니다. 이를 피하려고 static Logger* logger = new Logger;처럼 의도적으로 해제하지 않는 패턴을 쓰기도 합니다(누수 검사기가 경고하지만 프로세스 종료 시 OS가 회수합니다).
클래스 타입 전역이 동적 초기화되는지 확인하고 싶다면 Clang의 -Wglobal-constructors 경고가 유용합니다. 전역 생성자를 호출해야 하는 변수마다 declaration requires a global constructor 경고를 내므로, 어떤 전역이 초기화 순서 문제에 노출되어 있는지 목록을 얻을 수 있습니다.
초기화 순서
// 1. 0 초기화
static int x; // 0
// 2. 상수 초기화
constexpr int y = 10;
// 3. 동적 초기화
int z = compute();
초기화 단계 상세:
- Zero Initialization: 정적/전역 변수는 프로그램 로드 시 0으로 초기화
- Constant Initialization: 컴파일 타임에 값이 결정된 변수 초기화
- Dynamic Initialization: 런타임에 함수 호출 등으로 초기화
// 실행 순서 예시
static int counter; // 1단계: 0으로 초기화
constexpr int MAX = 100; // 2단계: 바이너리에 100 내장
int config = loadConfig(); // 3단계: main() 전에 loadConfig() 호출
int main() {
// 이 시점에 모든 전역 변수 초기화 완료
}
왜 순서가 중요한가?:
// ❌ 동적 초기화: 순서 보장 안 됨
// file1.cpp
int x = compute1();
// file2.cpp
extern int x;
int y = x + 1; // x가 초기화 안됐을 수 있음
// ✅ 상수 초기화: 순서 보장
// file1.cpp
constexpr int x = 10;
// file2.cpp
constexpr int y = x + 1; // 항상 11
자주 발생하는 문제
문제 1: 동적 초기화
// ❌ 런타임 계산
int getValue() { return 42; }
constexpr int x = getValue(); // 에러
// ✅ constexpr 함수
constexpr int getValue() { return 42; }
constexpr int x = getValue(); // OK
문제 2: 정적 초기화 순서 문제
// file1.cpp
int x = compute1();
// file2.cpp
extern int x;
int y = x + 1; // x가 초기화 안됐을 수 있음
// ✅ constexpr 사용
constexpr int x = 10;
constexpr int y = x + 1; // 순서 보장
문제 3: constinit 제약
// ❌ 자동 저장 기간 지역 변수
void func() {
// constinit int x = 10; // 에러
static constinit int calls = 0; // ✅ 함수 지역 static은 가능
++calls;
}
// ✅ 정적·스레드 저장 기간 변수
constinit static int x = 10;
constinit int global = 10;
constinit thread_local int tls_counter = 0;
thread_local 변수에 constinit을 붙이는 것은 성능상 의미가 있습니다. 동적 초기화가 필요한 thread_local 변수는 접근할 때마다 “이 스레드에서 초기화됐는가”를 확인하는 래퍼 함수 호출이 붙을 수 있는데, 상수 초기화가 보장되면 이 검사가 사라집니다.
문제 4: 포인터
int g = 10; // 정적 저장 기간
constexpr int* pg = &g; // ✅ OK: 전역 변수의 주소는 상수 표현식
void f() {
int local = 10;
// constexpr int* pl = &local; // ❌ 에러: 스택 주소는 실행 때마다 다름
static int s = 10;
constexpr int* ps = &s; // ✅ OK: 지역 static도 정적 저장 기간
}
기준은 “값이 상수인가”가 아니라 주소가 컴파일·링크 시점에 정해지는가입니다. 전역 변수와 지역 static 변수는 주소가 고정되므로(ASLR이 있어도 재배치로 처리됨) constexpr 포인터가 될 수 있고, 자동 지역 변수는 함수가 호출될 때마다 스택 위치가 달라서 불가능합니다. 이 규칙은 C++11부터 동일합니다.
성능 이점
// 동적 초기화: 런타임 비용
int x = expensiveComputation();
// 상수 초기화: 비용 없음
constexpr int x = 42;
성능 비교 예시:
#include <chrono>
#include <iostream>
// 동적 초기화
int dynamicValue() {
int sum = 0;
for (int i = 0; i < 1000; ++i) {
sum += i;
}
return sum;
}
// 상수 초기화
constexpr int constValue() {
int sum = 0;
for (int i = 0; i < 1000; ++i) {
sum += i;
}
return sum;
}
// 전역 변수
int g_dynamic = dynamicValue(); // 프로그램 시작 시 계산
constexpr int g_const = constValue(); // 컴파일 타임에 계산
int main() {
// g_const는 이미 바이너리에 499500으로 내장됨
std::cout << g_const << '\n'; // 즉시 출력
}
최적화 효과:
| 항목 | 동적 초기화 | 상수 초기화 |
|---|---|---|
| 계산 시점 | 프로그램 시작 | 컴파일 타임 |
| 시작 시간 | 느림 | 빠름 |
| 바이너리 크기 | 작음 | 약간 큼 |
| 디버깅 | 쉬움 | 어려움 |
실무 권장:
- 작은 상수: constexpr 사용 (성능 향상)
- 복잡한 계산: 동적 초기화 (디버깅 용이)
- 정적 초기화 순서 문제: constexpr 또는 Singleton
다른 초기화 단계와의 관계 (한눈에)
정적 저장 기간 객체는 표준적으로 0 초기화 → (상수 초기화 또는 동적 초기화) 순으로 이야기할 수 있습니다. 값 초기화, 0 초기화, 동적 초기화 글과 교차로 읽으면 그림이 잡힙니다.
| 구분 | 시점 | 전형적 문법 | 런타임 비용 |
|---|---|---|---|
| 0 초기화 | 로드 시 등 | 정적 int x; | 없음에 가깝게 |
| 상수 초기화 | 컴파일 타임 | constexpr int x = f(); | 호출부 없음(평가됨) |
| 동적 초기화 | 시작 시 | int x = g(); | g() 비용 |
상수 초기화로 끝나면 해당 객체는 동적 초기화 단계에 끼지 않아, TU 간 순서 이슈에서 안전한 쪽에 서게 됩니다.
실전 활용 사례 (보강)
- 룩업 테이블·해시 상수: CRC/해시 다항식, 소수 테이블 등을
constexpr로 만들어 ROM/데이터 섹션에 박습니다. - 단위·차원 분석: 물리 단위를 컴파일 타임에만 다루는 작은 라이브러리에서, 모든 “상수”를 constexpr로 고정합니다.
- 플래그/비트마스크: 런타임에 설정 파일을 읽을 수 없다면, 빌드 시
-D와constexpr상수를 조합해 환경별 마스크를 나눕니다. - 정적 초기화 순서 회피: 전역에서 꼭 필요한 정수·포인터 상수만 constexpr/
constinit로 빼고, 나머지 복잡 객체는 지역 static이나 함수 호출 뒤로 미룹니다.
성능 영향 (실무 관점)
- 시작 시간: 동적 전역 초기화가 많을수록
main전 구간이 길어집니다. 상수 초기화로 옮길 수 있는 것은 바이너리에 상수로 박혀 그 비용이 사라집니다. - 캐시·코드 크기: constexpr 데이터는 종종 읽기 전용 데이터로 모여 캐시 친화적일 수 있습니다. 반대로 거대한 constexpr 배열은 바이너리를 키웁니다.
- 컴파일 시간: 매우 무거운 constexpr 메타프로그래밍은 빌드 시간을 늘립니다. 런타임 비용과 트레이드오프입니다.
컴파일러 최적화
- 상수 초기화된
constexpr값은 이미 숫자/주소로 남으며, 사용처에서 로드·즉시값으로 접힙니다. constexpr함수는 인자가 상수일 때만 컴파일 타임에 실행되고, 런타임 인자면 일반 함수처럼 생성될 수 있습니다. 같은 함수라도 두 경로가 있습니다.-fconstexpr-depth,-fconstexpr-loop-limit등(구현별) 한계에 걸리면 컴파일 에러가 나므로, 복잡한 constexpr는 단계적으로 쪼개세요.
흔한 실수 (보강)
- 전역 constexpr 포인터: 주소 상수성 규칙을 어기면 컴파일이 안 됩니다. C++ 버전별로 허용 범위가 다릅니다.
constinit없이 “전역이 꼭 컴파일 타임 초기화였으면”에만 의존: 구현이나 리팩터링으로 동적 초기화로 바뀌면 순서 버그가 납니다. 의도가 있으면constinit으로 고정하세요.constexpr변수를 헤더에 두고 ODR 위반:inline constexpr(C++17) 규칙을 지키지 않으면 링크 에러나 중복 정의가 납니다.- 동적 초기화를 constexpr로 억지 변환: 파일·네트워크 입력은 불가능합니다. 잘못된 기대로 빌드 설정만 꼬이기 쉽습니다.
FAQ
Q1: constexpr이나 constinit 없이도 상수 초기화가 되나요?
A: 됩니다. 정적 변수의 초기값이 상수 표현식이면 int x = 10;도 상수 초기화입니다. 두 키워드는 그것을 보장하고 검사하는 역할을 합니다.
Q2: constexpr vs constinit 차이는?
A:
- constexpr: 컴파일 타임 상수, 런타임 수정 불가
- constinit: 초기화만 컴파일 타임, 런타임 수정 가능
constexpr int a = 10;
// a = 20; // 에러
constinit int b = 10;
b = 20; // OK
Q3: 초기화 순서는 어떻게 되나요?
A:
- Zero Initialization: 정적/전역 변수 0으로 초기화
- Constant Initialization: 컴파일 타임 상수 초기화
- Dynamic Initialization: 런타임 함수 호출
Q4: constinit은 왜 정적/전역 변수만 가능한가요?
A: constinit은 정적 초기화 순서 문제를 해결하기 위한 기능입니다. 자동 지역 변수는 함수 호출 시 스택에 생성되므로 정적 초기화 순서 문제가 없습니다. 함수 안이라도 static이나 thread_local로 선언한 변수에는 쓸 수 있습니다.
Q5: constexpr 함수는 항상 컴파일 타임에 실행되나요?
A: 아닙니다. constexpr 함수는 가능하면 컴파일 타임에 실행됩니다. 런타임 값으로 호출하면 런타임에 실행됩니다.
constexpr int square(int x) { return x * x; }
constexpr int a = square(5); // 컴파일 타임
int b = square(getUserInput()); // 런타임
관련 글: constexpr 함수, constexpr if, 값 초기화, 0 초기화.
같이 보면 좋은 글
- C++ constexpr 함수: 컴파일 타임 평가 조건과 C++11·14·17 제약 변화
- C++ 값 초기화: int x{}와 int()가 0이 되는 이유와 {}·()의 차이
- C++ Zero Initialization | “0 초기화” 가이드
- C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
- C++ constexpr 고급 가이드 | constexpr 컨테이너·알고리즘·문자열·new/delete 실전