C++ std::variant vs union: 타입 안전성과 비용 비교
이 글의 핵심
여러 타입 중 하나를 저장할 때 union을 쓰면 std::string 같은 비자명 타입의 생성자와 소멸자를 직접 관리해야 합니다. variant가 추가로 쓰는 메모리와 접근 비용을 비교하고, C 호환 구조체나 비트 조작처럼 여전히 union이 적합한 경우를 구분해 선택 기준을 정리합니다.
여러 타입 중 하나를 담는 두 가지 방법
C++에서 여러 타입 중 하나를 저장하려면 union이나 C++17의 std::variant를 사용합니다. 둘 다 가장 큰 대안 타입만큼의 공간을 여러 타입이 공유한다는 점은 같지만, union은 지금 어떤 멤버가 들어 있는지 기록하지 않고 std::variant는 그 정보(인덱스)를 함께 저장합니다. 이 차이가 타입 안전성, 생성자·소멸자 처리, 크기의 차이로 이어집니다.
| 관점 | std::variant | union |
|---|---|---|
| 접근 비용 | 인덱스 검사 분기 하나 | 검사 없음 |
| 활성 타입 추적 | index(), holds_alternative, visit로 자동 | 프로그래머가 따로 기억해야 함 |
| 주로 쓰는 곳 | 현대 C++ 도메인 모델 | C 연동, 저수준 라이브러리 구현, 레거시 |
// union: 활성 멤버를 기록하지 않음
union Data {
int i;
double d;
char c;
};
Data u;
u.i = 42;
std::cout << u.d << '\n'; // 미정의 동작: 활성 멤버는 i
// std::variant: 어떤 타입이 들어 있는지 기록함
std::variant<int, double, char> v;
v = 42; // int 저장
// std::cout << std::get<double>(v); // std::bad_variant_access 예외
std::cout << std::get<int>(v) << '\n'; // 42
u.d를 읽는 줄이 문제인 이유는 C++에서 union은 마지막으로 값을 쓴 멤버(활성 멤버)만 읽을 수 있기 때문입니다. 다른 멤버를 읽으면 정의되지 않은 동작이라, 컴파일러에 따라 쓰레기 값이 나오기도 하고 최적화 과정에서 코드가 예상과 다르게 바뀌기도 합니다. union 자체는 지금 어떤 멤버가 활성인지 기록하지 않으므로, 그 정보를 따로 들고 다니는 것은 전적으로 프로그래머의 책임입니다. std::variant는 이 “지금 무엇이 들어 있는가”를 나타내는 인덱스를 내부에 함께 저장하고, 잘못된 타입으로 꺼내려 하면 std::bad_variant_access 예외를 던져 조용한 오동작 대신 즉시 실패하게 만듭니다.
활성 타입을 누가 기억하는가
| 항목 | union | std::variant |
|---|---|---|
| 잘못된 타입으로 읽기 | 미정의 동작 | 예외 또는 nullptr로 감지 |
| 생성자/소멸자 | 직접 호출해야 함 | 자동으로 호출 |
| 활성 타입 추적 | 수동 | 자동 |
비자명 타입(std::string 등) | 가능하지만 특수 멤버 함수를 직접 작성 | 그대로 사용 |
| 방문(visit) | 없음 | std::visit |
| C 호환 레이아웃 | 있음 | 없음 |
| 크기 | 가장 큰 멤버 | 가장 큰 대안 + 인덱스 + 패딩 |
| 표준 | 모든 버전 | C++17 이후 |
union으로 같은 일을 하려면 태그를 직접 둡니다.
enum class DataType { Int, Double };
struct TaggedData {
DataType type;
union {
int i;
double d;
};
};
TaggedData t{DataType::Int};
t.i = 42;
if (t.type == DataType::Int) {
std::cout << t.i << '\n';
}
태그를 갱신하는 것을 잊거나, 태그를 확인하지 않고 읽어도 컴파일러는 아무 말도 하지 않습니다. std::variant는 같은 정보를 자동으로 관리합니다.
std::variant<int, double, std::string> data;
data = 42; // int
data = 3.14; // double
data = std::string("Hello"); // string
if (std::holds_alternative<int>(data)) {
std::cout << std::get<int>(data) << '\n';
}
try {
std::cout << std::get<double>(data) << '\n';
} catch (const std::bad_variant_access& e) {
std::cout << "잘못된 타입: " << e.what() << '\n';
}
// get_if: 타입이 다르면 nullptr 반환
if (auto* ptr = std::get_if<std::string>(&data)) {
std::cout << *ptr << '\n'; // "Hello"
}
생성자·소멸자를 가진 타입을 담을 때
union: std::string 멤버의 생성과 소멸을 직접 호출
C++11의 “제약 없는 union(unrestricted union)” 규칙 덕분에 std::string 같은 멤버를 union 안에 선언하는 것 자체는 가능합니다. 하지만 컴파일러는 어떤 멤버를 생성하고 소멸해야 할지 모르기 때문에, 그런 멤버가 있으면 union의 기본 생성자, 소멸자, 복사·이동 연산을 삭제된 것으로 정의합니다. 선언까지는 컴파일되고, 객체를 만드는 순간 에러가 납니다(C++03에서는 선언 자체가 에러였습니다).
union StrOrInt {
int i;
std::string s; // C++11부터 선언은 가능
};
// StrOrInt x; // error: use of deleted function 'StrOrInt::StrOrInt()'
그래서 직접 쓰려면 태그를 두고, 생성자에서 placement new로 해당 멤버를 만들고, 소멸자에서 태그를 보고 직접 소멸자를 호출해야 합니다.
#include <memory>
#include <new>
#include <string>
class SafeData {
public:
explicit SafeData(int value) : type_(Type::Int) { storage_.i = value; }
explicit SafeData(const std::string& value) : type_(Type::String) {
new (&storage_.s) std::string(value); // placement new로 활성 멤버 생성
}
~SafeData() {
if (type_ == Type::String) {
std::destroy_at(&storage_.s); // 활성 멤버를 직접 소멸
}
}
SafeData(const SafeData&) = delete; // 복사·이동도 태그 조합별로 직접 구현해야 함
SafeData& operator=(const SafeData&) = delete;
private:
enum class Type { Int, String };
union Storage {
Storage() {} // 아무 멤버도 생성하지 않음
~Storage() {} // 소멸은 SafeData가 담당
int i;
std::string s;
};
Type type_;
Storage storage_;
};
union에 사용자 정의 생성자와 소멸자를 두면 삭제된 특수 멤버 함수 문제를 피하면서, 실제 멤버의 생성과 소멸은 바깥 클래스가 태그를 보고 결정하게 됩니다. 이 방식으로 완성하려면 여기에 복사 생성자, 이동 생성자, 복사 대입, 이동 대입을 모두 태그 조합별로 구현해야 하고, 대입 도중 새 멤버의 생성자가 예외를 던지면 이전 멤버는 이미 소멸된 상태가 되는 예외 안전성 문제까지 챙겨야 합니다. std::variant는 바로 이 까다로운 코드를 표준 라이브러리가 대신 작성해 둔 것이라고 보면 됩니다. union 대신 char 배열을 버퍼로 쓰는 방식도 있지만, 그때는 alignas(std::string)으로 정렬을 맞추고 꺼낼 때 std::launder가 필요해져 오히려 더 번거롭습니다.
std::variant: 대입 시 이전 값을 자동으로 소멸
std::variant<int, std::string> data;
data = 42; // int 저장
data = std::string("Hello"); // int를 정리하고 string 생성
// data가 소멸할 때 string 소멸자가 자동으로 호출됨
std::variant를 쓸 때 알아 둘 함정이 몇 가지 있습니다. 첫째, 기본 생성된 variant는 첫 번째 대안 타입을 값 초기화한 상태가 됩니다. 위 코드의 data는 선언 직후 int 0을 담고 있으므로, 첫 번째 타입이 기본 생성자가 없는 타입이면 기본 생성 자체가 컴파일되지 않습니다. 이때는 맨 앞에 std::monostate를 두어 “아직 값 없음” 상태를 표현합니다. 둘째, 대입 도중 새 값의 생성자가 예외를 던지면 variant가 어떤 값도 담지 않은 valueless_by_exception 상태가 될 수 있습니다. 이 상태에서 std::visit을 호출하면 bad_variant_access가 발생하므로, 예외를 던질 수 있는 타입을 담는다면 valueless_by_exception()을 확인하는 코드가 필요할 수 있습니다. 셋째, 참조는 대안 타입이 될 수 없어서 std::variant<int&, double&>는 컴파일되지 않습니다. 원본을 가리켜야 한다면 std::reference_wrapper<int>나 포인터를 담고, 그 대상의 수명은 호출하는 쪽이 책임져야 합니다.
std::visit와 overloaded 패턴
std::visit로 타입별 처리
std::variant<int, double, std::string> data = 42;
std::visit([](const auto& arg) {
using T = std::decay_t<decltype(arg)>;
if constexpr (std::is_same_v<T, int>) {
std::cout << "int: " << arg << '\n';
} else if constexpr (std::is_same_v<T, double>) {
std::cout << "double: " << arg << '\n';
} else if constexpr (std::is_same_v<T, std::string>) {
std::cout << "string: " << arg << '\n';
}
}, data);
// 출력: int: 42
람다를 묶는 overloaded 패턴
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
std::variant<int, double, std::string> data = 3.14;
std::visit(overloaded{
[](int arg) { std::cout << "int: " << arg << '\n'; },
[](double arg) { std::cout << "double: " << arg << '\n'; },
[](const std::string& arg) { std::cout << "string: " << arg << '\n'; }
}, data);
// 출력: double: 3.14
overloaded는 여러 람다를 상속받아 각 람다의 operator()를 한 객체의 오버로드 집합으로 합치는 도우미이고, 두 번째 줄은 C++17의 클래스 템플릿 인자 추론 가이드입니다(C++20부터는 집합체 추론이 표준에 들어와 이 줄을 생략해도 됩니다). if constexpr 방식과 비교한 가장 큰 장점은 빠뜨린 타입이 컴파일 에러로 드러나는 경우가 많다는 점입니다. variant에 새 타입을 추가했는데 방문자에 해당 람다가 없으면 호출할 오버로드를 찾지 못해 컴파일이 실패합니다. 반대로 [](const auto&) 같은 포괄 람다를 넣으면 이 검사가 사라집니다. if constexpr 사슬은 마지막에 else static_assert(always_false<T>) 같은 분기를 두지 않는 한 빠뜨린 타입을 조용히 무시합니다.
오버로드 해석 규칙 때문에 생기는 미묘한 문제도 있습니다. 위 방문자에 int와 double 람다가 모두 있으므로 정확히 맞는 쪽이 선택되지만, double 람다만 있고 variant에 int가 들어 있으면 암시적 변환으로 double 람다가 호출됩니다. 비슷하게 C++17에서 std::variant<bool, std::string> v = "hello";는 문자열 리터럴이 std::string이 아니라 bool로 변환되어 true가 저장되는 유명한 함정이 있었고, C++20에서 좁히는 변환을 막도록 규칙이 수정되었습니다(P0608). 문자열을 담을 때는 std::string("hello")나 "hello"s로 타입을 명시하는 습관이 안전합니다.
방문자는 값만 받고 인덱스를 받지 않으므로, 인덱스가 필요하면 index()를 캡처해서 씁니다.
std::visit([idx = data.index()](const auto& arg) {
std::cout << "인덱스: " << idx << ", 값: " << arg << '\n';
}, data);
std::visit에 넘기는 람다들은 반환 타입이 모두 같아야 합니다. C++20에서는 반환 타입을 명시하는 std::visit<R>(...)이 추가되어 람다마다 반환 타입이 달라도 R로 맞출 수 있고, C++26에는 data.visit(visitor)처럼 멤버 함수로 호출하는 형태가 추가되었습니다.
크기·접근 속도·메모리 레이아웃 비교
접근 비용
#include <benchmark/benchmark.h>
#include <variant>
static void BM_Union(benchmark::State& state) {
union Data { int i; double d; };
for (auto _ : state) {
Data data;
data.i = 42;
int x = data.i;
benchmark::DoNotOptimize(x);
}
}
BENCHMARK(BM_Union);
static void BM_Variant(benchmark::State& state) {
for (auto _ : state) {
std::variant<int, double> data;
data = 42;
int x = std::get<int>(data);
benchmark::DoNotOptimize(x);
}
}
BENCHMARK(BM_Variant);
이 정도로 단순한 코드에서는 최적화된 빌드에서 두 방식 모두 컴파일러가 거의 상수로 접어 버리므로, 측정값에 차이가 거의 나지 않는 것이 일반적입니다. variant의 실제 비용은 접근 방식별로 다음과 같습니다. union의 멤버 접근은 검사가 없습니다. std::get은 인덱스를 비교하는 분기 하나가 있고 틀리면 예외를 던집니다. std::get_if도 인덱스를 확인한 뒤 포인터나 nullptr를 돌려줍니다. std::visit은 구현에 따라 switch로 펼쳐지거나 함수 포인터 테이블을 거치는 간접 호출이 되는데, 간접 호출이면 인라인되지 않을 수 있습니다. 성능이 중요한 루프라면 마이크로 벤치마크보다 생성된 어셈블리를 확인하는 편이 정확합니다.
sizeof와 레이아웃
union Data {
char c; // 1바이트
int i; // 4바이트
double d; // 8바이트
};
std::cout << sizeof(Data) << '\n'; // 8 (가장 큰 멤버)
std::variant<char, int, double> v;
std::cout << sizeof(v) << '\n'; // 일반적으로 16 (8 + 인덱스 + 패딩)
libstdc++ 기준으로 variant의 레이아웃은 [데이터 8바이트][인덱스 1바이트][패딩 7바이트]입니다. 인덱스는 대안 수에 맞는 가장 작은 정수 타입으로 저장되지만, 전체 크기가 가장 큰 대안의 정렬 단위로 반올림되므로 1바이트 인덱스가 8바이트를 더하는 결과가 됩니다.
큰 대안 하나가 섞이면 작은 값을 담을 때도 그 크기를 그대로 차지합니다.
std::variant<char, int, std::string> data;
std::cout << sizeof(data) << '\n'; // libstdc++ 기준 40 (string 32 + 인덱스 + 패딩)
드물게 쓰이는 큰 대안은 std::unique_ptr<std::string>처럼 힙으로 옮기면 variant 자체는 16바이트로 줄어듭니다. 대신 그 대안을 담을 때마다 할당이 생기고, 값에 접근할 때 간접 참조가 하나 늘어납니다. 원시 포인터(std::string*)를 담으면 크기는 같지만 소유권이 불분명해져 누수를 만들기 쉽습니다. 문자열 길이에 상한이 있다면 std::array<char, 16> 같은 고정 크기 버퍼로 바꾸는 것도 방법입니다.
JSON 값·AST·프로토콜 메시지·설정 값 모델링
JSON 값 타입
JSON처럼 자기 자신을 포함하는 구조를 variant로 만들 때는 using 별칭만으로는 안 됩니다. 별칭은 선언이 끝나기 전에 자기 이름을 쓸 수 없기 때문입니다. 구조체로 한 번 감싸고 먼저 전방 선언합니다.
#include <cstdint>
#include <iostream>
#include <map>
#include <string>
#include <variant>
#include <vector>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
struct JsonValue;
using JsonArray = std::vector<JsonValue>;
using JsonObject = std::map<std::string, JsonValue>;
struct JsonValue {
std::variant<std::nullptr_t, bool, std::int64_t, double,
std::string, JsonArray, JsonObject> v;
};
void print(const JsonValue& j) {
std::visit(overloaded{
[](std::nullptr_t) { std::cout << "null"; },
[](bool b) { std::cout << (b ? "true" : "false"); },
[](std::int64_t i) { std::cout << i; },
[](double d) { std::cout << d; },
[](const std::string& s) { std::cout << '"' << s << '"'; },
[](const JsonArray& arr) {
std::cout << '[';
for (std::size_t i = 0; i < arr.size(); ++i) {
if (i > 0) std::cout << ',';
print(arr[i]);
}
std::cout << ']';
},
[](const JsonObject& obj) {
std::cout << '{';
bool first = true;
for (const auto& [key, value] : obj) {
if (!first) std::cout << ',';
first = false;
std::cout << '"' << key << "\":";
print(value);
}
std::cout << '}';
}
}, j.v);
}
int main() {
JsonValue arr{JsonArray{
JsonValue{std::int64_t{42}},
JsonValue{3.14},
JsonValue{std::string("Hello")},
JsonValue{nullptr}
}};
print(arr); // [42,3.14,"Hello",null]
}
std::vector는 C++17부터 불완전 타입을 원소로 선언하는 것을 표준으로 허용합니다. std::map은 표준 보장은 없지만 주요 구현에서 동작하므로 이 방식이 흔히 쓰입니다. 정수를 담을 때 std::int64_t{42}처럼 타입을 명시한 이유는, 그냥 42(int)를 넘기면 C++17에서는 bool, std::int64_t, double로의 변환 순위가 같아 모호하다는 컴파일 에러가 나기 때문입니다. 방문자에 배열과 객체 람다를 모두 두었으므로 포괄 람다 없이 모든 대안을 처리하고, 대안을 추가하면 컴파일러가 처리 누락을 알려 줍니다.
수식 AST 노드
재귀 구조에 포인터가 필요한 이유는 variant가 값을 자기 안에 직접 담기 때문입니다. 크기가 정해지지 않은 자기 자신을 값으로 품을 수는 없으므로, 재귀가 들어가는 지점에서는 힙에 할당하고 포인터로 연결합니다.
#include <memory>
#include <string>
#include <variant>
struct Literal { double value; };
struct Variable { std::string name; };
struct BinaryOp;
struct UnaryOp;
using Expr = std::variant<
Literal,
Variable,
std::unique_ptr<BinaryOp>,
std::unique_ptr<UnaryOp>
>;
struct BinaryOp {
char op; // '+', '-', '*', '/'
Expr left;
Expr right;
};
struct UnaryOp {
char op; // '-'
Expr operand;
};
double lookupVariable(const Variable& var); // 변수 테이블 조회 (구현 생략)
double evaluate(const Expr& expr) {
return std::visit(overloaded{
[](const Literal& lit) { return lit.value; },
[](const Variable& var) { return lookupVariable(var); },
[](const std::unique_ptr<BinaryOp>& op) {
double left = evaluate(op->left);
double right = evaluate(op->right);
switch (op->op) {
case '+': return left + right;
case '-': return left - right;
case '*': return left * right;
case '/': return left / right;
}
return 0.0;
},
[](const std::unique_ptr<UnaryOp>& op) {
return -evaluate(op->operand);
}
}, expr);
}
Literal과 Variable은 variant 안에 값으로 들어가므로 Expr보다 먼저 완전히 정의되어 있어야 하고, 재귀하는 BinaryOp와 UnaryOp만 전방 선언한 뒤 unique_ptr로 담습니다. variant 기반 AST는 상속 기반 AST와 반대의 장단점을 가집니다. 노드 종류를 추가하면 모든 방문자를 고쳐야 해서 번거롭지만, 평가, 출력, 최적화 같은 새 연산은 기존 노드를 건드리지 않고 방문자 하나로 추가할 수 있고, 가상 함수 호출도 없습니다.
에러 처리: Result 타입
#include <iostream>
#include <string>
#include <utility>
template <typename T, typename E>
class Result {
std::variant<T, E> value_;
public:
Result(T value) : value_(std::move(value)) {}
Result(E error) : value_(std::move(error)) {}
bool isOk() const { return value_.index() == 0; }
bool isErr() const { return value_.index() == 1; }
T& value() { return std::get<0>(value_); }
E& error() { return std::get<1>(value_); }
T valueOr(T defaultValue) const {
return isOk() ? std::get<0>(value_) : defaultValue;
}
};
Result<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::string("0으로 나눌 수 없음");
}
return a / b;
}
int main() {
auto r1 = divide(10, 2);
if (r1.isOk()) std::cout << "결과: " << r1.value() << '\n'; // 5
auto r2 = divide(10, 0);
if (r2.isErr()) std::cout << "에러: " << r2.error() << '\n'; // 0으로 나눌 수 없음
}
타입 대신 인덱스로 접근한 이유는 T와 E가 같은 타입일 때(Result<std::string, std::string>) std::get<T>가 모호해지기 때문입니다. 다만 그 경우에는 두 생성자도 구분되지 않으므로, 실제로는 C++23의 std::expected<T, E>처럼 에러 쪽을 std::unexpected로 감싸 구분하는 설계가 필요합니다. C++23을 쓸 수 있다면 직접 만들기보다 std::expected를 쓰는 편이 낫습니다.
프로토콜 메시지 디스패치
struct LoginMessage {
std::string username;
std::string password;
};
struct ChatMessage {
std::string sender;
std::string content;
};
struct FileTransfer {
std::string filename;
std::vector<std::uint8_t> data;
};
using Message = std::variant<LoginMessage, ChatMessage, FileTransfer>;
class MessageHandler {
public:
void handle(const Message& msg) {
std::visit(overloaded{
[this](const LoginMessage& m) { handleLogin(m); },
[this](const ChatMessage& m) { handleChat(m); },
[this](const FileTransfer& m) { handleFile(m); }
}, msg);
}
private:
void handleLogin(const LoginMessage& msg) {
std::cout << "로그인: " << msg.username << '\n';
}
void handleChat(const ChatMessage& msg) {
std::cout << msg.sender << ": " << msg.content << '\n';
}
void handleFile(const FileTransfer& msg) {
std::cout << "파일 수신: " << msg.filename
<< " (" << msg.data.size() << " 바이트)\n";
}
};
메시지 종류가 컴파일 시점에 정해진 닫힌 집합이라 variant와 잘 맞습니다. 새 메시지 타입을 Message에 추가하면 handle의 방문자가 컴파일되지 않으므로 처리기를 빠뜨릴 수 없습니다.
여러 타입을 갖는 설정 값
using ConfigValue = std::variant<
bool,
int,
double,
std::string,
std::vector<std::string>
>;
class Config {
std::map<std::string, ConfigValue> values_;
public:
void set(const std::string& key, ConfigValue value) {
values_[key] = std::move(value);
}
template <typename T>
std::optional<T> get(const std::string& key) const {
auto it = values_.find(key);
if (it != values_.end()) {
if (auto* ptr = std::get_if<T>(&it->second)) {
return *ptr;
}
}
return std::nullopt;
}
std::string toString(const std::string& key) const {
auto it = values_.find(key);
if (it == values_.end()) {
return "";
}
return std::visit(overloaded{
[](bool v) { return std::string(v ? "true" : "false"); }, // 모든 람다의 반환 타입이 같아야 함
[](int v) { return std::to_string(v); },
[](double v) { return std::to_string(v); },
[](const std::string& v) { return v; },
[](const std::vector<std::string>& v) {
std::string result = "[";
for (std::size_t i = 0; i < v.size(); ++i) {
if (i > 0) result += ", ";
result += v[i];
}
return result + "]";
}
}, it->second);
}
};
Config config;
config.set("port", 8080);
config.set("host", std::string("localhost"));
config.set("debug", true);
config.set("allowed_origins", std::vector<std::string>{"*"});
auto port = config.get<int>("port"); // std::optional<int>
std::cout << config.toString("allowed_origins") << '\n'; // [*]
여기서도 "localhost"를 std::string으로 감싼 이유는 앞에서 본 bool 변환 함정 때문입니다.
그래도 union이 필요한 곳
C API와의 호환
C 라이브러리와 구조체를 주고받을 때는 레이아웃이 C와 같아야 하므로 union을 씁니다. std::variant의 레이아웃은 구현마다 다르고 C에서 해석할 수 없습니다.
extern "C" {
union IpAddress {
std::uint32_t ipv4;
std::uint8_t ipv6[16];
};
void process_ip(IpAddress addr, bool isIpv6); // 태그는 별도 인자로 전달
}
비트 단위 타입 재해석
// C에서는 허용되지만 C++에서는 정의되지 않은 동작
union FloatBits {
float f;
std::uint32_t bits;
};
FloatBits fb;
fb.f = 3.14f;
std::cout << std::hex << fb.bits << '\n';
// C++20: std::bit_cast (C++17 이하는 std::memcpy)
std::uint32_t bits = std::bit_cast<std::uint32_t>(3.14f);
union으로 비트 패턴을 읽는 “타입 퍼닝”은 C에서는 명시적으로 허용되지만, C++ 표준에서는 비활성 멤버를 읽는 것이므로 정의되지 않은 동작입니다. GCC는 이 동작을 확장으로 문서화해 보장하고 Clang도 같게 동작하기 때문에 실제로는 잘 돌아가는 경우가 많지만, 이식성이 필요한 코드라면 C++20의 std::bit_cast나 std::memcpy를 쓰는 것이 정석입니다. memcpy는 호출처럼 보여도 컴파일러가 레지스터 이동 하나로 최적화하므로 성능 걱정은 필요 없습니다. 그래서 C++에서 union이 여전히 필요한 곳은 C와 공유하는 구조체 레이아웃, 판별자 1바이트와 패딩까지 아껴야 하는 메모리 제약 코드, 그리고 std::variant나 std::optional 같은 라이브러리 타입을 직접 구현하는 경우 정도로 좁혀집니다.
variant 대안 목록을 설계할 때
대안 목록의 첫 번째 타입은 기본 생성 시 들어가는 값이 되므로, 값싸게 기본 생성할 수 있는 타입이나 std::monostate를 맨 앞에 두는 것이 좋습니다. 같은 타입을 두 번 넣는 것(std::variant<int, int>)은 선언은 가능하지만, v = 1;이나 std::get<int>(v)가 모호해서 컴파일 에러가 나고 std::in_place_index와 std::get<0>(v)처럼 인덱스로만 다룰 수 있습니다. 같은 표현이 서로 다른 의미를 가진다면 struct UserId { int value; };처럼 감싸 별도 타입으로 만드는 편이 읽기 쉽습니다.
variant는 담을 수 있는 타입 목록이 컴파일 시점에 고정되는 반면, std::any는 어떤 타입이든 담는 대신 꺼낼 때 타입을 정확히 알아야 합니다. 그 비교는 C++ std::any vs void*에서 다룹니다.