C++ union과 std::variant: 공용체의 타입 안전성 문제와 variant로 바꾸기
이 글의 핵심
union은 멤버들이 같은 메모리를 공유하지만 지금 어떤 멤버가 유효한지 기억하지 않아, 잘못 읽으면 정의되지 않은 동작이 됩니다. C++17 std::variant는 활성 타입을 스스로 추적하는 타입 안전 공용체입니다. 이 글은 union의 함정, variant의 get/get_if/visit과 overloaded 패턴, 기본 생성·참조·문자열 리터럴 변환 같은 함정을 JSON 값·상태 머신·Result 예제로 정리합니다.
union 기본
union의 모든 멤버는 정확히 같은 메모리 주소에서 시작합니다 — struct가 각 멤버에 별도의 공간을 할당해 나란히 배치하는 것과 정반대로, union은 그 멤버들 중 가장 큰 것이 필요로 하는 만큼의 공간만 확보하고 모든 멤버가 그 공간을 겹쳐 쓰도록 설계되어 있습니다. sizeof(Data)가 4인 것(가장 큰 멤버인 int/float의 크기)이 이 겹침 구조의 직접적인 증거이며, d.i = 10 다음에 d.f = 3.14f를 대입하면 f가 차지하는 4바이트가 조금 전 i가 차지하던 바로 그 4바이트를 덮어써, i가 담고 있던 값은 완전히 사라집니다. 이 “메모리 겹쳐 쓰기”가 union이 존재하는 유일한 이유입니다 — 여러 타입 중 한 번에 하나만 필요한 상황에서, 그 타입들을 위한 공간을 각각 따로 잡지 않고 가장 큰 것 하나의 공간만 재사용해 메모리를 절약합니다.
union Data {
int i;
float f;
char c;
};
int main() {
Data d;
d.i = 10;
cout << d.i << endl; // 10
d.f = 3.14f;
cout << d.f << endl; // 3.14
// d.i는 이제 유효하지 않음!
cout << sizeof(Data) << endl; // 4 (가장 큰 멤버)
}
union 문제점
두 문제는 모두 union이 “지금 어떤 멤버가 실제로 유효한지”를 전혀 기억하지 않는다는 근본적인 한계에서 나옵니다. d.i = 10; cout << d.f가 쓰레기 값을 내는 이유는, int로 쓰인 4바이트 비트 패턴을 그대로 float로 재해석하기 때문입니다 — 정수 10의 비트 패턴은 부동소수점 인코딩 규칙에서는 완전히 다른(그리고 대체로 무의미한) 값을 나타내며, 이는 표준이 명시적으로 정의되지 않은 동작으로 규정하는 위반입니다. string처럼 생성자·소멸자가 있는 타입이 C++11 이전 union에서 금지되었던 이유는, 컴파일러가 “이 멤버가 지금 살아있는지”를 알 방법이 없어 언제 생성자를, 언제 소멸자를 호출해야 할지 판단할 수 없었기 때문입니다 — union이 멤버 전환 시 이전 멤버의 소멸자를 자동으로 호출해 주지 않으므로, string 같은 타입을 그대로 두면 그 내부 힙 메모리가 관리되지 않은 채 방치됩니다.
// ❌ 타입 안전하지 않음
union Data {
int i;
float f;
};
Data d;
d.i = 10;
cout << d.f << endl; // 쓰레기 값 (UB)
// ❌ 생성자/소멸자 있는 타입 불가
union Bad {
string s; // 에러! (C++11 이전)
};
std::variant (C++17)
std::variant<int, double, string>은 겉보기엔 union과 비슷하지만, 근본적인 차이는 “지금 무엇이 담겨 있는지 스스로 기억한다”는 것입니다 — 내부적으로 현재 활성화된 대안(alternative)의 인덱스를 별도로 저장해 두므로, v = 10 다음 get<int>(v)는 안전하게 성공하지만 get<int>(v)를 실제로 담긴 타입이 string인 상태에서 호출하면 union처럼 조용히 쓰레기 값을 내는 대신 std::bad_variant_access 예외를 명시적으로 던집니다. 이 하나의 차이가 variant를 “타입 안전한 union”이라 부르는 이유이며, string 같은 비트리비얼 타입도 아무 제약 없이 담을 수 있는 것도 variant가 대안을 전환할 때마다 이전 대안의 소멸자와 새 대안의 생성자를 정확히 호출해 주기 때문입니다 — union에서는 프로그래머가 직접 해야 했던 이 수명 관리를 variant가 완전히 대신 해 줍니다.
#include <variant>
int main() {
variant<int, double, string> v;
v = 10;
cout << get<int>(v) << endl; // 10
v = 3.14;
cout << get<double>(v) << endl; // 3.14
v = string("Hello");
cout << get<string>(v) << endl; // Hello
// 잘못된 타입 접근
try {
cout << get<int>(v) << endl; // 예외
} catch (const bad_variant_access& e) {
cout << "타입 불일치" << endl;
}
}
variant 방문자
get<T>로 타입을 하나씩 시도하며 확인하는 대신, std::visit은 “지금 담긴 타입이 무엇이든 그에 맞는 처리를 하라”는 것을 한 번에 표현하는 방법입니다 — Visitor가 int, double, const string& 각각에 대한 operator() 오버로드를 제공하면, visit(Visitor{}, v)는 v에 실제로 담긴 타입에 정확히 대응하는 오버로드를 컴파일 타임에 찾아 호출합니다. 이 방식의 진짜 이점은 안전성입니다 — 만약 variant에 새 타입(예: bool)을 추가했는데 Visitor에 그에 대응하는 오버로드를 깜빡했다면, std::visit 호출 자체가 컴파일 에러가 되어 누락을 즉시 알려줍니다. get<T>를 여러 번 시도하는 방식이었다면 이런 누락이 컴파일 타임에 드러나지 않고 런타임에 예외로만 나타났을 것입니다.
#include <variant>
struct Visitor {
void operator()(int i) {
cout << "정수: " << i << endl;
}
void operator()(double d) {
cout << "실수: " << d << endl;
}
void operator()(const string& s) {
cout << "문자열: " << s << endl;
}
};
int main() {
variant<int, double, string> v;
v = 10;
visit(Visitor{}, v); // 정수: 10
v = 3.14;
visit(Visitor{}, v); // 실수: 3.14
v = string("Hello");
visit(Visitor{}, v); // 문자열: Hello
}
overloaded 패턴: 방문자 구조체 없이 람다로
방문자마다 구조체를 만드는 대신, 람다 여러 개를 묶어 한 번에 넘기는 관용구가 널리 쓰입니다.
template<class... Ts>
struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts>
overloaded(Ts...) -> overloaded<Ts...>; // C++17에서 필요, C++20부터는 생략 가능
std::variant<int, double, std::string> v = 3.0;
std::visit(overloaded{
[](int i) { std::cout << "int " << i << '\n'; },
[](double d) { std::cout << "double " << d << '\n'; },
[](const std::string& s) { std::cout << "string " << s << '\n'; },
}, v); // double 3
overloaded는 넘겨받은 람다 타입들을 전부 상속하고, using Ts::operator()...로 각 람다의 호출 연산자를 한 오버로드 집합으로 모읍니다. std::visit은 현재 담긴 타입으로 그 집합에서 오버로드 해석을 하므로, 대안 하나를 처리하지 않으면 컴파일 에러가 나서 처리 누락을 컴파일러가 잡아 줍니다. 세 번째 줄의 추론 가이드는 C++17에서 overloaded{...}의 템플릿 인자를 추론하기 위한 것이고, C++20에서는 집합체 CTAD 덕분에 원칙적으로 생략할 수 있고 GCC 10도 일반 람다만 있을 때는 가이드 없이 컴파일하지만, 아래처럼 제네릭 람다(auto 매개변수)가 섞이면 GCC 10은 class template argument deduction failed로 실패했습니다. 여러 컴파일러에서 쓸 코드라면 가이드 한 줄을 남겨 두는 편이 안전합니다. 주의할 점은 오버로드 해석이 암시적 변환을 허용한다는 것입니다. double용 람다를 빼먹어도 int 람다가 있으면 double → int 변환으로 조용히 호출될 수 있으니, 정확히 일치해야 한다면 마지막에 [](auto&& x) { static_assert(sizeof(x) == 0, "처리하지 않은 대안"); } 같은 제네릭 람다를 두면, 변환이 필요한 대안은 정확히 일치하는 이 템플릿 쪽으로 가서 컴파일 에러가 납니다.
실전 예시
예시 1: JSON 값
JSON이라는 형식 자체가 “값은 null, 불리언, 숫자, 문자열, 배열, 객체 중 정확히 하나”라는 닫힌 집합의 개념을 갖고 있으므로, 이를 표현하기에 variant<nullptr_t, bool, int, double, string, vector<JsonValue>, map<string, JsonValue>>가 자연스러운 선택입니다 — JSON 파서나 직렬화 라이브러리 대부분이 내부적으로 정확히 이런 구조를 씁니다. vector<JsonValue>와 map<string, JsonValue>가 JsonValue 자기 자신을 담는 재귀적 구조라는 점도 주목할 만한데, 이것이 문제없이 컴파일되는 이유는 vector와 map이 요소를 힙에 별도로 저장하는 컨테이너라 JsonValue의 완전한 크기를 그 시점에 알 필요가 없기 때문입니다 — 만약 JsonValue를 배열처럼 직접 값으로 담으려 했다면 무한 크기 문제(자기 자신을 포함하는 타입은 크기가 무한대가 됨)에 부딪혔을 것입니다.
#include <variant>
#include <map>
class JsonValue {
public:
using Value = variant<nullptr_t, bool, int, double, string,
vector<JsonValue>, map<string, JsonValue>>;
Value value;
JsonValue() : value(nullptr) {}
JsonValue(int i) : value(i) {}
JsonValue(double d) : value(d) {}
JsonValue(const string& s) : value(s) {}
bool isNull() const { return holds_alternative<nullptr_t>(value); }
bool isBool() const { return holds_alternative<bool>(value); }
bool isInt() const { return holds_alternative<int>(value); }
bool isDouble() const { return holds_alternative<double>(value); }
bool isString() const { return holds_alternative<string>(value); }
int asInt() const { return get<int>(value); }
double asDouble() const { return get<double>(value); }
string asString() const { return get<string>(value); }
};
int main() {
JsonValue v1 = 42;
JsonValue v2 = 3.14;
JsonValue v3 = string("Hello");
if (v1.isInt()) {
cout << v1.asInt() << endl;
}
}
예시 2: 상태 머신
전통적으로 상태 머신은 열거형(enum State { IDLE, RUNNING, ... })과 그 상태별로 필요한 데이터를 담은 별도의 멤버 변수들을 함께 두는 방식으로 구현되었는데, 이 방식은 “지금 RUNNING 상태가 아닌데 실수로 progress 필드를 읽는” 것 같은 실수를 타입 시스템이 막아주지 못합니다. variant<Idle, Running, Paused, Completed>는 각 상태에 필요한 데이터를 그 상태 전용 구조체 안에 캡슐화해, progress는 오직 Running 상태일 때만 존재하는 필드가 되도록 만듭니다 — pause()가 get_if<Running>(&state)로 먼저 확인한 뒤에야 r->progress에 접근하는 것도 “지금 정말 Running 상태인가”를 타입 수준에서 검증하는 것입니다. printState()의 if constexpr 체인은 앞서 다룬 std::visit 패턴의 변형으로, 람다 하나 안에서 컴파일 타임에 타입을 분기해 각 상태에 맞는 처리를 표현하는 방식입니다.
struct Idle {};
struct Running { int progress; };
struct Paused { int savedProgress; };
struct Completed { string result; };
using State = variant<Idle, Running, Paused, Completed>;
class Task {
private:
State state;
public:
Task() : state(Idle{}) {}
void start() {
state = Running{0};
}
void pause() {
if (auto* r = get_if<Running>(&state)) {
state = Paused{r->progress};
}
}
void resume() {
if (auto* p = get_if<Paused>(&state)) {
state = Running{p->savedProgress};
}
}
void complete(const string& result) {
state = Completed{result};
}
void printState() {
visit([](const auto& s) {
using T = decay_t<decltype(s)>;
if constexpr (is_same_v<T, Idle>) {
cout << "대기 중" << endl;
} else if constexpr (is_same_v<T, Running>) {
cout << "실행 중: " << s.progress << "%" << endl;
} else if constexpr (is_same_v<T, Paused>) {
cout << "일시정지: " << s.savedProgress << "%" << endl;
} else if constexpr (is_same_v<T, Completed>) {
cout << "완료: " << s.result << endl;
}
}, state);
}
};
int main() {
Task task;
task.printState(); // 대기 중
task.start();
task.printState(); // 실행 중: 0%
task.pause();
task.printState(); // 일시정지: 0%
}
예시 3: 에러 처리
Result<T, E>는 Rust의 Result 타입이나 함수형 언어의 Either를 C++ variant로 재현한 것으로, “성공했을 때의 값(T)과 실패했을 때의 에러(E) 중 정확히 하나만 존재한다”는 것을 타입으로 표현합니다. 이 패턴이 예외 던지기보다 나은 점은, 함수의 시그니처(Result<int, string> divide(...))만 보고도 “이 함수는 실패할 수 있다”는 사실과 그 실패가 어떤 타입으로 표현되는지를 호출자가 컴파일 타임에 알 수 있다는 것입니다 — 예외는 함수 시그니처에 드러나지 않으므로, 호출자가 divide를 호출하기 전에는 이 함수가 예외를 던질 수 있는지조차 알 방법이 없습니다. isOk()로 먼저 확인한 뒤 value()나 error()를 호출하는 흐름도, 예외 처리를 위한 try/catch 블록 없이 일반적인 if/else만으로 실패 처리를 표현할 수 있게 해 줍니다.
template<typename T, typename E>
class Result {
private:
variant<T, E> data;
public:
Result(T value) : data(value) {}
Result(E error) : data(error) {}
bool isOk() const {
return holds_alternative<T>(data);
}
bool isErr() const {
return holds_alternative<E>(data);
}
T& value() {
return get<T>(data);
}
E& error() {
return get<E>(data);
}
};
Result<int, string> divide(int a, int b) {
if (b == 0) {
return string("0으로 나눌 수 없음");
}
return a / b;
}
int main() {
auto result = divide(10, 2);
if (result.isOk()) {
cout << "결과: " << result.value() << endl;
} else {
cout << "에러: " << result.error() << endl;
}
}
예시 4: 플래그 집합
이 예시는 union/variant와는 다른 종류의 문제(“여러 불리언 값을 압축해서 저장하기”)를 보여주기 위한 대조군입니다 — union이 “여러 타입 중 하나”를, variant가 “여러 타입 중 하나를 안전하게”를 표현한다면, std::bitset은 “여러 개의 독립적인 켜짐/꺼짐 상태를 비트 단위로 압축”하는 완전히 다른 목적을 갖습니다. enum class Feature의 마지막에 FEATURE_COUNT를 두는 것은 실제 기능이 아니라 “지금까지 정의된 기능의 개수”를 나타내는 관용적인 트릭이며, 이를 bitset의 크기(static_cast<size_t>(Feature::FEATURE_COUNT))로 사용하면 새 기능이 추가될 때마다 bitset의 크기를 손으로 다시 계산할 필요 없이 자동으로 맞춰집니다. enum class로 감싼 것은 Feature::FEATURE_A가 실수로 다른 정수와 섞이는 것을 타입 시스템으로 방지하기 위함입니다.
#include <bitset>
enum class Feature {
FEATURE_A,
FEATURE_B,
FEATURE_C,
FEATURE_D,
FEATURE_COUNT
};
class FeatureFlags {
private:
bitset<static_cast<size_t>(Feature::FEATURE_COUNT)> flags;
public:
void enable(Feature f) {
flags.set(static_cast<size_t>(f));
}
void disable(Feature f) {
flags.reset(static_cast<size_t>(f));
}
bool isEnabled(Feature f) const {
return flags.test(static_cast<size_t>(f));
}
void print() const {
cout << "플래그: " << flags << endl;
}
};
int main() {
FeatureFlags features;
features.enable(Feature::FEATURE_A);
features.enable(Feature::FEATURE_C);
features.print(); // 0101
if (features.isEnabled(Feature::FEATURE_A)) {
cout << "기능 A 활성화" << endl;
}
}
variant vs union
이 표의 네 항목은 결국 하나의 트레이드오프로 요약됩니다 — variant가 제공하는 모든 안전성(타입 검사, 자동 수명 관리, 복잡한 타입 지원)은 “지금 어떤 대안이 활성화되어 있는가”를 추적하는 약간의 오버헤드(대개 인덱스 하나만큼의 추가 메모리와 대입 시의 검사 비용)를 대가로 지불한 결과입니다. union은 이 추적 자체를 전혀 하지 않으므로 그만큼 가볍지만, 그 대가로 모든 안전성 보장을 프로그래머가 직접 책임져야 합니다. 실무 기준은 명확합니다 — 메모리 몇 바이트가 정말로 문제가 되는 극도로 제한된 환경(임베디드, 하드웨어 레지스터 매핑)이 아니라면, variant의 안전성이 그 미미한 오버헤드보다 거의 항상 더 가치 있습니다.
자주 발생하는 문제
문제 1: union 타입 추적
“태그 붙은 union(tagged union)” 패턴은 variant가 표준화되기 전 C와 초기 C++에서 이 문제를 해결하던 전통적인 방법입니다 — 별도의 enum 필드로 “지금 어떤 멤버가 활성 상태인지”를 수동으로 기록해 두고, union에 접근하기 전에 항상 그 태그를 먼저 확인하는 규율을 프로그래머 스스로 지켜야 했습니다. 문제는 이 규율이 언어에 의해 강제되지 않는다는 것입니다 — 태그를 갱신하는 것을 깜빡하거나, 태그 확인 없이 멤버에 접근하는 코드가 어딘가에 섞여 들어가면 컴파일러는 이를 전혀 잡아내지 못합니다. std::variant는 이 태그 관리 자체를 언어 차원에서 자동화해, “태그를 깜빡했다”는 종류의 실수 자체가 애초에 발생할 수 없게 만듭니다 — 이것이 표에서 “현재 타입 추적: 수동 vs 자동”이라는 차이가 실제로 의미하는 바입니다.
// ❌ 타입 추적 안함
union Data {
int i;
float f;
};
Data d;
d.i = 10;
// 나중에 i인지 f인지 모름!
// ✅ 태그 추가
struct TaggedData {
enum Type { INT, FLOAT } type;
union {
int i;
float f;
};
};
// ✅ variant 사용 (더 좋음)
variant<int, float> data;
문제 1-1: 기본 생성은 첫 번째 대안으로
std::variant<int, double> v1; // int{} = 0을 담고 있음
struct NoDefault { NoDefault(int) {} };
std::variant<NoDefault, int> v2; // ❌ use of deleted function 'variant()'
std::variant<std::monostate, NoDefault> v3; // ✅ "아직 값 없음" 상태로 시작
variant는 비어 있을 수 없어서, 기본 생성하면 첫 번째 대안을 값 초기화합니다. 첫 번째 타입에 기본 생성자가 없으면 variant 자체를 기본 생성할 수 없고, 에러 메시지가 variant()가 삭제됐다고만 나와서 원인을 찾기 어렵습니다. “아직 아무 값도 없음”을 표현해야 한다면 std::monostate를 첫 대안으로 둡니다. 비슷한 이유로 참조는 대안이 될 수 없으므로(std::variant<int&>는 불가) std::reference_wrapper<int>나 포인터를 담습니다.
문제 1-2: 문자열 리터럴이 bool로 가던 문제
std::variant<bool, std::string> v = "abc";
std::cout << v.index(); // GCC 10: 1 (std::string)
const char*는 bool로의 표준 변환이 std::string 생성자(사용자 정의 변환)보다 우선이라, 초기 C++17 구현에서는 이 코드가 bool(true)을 담았습니다. 문자열을 넣었는데 true가 들어가는 악명 높은 함정이었고, C++20(P0608)에서 좁히는 변환과 bool 변환을 후보에서 빼도록 규칙이 바뀌었습니다. GCC 10은 -std=c++17에서도 새 규칙을 적용해 std::string(인덱스 1)을 고르지만, 오래된 컴파일러나 표준 라이브러리를 함께 지원해야 한다면 std::string("abc")처럼 타입을 명시하는 편이 안전합니다.
문제 2: variant 예외
get<T>가 예외를 던지는 이유는 이것이 “이 variant는 지금 T 타입 값을 담고 있어야 정상”이라는 확신이 있을 때 쓰도록 설계되었기 때문이며, 그 확신이 틀렸을 때 조용히 잘못된 값을 반환하는 대신 즉시 예외로 알려주는 것이 union과 대비되는 variant의 안전성입니다. 하지만 “지금 무슨 타입이 들어있는지 모르는 채로 확인해 보고 싶다”는 경우라면 예외 기반 접근은 번거로우며, 이럴 때는 get_if(포인터를 반환하고 타입이 다르면 nullptr)나 holds_alternative(타입 일치 여부만 bool로 확인)가 예외 없이 흐름을 제어할 수 있는 대안입니다. 세 가지 방법(get, get_if, holds_alternative) 중 어느 것을 쓸지는 “타입이 맞을 것이라 확신하는가, 아니면 그 자체를 확인하는 것이 로직의 일부인가”로 결정하면 됩니다 — 전자라면 실패가 곧 버그이므로 예외가 적절하고, 후자라면 분기 로직의 정상적인 일부이므로 get_if/holds_alternative가 더 자연스럽습니다.
// ❌ 예외 발생
variant<int, double> v = 10;
double d = get<double>(v); // bad_variant_access
// ✅ get_if 사용
if (double* p = get_if<double>(&v)) {
cout << *p << endl;
} else {
cout << "타입 불일치" << endl;
}
// ✅ holds_alternative
if (holds_alternative<int>(v)) {
cout << get<int>(v) << endl;
}
문제 3: 비트 필드 패딩
비트 필드(unsigned int a : 1)는 union처럼 메모리를 극도로 절약하려는 저수준 기법의 또 다른 예이지만, 여기서도 “이론적으로 필요한 최소 크기”와 “실제로 컴파일러가 배정하는 크기”는 다를 수 있습니다 — 표준은 비트 필드를 담는 기반 타입(여기서는 unsigned int) 단위로 할당이 이루어진다고만 규정할 뿐, 그 이하로 얼마나 촘촘하게 packing할지는 구현 정의(implementation-defined) 사항으로 남겨 두었습니다. 그래서 a와 b가 합쳐 2비트만 있으면 될 것 같아도, 대부분의 컴파일러는 unsigned int(보통 4바이트) 전체를 그 비트 필드 그룹을 위해 할당하므로 sizeof(Flags)가 1이 아니라 4로 나옵니다. union의 크기가 표준에 의해 명확히 결정되는 것(가장 큰 멤버의 크기)과 달리, 비트 필드의 정확한 메모리 레이아웃은 플랫폼과 컴파일러에 따라 달라질 수 있다는 점이 저수준 최적화를 시도할 때 반드시 감안해야 할 함정입니다.
struct Flags {
unsigned int a : 1;
unsigned int b : 1;
// 컴파일러가 패딩 추가 가능
};
cout << sizeof(Flags) << endl; // 4 (예상: 1)
FAQ
Q1: union은 언제 사용하나요?
A:
- 메모리 제약
- C 호환성
- 저수준 프로그래밍
Q2: variant는 언제 사용하나요?
A:
- 타입 안전 필요
- 여러 타입 중 하나
- 에러 처리 (Result 타입)
Q3: union vs variant 성능?
A: union이 약간 빠르지만, 대부분 무시할 수 있습니다.
Q4: 비트 필드는 언제 사용하나요?
A:
- 하드웨어 레지스터
- 네트워크 프로토콜
- 메모리 최적화
Q5: variant 오버헤드는?
A: 타입 인덱스 저장 (보통 1바이트)과 정렬 패딩입니다.
Q6: Union/Variant 학습 리소스는?
A:
- cppreference.com
- “Effective Modern C++”
- “C++17: The Complete Guide”