C++ const·constexpr 내부 동작 | 평가 시점·if constexpr

이 글의 핵심

const는 ‘읽기 전용’을 보장하고, constexpr는 ‘컴파일 타임에 값·호출이 유효할 수 있음’을 표시합니다. 이 글에서는 두 키워드의 평가 시점, constexpr 함수 요건, if constexpr의 버려진 분기, C++20 consteval, 실무 const 패턴을 표준과 컴파일러 동작에 맞춰 설명합니다.

C++에서 const와 constexpr는 자주 함께 등장하지만, 의미와 컴파일러가 수행하는 검사는 크게 다릅니다. const는 주로 객체의 불변성과 오버로드 해석(특히 멤버 함수)에 관여하고, constexpr는 함수·변수가 상수 표현식(constant expression) 문맥에서 쓰일 수 있는지, 그리고 필요할 때 컴파일 타임에 값을 고정할 수 있는지를 표시합니다. 기초적인 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() {
  int arr[a];      // OK: a는 상수 표현식으로 초기화된 const 정수
  // 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 xconstexpr T x
초기화상수·비상수 식 모두 가능(비상수면 런타임)상수 표현식만 허용
용도불변 보장, API 읽기 전용컴파일 타임 상수, 템플릿 인자·배열 크기 등
상수 문맥에서 사용정수·열거형이고 상수로 초기화된 경우만항상 가능

변수에 한정하면 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에서는 constexpr 가상 함수, constexpr 함수 안의 try 블록(상수 평가 중 실제로 throw하는 것은 여전히 불가), 상수 평가 안에서 해제까지 끝나는 동적 할당(std::vector, std::string을 constexpr 함수 안에서 쓰는 것) 등이 허용되어 컴파일 타임 알고리즘의 표현력이 크게 늘었습니다. 다만 상수 평가 중에 할당한 메모리는 평가가 끝나기 전에 해제되어야 하므로, constexpr std::vector<int> v{1, 2, 3};처럼 결과를 constexpr 변수로 남기는 것은 여전히 안 됩니다.

constexpr 함수는 암시적으로 inline이므로 헤더에 정의해 여러 번역 단위(TU)에서 포함해도 ODR 위반이 아닙니다. 대신 런타임 호출이 가능한 이상 일반 인라인 함수처럼 바이너리에 코드가 남을 수 있고, 컴파일 타임 전용으로 쓰고 싶다면 뒤에서 볼 consteval이 맞습니다.

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였다면 양쪽 모두 유효한 코드여야 해서, T = std::string일 때 std::to_string(std::string) 같은 존재하지 않는 오버로드 호출 때문에 컴파일이 실패합니다.

주의할 점

분기를 버리는 효과는 템플릿 안에서만 생깁니다. 비템플릿 함수에서도 if constexpr를 쓸 수는 있지만, 버려진 분기도 완전히 타입 검사를 받으므로 존재하지 않는 멤버를 호출하면 여전히 컴파일 에러입니다. 템플릿 안에서도 템플릿 인자에 의존하지 않는 코드는 정의 시점에 검사됩니다.

반환 타입 추론에도 영향이 있습니다. auto 반환 함수에서 버려진 분기의 return은 타입 추론에 참여하지 않습니다. 위 as_string<int>가 int를 돌려주는 경로와 std::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로 변환되는 상수 표현식이어야 하며, 버려진 분기도 전처리기처럼 소스에서 사라지는 것이 아니라 파싱은 됩니다. 그래서 #ifdef와 달리 문법적으로 올바른 코드여야 합니다.

템플릿 특수화 대신 단일 함수로 가독성을 얻는 대표적인 도구이며, 자세한 비교는 if constexpr 글의 예제를 참고하면 좋습니다.

consteval: 즉시(immediate) 함수

C++20의 consteval은 함수가 반드시 컴파일 타임에만 호출되어야 함을 강제합니다. 이런 함수를 즉시 함수(immediate function)라 부릅니다.

constexpr와 consteval 함수에 런타임 값을 넘겼을 때의 차이

그림: 실제 실행 결과 — constexpr 함수는 런타임 인자도 받아 일반 함수처럼 동작하지만, consteval 함수에 런타임 값(argc)을 넘기면 바로 컴파일 에러가 된다. (환경: Ubuntu 24.04 · g++ 13.3)

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 용도입니다(cppreference의 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 정확성(const correctness)은 API 계약을 컴파일러가 검사하게 만드는 비용이 적은 습관입니다.

읽기 전용 인자: const T&와 const 멤버

대용량 객체는 복사 대신 const T&로 받으며, 객체 상태를 바꾸지 않는 멤버는 const로 선언합니다. 이렇게 하면 const 객체에서 호출 가능한 메서드 집합이 명확해지고, 실수로 상태를 바꾸는 호출이 컴파일 단계에서 걸립니다.

#include <cstddef>
#include <iostream>
#include <vector>

class Buffer {
 public:
  std::size_t size() const { return bytes_.size(); }
  void append(const std::byte* p, std::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
#include <string_view>

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 읽는 법

const T*와 T const*는 같은 뜻(가리키는 대상이 const)이고, T* const는 포인터 자체가 const입니다. 팀 코딩 규칙으로 한 가지 표기를 정해 두면 리뷰할 때 헷갈리지 않습니다. 자세한 표는 C++ const 에러: passing as const 컴파일 에러 원인과 const 정합성 글에 있습니다.

constexpr 함수와 static_assert로 불변식 고정하기

constexpr 함수의 성질은 static_assert로 빌드 타임에 검증할 수 있습니다. 런타임 테스트와 겹치더라도, 상수 테이블이나 단위 변환 같은 불변식이 깨지면 컴파일 단계에서 바로 드러납니다.

constexpr int day_seconds() { return 24 * 60 * 60; }
static_assert(day_seconds() == 86400);