요약
저희 팀은 네이버의 고성능 검색 엔진 기술을 계승한 분산 데이터베이스인 Dot을 개발하고 있습니다. 스토리지 계층을 직접 다루다 보니 디스크에서 읽어 온 바이트 패턴을 uint64_t, float, 혹은 사용자 정의 구조체로 해석하는 일이 일상이고, 코드 리뷰에서 reinterpret_cast를 마주치는 일도 잦습니다. 볼 때마다 느끼는 불안함, 즉 '이것은 되도록 쓰지 말라고 배웠는데 괜찮을까?' 하는 감정은 C++ 개발자라면 공감하실 것입니다.
C++20에서 등장한 std::bit_cast는 그 불안에 대한 해답처럼 보였습니다. 표준 라이브러리가 제공하는 함수라는 점, 위험해 보이는 타입 퍼닝(type punning)까지 척척 해낸다는 점이 '이것은 안전하다'라는 착각을 심어 주었습니다. 그 착각이 퍼지면서 팀은 std::bit_cast가 주는 안도감에 젖어 들었고, 예전 같으면 reinterpret_cast를 썼을 자리를 std::bit_cast가 차지하기 시작했습니다.
그러다 어느 날, 다음과 같은 코드를 보았습니다.
const uint8_t* src = ...;
float* dst = std::bit_cast<float*>(src); // 컴파일 통과, 경고 없음
const를 아무런 경고 없이 제거해 버리는 코드였습니다. 뭔가 잘못되었다는 것은 직감했지만, '정확히 왜 잘못되었는가'를 설명하지 못했습니다. std::bit_cast와 reinterpret_cast의 정확한 의미론(semantics)을 몰랐기 때문에, 잘못되었다는 말을 꺼내기가 어려웠습니다.
이 글은 그 경험에서 시작되었습니다. 저희는 std::bit_cast를 제대로 이해하지 못한 채 쓰고 있었고, reinterpret_cast는 막연히 두려워하고만 있었습니다. 둘 다 제대로 알아야 했습니다. 바이트 패턴을 C++ 타입으로 해석할 때, std::bit_cast와 reinterpret_cast 중 무엇을 써야 할까요? 이 질문에 명확히 답하기 위해, 두 캐스트의 의미론과 그 배경에 있는 엄격한 앨리어싱 규칙(strict aliasing rule), 포인터와 정수 간 변환을 정리합니다. 이 내용만 알아도 reinterpret_cast를 두려움 없이 올바르게 쓸 수 있습니다.
reinterpret_cast에는 객체 수명(object lifetime)이라는 더 깊은 규칙이 있습니다. 하지만 실무에서는 몰라도 문제가 되지 않으므로, 해당 내용은 다음 글(예정)에서 다룹니다. 더 깊은 내용이 궁금하신 분만 읽어 보시기를 권합니다.
타입 퍼닝: std::bit_cast는 어떤 문제를 풀기 위해 만들어졌는가
타입 퍼닝은 어떤 값의 비트 패턴을 다른 타입으로 재해석하는 작업입니다. 예를 들어 uint32_t 값 0x41480000의 비트 패턴을 IEEE 754 float로 해석하면 12.5f가 됩니다. 비트 조작, 직렬화, 해시 함수 등에서 흔하게 쓰이는 작업입니다.
이처럼 간단해 보이는 작업을 C++에서 올바르게 수행하는 방법은 의외로 까다롭습니다. 여러 시도를 살펴보겠습니다.
시도 1: reinterpret_cast
uint32_t i = 0x41480000;
float f = *reinterpret_cast<float*>(&i); // UB!
가장 직관적인 방법이지만 미정의 동작(UB, undefined behavior)입니다. uint32_t 객체를 float*로 접근하는 것은 엄격한 앨리어싱 규칙 위반입니다. 컴파일러는 float*와 uint32_t*가 같은 메모리를 가리키지 않는다고 가정할 수 있으므로, 최적화 과정에서 예상과 다른 결과가 나올 수 있습니다(엄격한 앨리어싱 규칙의 자세한 내용은 엄격한 앨리어싱 규칙에서 다룹니다).
시도 2: union
union { uint32_t i; float f; } u;
u.i = 0x41480000; // i가 active member가 됨
float f = u.f; // f는 비활성 멤버 → C++에서는 UB!(C에서는 OK)
C에서는 합법적인 타입 퍼닝 방법이지만, C++에서는 미정의 동작입니다. C++의 union은 한 번에 하나의 멤버만 활성(active) 상태일 수 있으며, 비활성 멤버를 읽는 것은 허용되지 않습니다1. 많은 컴파일러가 비표준 확장으로 이를 지원하지만, 표준을 준수하는 코드는 아닙니다.
시도 3: memcpy(정답)
uint32_t i = 0x41480000;
float f;
std::memcpy(&f, &i, sizeof(f)); // Well-defined
이것이 C++에서 올바른 타입 퍼닝 방법입니다. memcpy는 바이트 단위로 복사합니다2. 원본 uint32_t 객체와 별개인 float 객체에 비트를 복사하므로, 엄격한 앨리어싱 규칙 위반이 발생하지 않습니다. 동일한 메모리를 두 가지 타입으로 동시에 접근하는 것이 아니라 새로운 객체에 비트를 옮기는 방식이기 때문입니다.
'그러면 실제로 memcpy 함수 호출이 발생해서 느리지 않을까?'라고 생각하실 수 있지만, 현대 컴파일러는 이 패턴을 인식하여 memcpy 호출을 완전히 제거하고 레지스터 이동으로 최적화합니다. 생성되는 어셈블리는 reinterpret_cast 버전과 사실상 동일합니다.
std::bit_cast: memcpy의 구문 편의(syntax sugar)
uint32_t i = 0x41480000;
float f = std::bit_cast<float>(i); // Well-defined, constexpr 가능
std::bit_cast는 본질적으로 memcpy와 동일한 의미론을 지닙니다. 원본의 객체 표현(object representation), 즉 비트를 새로운 타입의 객체에 복사합니다. C++20에 도입되었습니다3.
단, std::bit_cast<To>(from)는 sizeof(To) == sizeof(From) 조건을 만족하고 To와 From이 모두 간단 복사 가능(trivially copyable)한 타입일 때만 사용할 수 있습니다.
memcpy 대비 std::bit_cast의 이점은 다음과 같습니다.
constexpr지원:memcpy는constexpr문맥에서 사용할 수 없지만,std::bit_cast는 일부 제약 하에4 컴파일 타임 평가가 가능합니다.- 기본 생성 가능(default-constructible) 조건 불필요:
memcpy를 쓰려면 대상 타입의 변수를 먼저 선언해야 하지만,std::bit_cast는 바로 값을 반환합니다. - 의도의 명확성: '이 비트 패턴을 다른 타입으로 재해석한다'라는 의도가 코드에 명확하게 드러납니다.
타입 퍼닝 정리: std::bit_cast는 값 대 값 도구이다
std::bit_cast는 값 대 값(value-to-value) 타입 퍼닝 도구입니다. 포인터에 std::bit_cast를 쓰면 포인터 값(주소)의 비트를 복사하는 것이지, 가리키는 대상을 변환하는 것이 아닙니다. 결과 포인터를 역참조(dereference)하면 reinterpret_cast와 동일한 엄격한 앨리어싱 규칙 위반이 발생합니다5. 또한 비트를 그대로 복사하므로 const T*를 T*로 변환하는 것도 컴파일러 경고 없이 통과합니다.
다음 다이어그램은 네 가지 타입 퍼닝 방법의 메모리 관점 차이를 보여줍니다.
reinterpret_cast를 올바르게 쓰기 위해 알아야 할 것들
타입 퍼닝에는 std::bit_cast를 쓰면 됩니다. 그렇다면 reinterpret_cast는 언제 써야 할까요? 포인터 및 참조6 타입 변환이 필요할 때입니다. 올바르게 사용하려면 엄격한 앨리어싱 규칙을 이해해야 합니다.
reinterpret_cast와 static_cast
본격적으로 엄격한 앨리어싱 규칙을 이야기하기 전에, reinterpret_cast를 사용한 포인터 변환이 무슨 뜻인지 짚고 넘어가겠습니다. 표준은 포인터 reinterpret_cast<U*>(p)를 static_cast<U*>(static_cast<void*>(p))로 정의합니다7. 즉 void*를 경유하는 두 단계의 static_cast와 같습니다. 따라서 malloc 반환값처럼 이미 void*가 있다면 static_cast<T*> 한 번이면 충분하고, reinterpret_cast를 쓸 필요가 없습니다.
reinterpret_cast는 void*를 거치지 않고 서로 무관한 포인터 타입을 직접 변환할 때, 즉 이 두 단계를 한 줄로 쓰고 싶을 때 사용합니다.
동일한 변환이므로 엄격한 앨리어싱 규칙은 어느 캐스트를 쓰든 동일하게 적용됩니다. 이후 설명에서는 간결성을 위해 reinterpret_cast로 통일하겠습니다.
엄격한 앨리어싱 규칙
프로그램이 객체에 저장된 값을 해당 객체에 대해 type-accessible하지 않은 glvalue(generalized lvalue)로 접근하면 미정의 동작이 발생합니다8. 이것이 엄격한 앨리어싱 규칙입니다.
type-accessible 타입은 다음 중 하나에 해당하는 경우를 말합니다9.
- 객체의 동적 타입 자체(cv-qualified 포함)
- signed/unsigned 대응 타입
char,unsigned char,std::byte(어떤 타입이든 바이트 단위 접근은 항상 허용)
따라서 uint32_t 객체를 float*로 접근하는 것은 위 예외에 해당하지 않으므로 엄격한 앨리어싱 규칙 위반이 됩니다.
이 규칙이 존재하는 이유는 타입 기반 앨리어싱 분석(TBAA, type-based alias analysis) 최적화를 가능하게 하기 위해서입니다. 컴파일러는 '서로 다른 타입의 포인터는 같은 메모리를 가리키지 않는다'라고 가정하고, 이를 바탕으로 load/store 재배치, 레지스터 캐싱, 불필요한 메모리 재로드 제거 등의 최적화를 수행합니다.
이 규칙이 실제로 어떤 차이를 만드는지 살펴보겠습니다. 다음 다이어그램은 TBAA 최적화가 엄격한 앨리어싱 규칙 위반 코드에서 어떻게 버그를 만드는지 보여줍니다.
다이어그램 왼쪽의 foo() 함수는 *i와 *f에 각각 1과 0을 쓰고 *i를 다시 읽어 반환합니다. TBAA 최적화에서 float*와 int*는 엄격한 앨리어싱 규칙에 의해 같은 메모리를 가리킬 수 없으므로, 컴파일러는 *f = 0.f가 *i의 값에 영향을 주지 않는다고 가정합니다.
그 결과, return *i에서 메모리를 다시 읽지 않고 상수 1을 직접 반환합니다. 최적화 옵션으로 컴파일해 보면 다이어그램 오른쪽과 같은 어셈블리가 생성되는 것을 확인할 수 있습니다. 이 함수를 foo(reinterpret_cast<float*>(&x), &x)처럼 호출하면, 프로그래머는 *f = 0.f가 x를 덮어쓰므로 0이 반환되기를 기대하겠지만 컴파일러는 1을 반환할 수 있습니다.
엄격한 앨리어싱 규칙 위반의 결과는 단순히 잘못된 값에 그치지 않고 미정의 동작으로 이어진다는 점을 명심해야 합니다10.
reinterpret_cast 자체는 UB가 아니다
여기서 중요한 구분이 있습니다. reinterpret_cast로 포인터 타입을 변환하는 것 자체는 미정의 동작이 아닙니다7. 미정의 동작이 발생하는 시점은 변환 결과를 역참조할 때입니다.
int i = 42;
float* fp = reinterpret_cast<float*>(&i); // OK — 포인터 값 보존
float f = *fp; // UB! — 엄격한 앨리어싱 규칙 위반
그렇다면 reinterpret_cast 결과를 역참조해도 안전한 경우는 언제일까요? 앞서 정리한 type-accessible 타입에 해당할 때입니다.
int i = 42;
// OK: char, unsigned char, std::byte로 접근
char* cp = reinterpret_cast<char*>(&i);
char c = *cp; // OK
// OK: signed ↔ unsigned 변환
unsigned int* up = reinterpret_cast<unsigned int*>(&i);
unsigned int u = *up; // OK
포인터 ↔ 정수 변환
reinterpret_cast의 또 다른 합법적 용도는 포인터와 정수 간 변환입니다7.
reinterpret_cast<uintptr_t>(ptr): 포인터 값을 정수로 변환(결과는 implementation-defined)reinterpret_cast<T*>(integer): 정수를 포인터로 변환- 왕복 변환(round-trip) 보장: 포인터 → 정수 → 같은 타입의 포인터 변환 시 원래 값이 보존됨
std::bit_cast<uintptr_t>(ptr)도 기술적으로는 동작할 수 있습니다. sizeof(uintptr_t) == sizeof(void*)이고 양쪽 모두 간단 복사 가능 타입이면 비트가 그대로 복사되며, 현대의 플랫 메모리(flat-memory) 플랫폼에서는 이 조건이 성립하므로 reinterpret_cast와 동일한 결과를 냅니다. 다만 C++ 표준은 sizeof(uintptr_t) == sizeof(void*)를 보장하지 않으므로11 std::bit_cast를 쓸 수 있다고 할 수는 없습니다. 이 외에도 reinterpret_cast를 써야 하는 이유가 더 있습니다.
- 명시적 표준 보장:
reinterpret_cast의 왕복 변환은 표준이 직접 보장합니다. 반면std::bit_cast의 왕복 변환은sizeof가 동일하고 비트가 보존된다는 플랫폼 의존적 가정에 기대고 있습니다. - 포인터 provenance: 포인터에는 어느 할당에서 유래했는지를 나타내는 메타데이터(출처, provenance)가 있습니다.
reinterpret_cast가 이 provenance를 보존하도록 표준화하려는 시도가 진행 중이지만,std::bit_cast는 그렇지 않습니다12. - 의미적 명확성:
reinterpret_cast는 이 용도를 위해 표준이 설계한 도구입니다.
엄격한 앨리어싱 규칙과 포인터 ↔ 정수 변환, 이 두 가지를 알면 실무에서 reinterpret_cast를 안전하게 사용할 수 있습니다. 사실 reinterpret_cast에는 객체 수명(object lifetime)이라는 더 깊은 규칙도 관여하지만, 자세한 내용은 다음 글에서 다룹니다.
정리: 언제 std::bit_cast, 언제 reinterpret_cast?
상황에 맞는 올바른 도구를 정리하면 다음과 같습니다.
| 상황 | 올바른 도구 |
|---|---|
| 값의 비트 패턴을 다른 타입으로 해석(타입 퍼닝) | std::bit_cast |
| 포인터 타입 변환 후 역참조(strict aliasing 준수 범위 내) | reinterpret_cast |
char*/std::byte*로 바이트 단위 접근 | reinterpret_cast |
| 포인터 ↔ 정수 변환 | reinterpret_cast |
다음 플로차트는 캐스팅이 필요할 때 어떤 도구를 선택해야 하는지 보여줍니다.
둘의 경계는 명확합니다. 비트 패턴을 다른 타입의 값으로 재해석하고 싶으면 std::bit_cast, 포인터가 가리키는 대상의 타입을 바꿔서 접근해야 하면 reinterpret_cast입니다. std::bit_cast를 포인터에 쓰면 엄격한 앨리어싱 규칙 위반과 const 우회라는 두 가지 함정에 빠지고, reinterpret_cast를 타입 퍼닝에 쓰면 마찬가지로 엄격한 앨리어싱 규칙을 위반합니다. 각 도구가 설계된 영역 안에서 사용하면 미정의 동작 없이 의도가 명확한 코드를 쓸 수 있습니다.
표준이 '비활성 멤버 읽기는 UB'라고 직접 명시하지는 않는다. union에서 한 번에 최대 하나의 멤버만 활성(수명이 진행 중)일 수 있고(class.union), 수명이 시작되지 않은 객체에 접근하면 UB이므로(basic.life), 이 두 규칙의 조합으로 도출된다. 객체 수명에 대해서는 다음 편에서 자세히 다룬다. ↩
memcpy의 바이트 복사 의미론: string.byte ↩
std::bit_cast는 constexpr로 선언되어 있지만, To·From 또는 그 하위 객체 중 union, 포인터, 멤버 포인터, volatile 한정 타입, 참조 타입 멤버가 포함되면 상수 평가가 불가능하다(bit.cast). ↩
Clang-Tidy bugprone-bitwise-pointer-cast 검사가 이 패턴을 잡아낸다. ↩
참조에 대한 reinterpret_cast는 포인터 변환과 동일한 의미론을 갖는다. reinterpret_cast(x)는 *reinterpret_cast(&x)와 같다(expr.reinterpret.cast). ↩
엄격한 앨리어싱 규칙: basic.lval ↩
이외에도 cv-qualified 베이스 클래스 타입, aggregate나 union의 멤버 타입 등의 예외가 있다. 다만 aggregate는 첫 번째 멤버만 주소 레이아웃이 보장되므로 포인터 캐스팅 시 주의가 필요하다. 전체 규칙은 basic.lval 참고. ↩
-fno-strict-aliasing 컴파일러 옵션은 TBAA 최적화를 비활성화하여 엄격한 앨리어싱 규칙 위반 코드가 '동작하게' 만든다. 하지만 코드 자체는 여전히 표준상 UB이며, 이 옵션 없이 컴파일하면 문제가 발생할 수 있다. ↩
표준은 uintptr_t에 대해 void* 왕복 변환만 보장하며(cstdint.syn), sizeof(uintptr_t) == sizeof(void*)는 보장하지 않는다. uintptr_t가 포인터보다 클 수 있으며, 그 경우 std::bit_cast는 컴파일되지 않기 때문이다. 다만 현실적으로 주요 플랫폼(x86-64, ARM64 등)에서는 크기가 동일하다. ↩
현재 C++ 표준은 provenance를 정의하지 않는다. reinterpret_cast는 포인터 → 정수 → 포인터 왕복 변환에서 '원래 값을 갖는다'라고 보장하지만(expr.reinterpret.cast), 그 '값'에 provenance가 포함되는지는 불명확하다. std::bit_cast에는 이 왕복 변환 보장 자체가 없다. 이 불명확성의 해소는 P2434R4(C++29 목표)에서 진행 중이다. ↩