C++ const·constexpr 내부 동작 | 평가 시점·if constexpr
이 글의 핵심
const는 ‘읽기 전용’을 보장하고, constexpr는 ‘컴파일 타임에 값·호출이 유효할 수 있음’을 표시합니다. 이 글에서는 두 키워드의 평가 시점, constexpr 함수 요건, if constexpr의 버려진 분기, C++20 consteval, 실무 const 패턴을 표준과 컴파일러 동작에 맞춰 설명합니다.
C++에서 const와 constexpr는 자주 함께 등장하지만, 의미와 컴파일러가 수행하는 검사·최적화는 크게 다릅니다. 전자는 주로 객체 수준의 불변성과 *오버로드 해석(특히 멤버 함수)*에 관여하며, 후자는 상수 표현식(constant expression) 문맥에서 함수·변수가 유효한지, 그리고 필요 시 컴파일 타임에 값을 고정할 수 있는지를 표시합니다. 아래에서는 평가 시점부터 if constexpr의 분기 제거, consteval, 그리고 프로덕션에서의 const 정확성 패턴까지 정리합니다. 기초적인 const 문법은 C++ const 에러: passing as const 컴파일 에러 원인과 const 정합성, constexpr 함수, C++17 if constexpr: 컴파일 타임 분기로 템플릿 단순화하기와 static_assert(false) 문제, C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전 글과 함께 보시면 흐름이 잡힙니다.
const와 constexpr의 평가 시점
const: “언제” 고정되는가
const로 선언된 객체는 초기화 이후 수정할 수 없다는 규칙만을 부여합니다. 초기화가 상수 표현식으로 이루어지느냐는 별개의 질문입니다.
int runtime_input(); // 외부 입력 등
const int a = 42; // 상수 표현식 초기화 → 종종 컴파일 타임에 값 확정
const int b = runtime_input(); // 런타임 초기화 → 값은 실행 시 결정, 이후 불변
void use() {
// b는 const이지만 “컴파일 타임 상수”는 아님 → 배열 크기 등에 못 씀(C++ 이전 규칙과 VLAs 제외)
int arr[a]; // OK (상수 표현식인 경우)
// int arr2[b]; // 표준 C++에서는 일반적으로 불가 (b가 컴파일 타임 상수가 아님)
}
여기서 int arr[a];가 되는 이유는 C++의 오래된 특례 때문입니다. 정수·열거형 타입의 const 변수가 상수 표현식으로 초기화되면, 그 변수는 상수 표현식 안에서 쓸 수 있습니다. 이 특례는 정수 계열에만 적용됩니다. 그래서 const double pi = 3.14159;를 선언한 뒤 constexpr double tau = pi * 2;를 쓰면 GCC는 error: the value of 'pi' is not usable in a constant expression을 내고, note: 'pi' was not declared 'constexpr'라는 힌트를 붙입니다. int에서 잘 되던 코드가 타입만 double로 바꾸면 깨지는 이유가 이것이고, 컴파일 타임 상수를 의도했다면 처음부터 constexpr로 쓰는 편이 규칙을 외울 필요가 없습니다.
int arr2[b];는 표준 C++에서는 오류지만, GCC와 Clang은 C99의 가변 길이 배열(VLA)을 확장 기능으로 받아들이기 때문에 기본 설정에서는 그냥 컴파일됩니다. -pedantic을 켜야 ISO C++ forbids variable length array 'arr2' 경고가 나오고, MSVC에서는 error C2131: expression did not evaluate to a constant로 바로 막힙니다. 리눅스에서만 개발하다 Windows 빌드에서 처음 발견되는 전형적인 이식성 문제이므로, -Wvla를 켜 두는 것을 권합니다.
컴파일러 입장에서 const T 지역 변수는 최적화 상수 전파(constant propagation)의 후보가 될 수 있지만, 언어 의미론상 “반드시 컴파일 타임에 숫자가 박혀야 한다”는 뜻은 아닙니다. 반면 constexpr 변수는 선언이 상수 표현식으로 초기화되어야 하므로, 그 값은 번역 단위를 넘어 일관되게 컴파일 타임에 확정됩니다(일부 문맥에서의 사용 규칙과 맞물림).
constexpr 변수: 상수 표현식과의 결합
constexpr 변수는 반드시 상수 표현식으로 초기화되어야 하며, 이는 곧 해당 이름이 컴파일 타임 상수로 쓰일 수 있음을 의미합니다(타입과 생명 주기 규칙을 만족할 때).
constexpr int k1 = 1 + 2; // 상수 표현식
constexpr double k2 = k1 * 3.14;
// constexpr int k3 = runtime_input(); // 오류: 상수 표현식이 아님
constexpr 변수는 C++11부터 암시적으로 const입니다. 따라서 constexpr int는 사실상 “컴파일 타임에 값이 확정되는 불변 정수”에 가깝습니다. (C++14에서 바뀐 것은 변수가 아니라 멤버 함수 쪽입니다. C++11에서는 constexpr 멤버 함수가 자동으로 const 멤버 함수가 되었지만, C++14부터는 그 암시적 const가 없어져서 C++11 코드를 C++14로 올릴 때 const 객체에서 호출이 안 되는 경우가 생겼습니다.)
포인터에서는 constexpr의 위치가 헷갈리기 쉽습니다. constexpr const char* p = "abc";에서 constexpr은 포인터 변수 자체를 상수로 만들고, const char는 가리키는 대상이 상수임을 뜻합니다. constexpr int* q = &global;은 “주소가 고정된 포인터”일 뿐 *q = 1;은 허용됩니다. 또 constexpr 포인터는 주소가 컴파일 타임에 확정되어야 하므로 전역·정적 객체의 주소는 담을 수 있지만 지역 변수의 주소는 담을 수 없습니다.
한눈에 보는 차이
| 구분 | const T x | constexpr T x |
|---|---|---|
| 초기화 | 상수·비상수 식 모두 가능(비상수면 런타임) | 상수 표현식만 허용 |
| 용도 | 불변 보장, API 읽기 전용 | 컴파일 타임 상수, 템플릿 인자·배열 크기 등 |
| 멤버 함수 | const 멤버 함수 집합과 연동 | 변수 선언과는 별개 문법 |
핵심: “const는 불변, constexpr는 (변수의 경우) 컴파일 타임 값 고정 + 불변”으로 기억하면 혼동이 줄어듭니다.
constexpr 함수의 요구 사항과 진화
constexpr 함수는 컴파일 타임 호출이 가능한 함수임을 표시합니다. 표준은 버전마다 허용되는 본문 구성이 넓어졌습니다.
C++11: 제한적인 단일 표현식 스타일
C++11의 constexpr 함수 본문은 사실상 return 문 하나로 제한되었습니다. 지역 변수도 루프도 쓸 수 없었기 때문에 반복은 재귀로, 분기는 삼항 연산자로 표현해야 했고, 그 결과 constexpr 코드가 읽기 어려운 한 줄짜리 식이 되곤 했습니다. 재귀 깊이도 컴파일러 한도(GCC -fconstexpr-depth, 기본 512)에 걸려, 큰 입력에서는 constexpr evaluation depth exceeds maximum 에러가 나기 쉬웠습니다.
C++14 이후: 일반 함수에 가까운 constexpr
지역 변수, 루프, 여러 return 경로가 허용되면서 실용적인 constexpr 함수 작성이 쉬워졌습니다. 여전히 constexpr 평가 중에는 정해진 연산만 가능하며, (버전에 따라) 할당·예외·일부 라이브러리 호출은 제한됩니다.
constexpr int sum_range(int n) {
int s = 0;
for (int i = 1; i <= n; ++i) {
s += i;
}
return s;
}
static_assert(sum_range(10) == 55, "");
constexpr 함수의 의미: “항상 컴파일 타임”이 아님
중요한 점은, constexpr 함수는 런타임에서도 호출 가능하다는 것입니다. 인자가 컴파일 타임 상수가 아니면 일반 함수처럼 실행됩니다.
constexpr int sq(int x) { return x * x; }
int main(int argc, char**) {
constexpr int a = sq(3); // 컴파일 타임
int v = argc;
int b = sq(v); // 런타임 호출 허용
(void)b;
}
컴파일러는 constexpr 함수 본문이 상수 표현식 평가 규칙을 만족하는지 검사합니다. 만족하지 않는 연산이 constexpr 평가 중에 실행되면 해당 호출은 상수 표현식으로 인정되지 않습니다.
여기서 많은 사람이 오해하는 지점이 하나 있습니다. 컴파일 타임 평가가 보장되는 것은 “상수 표현식이 필요한 문맥”뿐입니다. 위의 constexpr int a = sq(3);, static_assert, 배열 크기, 템플릿 인자가 그런 문맥입니다. 반면 int c = sq(3);처럼 결과를 일반 변수에 담으면, 인자가 리터럴이더라도 컴파일러는 런타임 호출로 둘 수 있습니다. 최적화 빌드에서는 대부분 상수로 접히지만 -O0 디버그 빌드에서는 실제로 함수 호출이 남습니다. “constexpr로 바꿨는데 디버그 빌드가 여전히 느리다”는 경우의 원인이 대개 이것입니다. 확실히 컴파일 타임에 계산하려면 결과를 constexpr 변수로 받거나, 뒤에서 설명할 consteval을 써야 합니다.
또 하나의 흔한 실수는 C++20의 std::is_constant_evaluated()를 if constexpr 조건에 넣는 것입니다. if constexpr의 조건 자체가 상수 문맥이므로 이 함수는 항상 true를 반환하고, 런타임 분기는 영원히 선택되지 않습니다. GCC는 이 경우 'std::is_constant_evaluated' always evaluates to true in 'if constexpr' 경고를 냅니다. 일반 if를 쓰거나, C++23의 if consteval을 쓰는 것이 올바른 형태입니다.
C++20 이후: 더 넓어진 constexpr 세계
C++20에서는 virtual의 constexpr, try/catch의 제한적 허용, std::vector 등의 constexpr 할당(컴파일러·라이브러리 지원 전제) 등으로 컴파일 타임 알고리즘 표현력이 크게 늘었습니다. 이는 “런타임과 컴파일 타임에 동일한 코드 경로”를 유지하려는 현대 C++ 스타일과 잘 맞습니다.
실무 체크리스트
- 헤더 정의:
constexpr함수는 기본적으로 인라인 의미가 있어, 동일 정의를 여러 TU에 두는 패턴과 잘 맞습니다. - ABI: constexpr이어도 런타임 호출이 가능하면 바이너리에 심볼이 남을 수 있습니다. 완전한 헤더 전용·컴파일 타임 전용이 목표면
consteval또는 익명 네임스페이스/내부 링크 전략을 검토합니다. - 예외·IO: 프로젝트 타깃 표준에서 허용 여부를 확인하며, constexpr 컨텍스트에서 금지된 연산이 끼어들지 않게 합니다.
if constexpr와 분기 제거(Branch elimination)
if constexpr는 템플릿 문맥에서 조건이 컴파일 타임 상수일 때, 선택되지 않은 분기를 인스턴스화에서 제외합니다. 이는 “죽은 코드 제거”와 비슷해 보이지만, 템플릿 인스턴스화 규칙과 직접 연결된다는 점이 다릅니다.
#include <type_traits>
#include <string>
template <typename T>
auto as_string(T v) {
if constexpr (std::is_same_v<T, std::string>) {
return v;
} else {
return std::to_string(v); // T가 std::string일 때 이 줄은 인스턴스화되지 않음
}
}
거짓 분기(discarded statement)의 의미
조건이 거짓이면 해당 분기의 문장은 인스턴스화되지 않습니다. 따라서 T에 따라 한쪽 분기만 타입 검사를 받습니다. 일반 if였다면 양쪽 모두 유효한 템플릿 코드여야 해서, 타입마다 존재하지 않는 멤버 호출 같은 문제가 생깁니다.
주의할 점
- 분기를 “버리는” 효과는 템플릿 안에서만 생깁니다. 비템플릿 함수에서도
if constexpr를 쓸 수는 있지만, 버려진 분기도 완전히 타입 검사를 받으므로 존재하지 않는 멤버를 호출하면 여전히 컴파일 에러입니다. 템플릿 안에서도 템플릿 인자에 의존하지 않는 코드는 정의 시점에 검사됩니다. - 반환 타입 추론:
auto반환 함수에서 버려진 분기의return은 타입 추론에 참여하지 않습니다. 위as_string이std::string과std::to_string(...)두 경로를 가지면서도 컴파일되는 이유입니다. 일반if였다면 두return의 타입이 달라inconsistent deduction for auto return type에러가 납니다. static_assert(false)함정: “지원하지 않는 타입” 분기에else { static_assert(false, "unsupported type"); }를 쓰면 C++20까지는 템플릿이 인스턴스화되지 않아도 컴파일 에러가 납니다. 조건이 템플릿 인자에 의존하지 않기 때문입니다. 예전에는static_assert(sizeof(T) == 0, ...)이나dependent_false<T>::value같은 우회가 필요했고, C++23(P2593, 여러 컴파일러가 이전 표준 모드에도 소급 적용)부터static_assert(false)가 그대로 허용됩니다.- 조건은 컴파일 타임에 결정되는 bool이어야 합니다(
std::is_same_v등). - “인스턴스화 제외”이지, 전처리기처럼 소스에서 문자가 삭제되는 것은 아닙니다. 가독성과 디버깅 경험은 여전히 중요합니다.
템플릿 특수화 대신 단일 함수로 가독성을 얻는 대표적인 도구이며, 자세한 비교는 if constexpr 글의 예제를 참고하면 좋습니다.
consteval: 즉시(immediate) 함수
C++20의 consteval은 함수가 반드시 컴파일 타임에만 호출되어야 함을 강제합니다. 이런 함수를 immediate function이라 부릅니다.
consteval int triple(int x) { return 3 * x; }
constexpr int a = triple(7); // OK: 컴파일 타임 호출
void f(int n) {
// int b = triple(n); // 오류: n은 컴파일 타임 상수가 아님
(void)n;
}
constexpr와 consteval의 역할 분담
| 키워드 | 컴파일 타임 호출 | 런타임 호출 |
|---|---|---|
constexpr | 가능 | 가능 |
consteval | 가능 | 불가 |
즉, 동일한 코드를 재사용하되 런타임에서도 쓰고 싶다면 constexpr, 컴파일 타임 전용 API를 실수로 런타임에 부르지 않게 막고 싶다면 consteval이 적합합니다.
실무에서의 사용감
- 메타프로그래밍 헬퍼, 해시 상수 테이블, 열거형·문자열을 컴파일 타임에 해석하는 어댑터 등에 사용됩니다.
- “이 함수는 절대 런타임에 호출되면 안 된다”는 요구가 명확할 때 의도를 타입 시스템에 적는 효과가 있습니다.
consteval을 도입하면서 겪기 쉬운 제약도 있습니다. 즉시 함수의 주소는 상수 평가 바깥으로 빠져나갈 수 없어서, consteval 함수를 함수 포인터에 담아 런타임 콜백으로 넘기는 코드는 컴파일되지 않습니다. 또 constexpr 함수 안에서 매개변수를 consteval 함수에 넘기면, 그 매개변수가 상수가 아니므로 C++20에서는 에러가 났습니다. C++23의 P2564(“consteval needs to propagate up”)가 이런 constexpr 함수를 자동으로 즉시 함수로 승격시켜 이 불편을 줄였습니다. std::format의 포맷 문자열 검사가 바로 이 consteval 생성자 메커니즘을 이용합니다. 그래서 std::format(fmt_var, x)처럼 런타임 문자열을 넘기면 에러가 나고, 그럴 때는 std::vformat을 써야 합니다.
C++23에서는 if consteval { ... } else { ... } 문이 추가되어, 같은 함수 안에서 “상수 평가 중이면 이 경로, 아니면 저 경로”를 나눌 수 있게 되었습니다. if consteval 블록 안에서는 consteval 함수를 자유롭게 부를 수 있다는 점이 std::is_constant_evaluated()와의 차이입니다. 프로젝트 표준 버전에 맞춰 참고 자료를 확인하십시오.
프로덕션 const 정확성 패턴
컴파일 타임 계산과 별개로, const 정확성은 API 계약을 컴파일러가 검사하게 만드는 가장 비용 대비 효과가 큰 습관입니다.
읽기 전용 인자: const T&와 const 멤버
대용량 객체는 복사 대신 const T&로 받으며, 객체 상태를 바꾸지 않는 멤버는 const로 선언합니다. 이렇게 하면 const 객체에서 호출 가능한 메서드 집합이 명확해지고, 실수로 상태를 바꾸는 호출이 컴파일 단계에서 걸립니다.
class Buffer {
public:
size_t size() const { return bytes_.size(); }
void append(const std::byte* p, size_t n); // 비const: 내용 변경
private:
std::vector<std::byte> bytes_;
};
void dump(const Buffer& b) {
std::cout << b.size(); // const 경로만 허용
}
헤더 상수: inline constexpr
C++17 이전에는 extern const + 단일 정의 등으로 ODR을 맞추는 경우가 많았습니다. 이제 헤더에서는 다음 패턴이 일반적입니다.
// constants.hpp
inline constexpr int kDefaultPort = 8080;
inline constexpr std::string_view kServiceName = "pkglog";
inline 변수는 프로그램 전체에서 단일 엔티티로 링크되며, 여러 TU에 정의를 두어도 됩니다.
그렇다면 inline 없이 constexpr int kDefaultPort = 8080;만 헤더에 두면 어떻게 될까요? 네임스페이스 범위의 const(따라서 constexpr) 변수는 내부 링크를 가지므로 링크 에러는 나지 않습니다. 대신 이 헤더를 포함한 TU마다 별도의 복사본이 생깁니다. int 상수라면 대부분 값으로 접혀서 차이가 없지만, std::string_view나 배열 테이블처럼 주소를 취하거나 크기가 큰 상수라면 TU 수만큼 바이너리에 중복되고, TU마다 &kServiceName이 다르게 나옵니다. 인라인 함수 안에서 이런 상수의 주소를 쓰면 엄밀히는 ODR 위반이 되기도 합니다. inline constexpr은 이 문제를 한 줄로 없애 줍니다. 클래스의 static constexpr 데이터 멤버는 C++17부터 자동으로 inline이므로, 예전처럼 .cpp 파일에 constexpr int Foo::kMax; 정의를 따로 두지 않아도 undefined reference to 'Foo::kMax' 링크 에러가 나지 않습니다.
“불변 참조”와 포인터 읽는 법
팀 코딩 규칙으로 const T*, T const*, const T& 스타일을 통일하면 리뷰 부담이 줄어듭니다. C++ const 에러: passing as const 컴파일 에러 원인과 const 정합성 글의 표를 팀 가이드에 인쇄해 두는 것도 좋습니다.
constexpr·consteval과 테스트
컴파일 타임에 성질을 검증하려면 static_assert와 constexpr/consteval 함수를 함께 쓰는 패턴이 유효합니다. 런타임 테스트와 중복되더라도, 수학적 불변식을 빌드 타임에 고정하면 회귀 비용이 줄어듭니다.
constexpr int day_seconds() { return 24 * 60 * 60; }
static_assert(day_seconds() == 86400);
자주 묻는 질문 (FAQ)
Q. consteval과 constexpr 함수는 어떤 기준으로 나눠 쓰나요?
A. constexpr 함수는 상수 문맥에서는 컴파일 타임에, 그렇지 않으면 런타임에 실행될 수 있어 같은 로직을 두 곳에서 재사용할 때 적합합니다. consteval 함수는 모든 호출이 컴파일 타임에 평가되어야 하며, 런타임 값을 인자로 넘기면 컴파일 에러가 납니다. 포맷 문자열 검증이나 해시 상수 생성처럼 반드시 빌드 단계에서 끝나야 하는 계산은 consteval로, 런타임 입력에도 쓰여야 하는 계산은 constexpr로 두면 역할이 분명해집니다.
같이 보면 좋은 글
- C++ const 에러: passing as const 컴파일 에러 원인과 const 정합성
- C++ constexpr 함수: 컴파일 타임 평가 조건과 C++11·14·17 제약 변화
- C++ if constexpr | ‘컴파일 타임 if’ 가이드
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++ 동적 초기화와 정적 초기화 순서 문제: Meyers 싱글톤·constinit으로 해결하기