C++ std::variant vs union: 타입 안전성과 비용 비교

이 글의 핵심

여러 타입 중 하나를 저장할 때 union을 쓰면 std::string 같은 비자명 타입의 생성자와 소멸자를 직접 관리해야 합니다. variant가 추가로 쓰는 메모리와 접근 비용을 비교하고, C 호환 구조체나 비트 조작처럼 여전히 union이 적합한 경우를 구분해 선택 기준과 체크리스트를 제공합니다.

여러 타입 중 하나를 담는 두 가지 방법

C++에서 여러 타입 중 하나를 저장하려면 union이나 std::variant(C++17)를 사용합니다. variant는 타입 안전하지만, union은 수동 관리가 필요합니다.

비유로 말씀드리면, union은 여러 형태의 물건을 같은 서랍 칸에 억지로 넣는 것이고, variant는 “지금은 이 타입”이라는 꼬리표가 붙은 서랍입니다. 꼬리표 없이 꺼내면 엉뚱한 타입으로 읽는 미정의 동작이 생길 수 있습니다.

언제 std::variant를, 언제 union을 쓰나요?

관점std::variantunion
성능안전한 접근에 약간의 비용POD·C 레이아웃에 맞출 때 최소
사용성visit, get_if로 활성 타입 추적활성 멤버를 직접 기억해야 함
적용 시나리오현대 C++ 도메인 모델FFI·비트 패킹·레거시
// ❌ union: 타입 불안전
union Data {
    int i;
    double d;
    char c;
};

Data data;
data.i = 42;
std::cout << data.d << '\n';  // ❌ 미정의 동작 (int를 double로 읽음)

// ✅ std::variant: 타입 안전
std::variant<int, double, char> data;
data = 42;  // int 저장
// std::cout << std::get<double>(data) << '\n';  // ❌ 예외 던짐
std::cout << std::get<int>(data) << '\n';  // ✅ 42

위 코드에서 data.d를 읽는 줄이 문제인 이유는 C++에서 union은 마지막으로 값을 쓴 멤버(활성 멤버)만 읽을 수 있기 때문입니다. 다른 멤버를 읽으면 정의되지 않은 동작이라, 컴파일러에 따라 쓰레기 값이 나오기도 하고 최적화 과정에서 코드가 예상과 다르게 바뀌기도 합니다. union 자체는 지금 어떤 멤버가 활성인지 기록하지 않으므로, 그 정보를 따로 들고 다니는 것은 전적으로 프로그래머의 책임입니다. std::variant는 이 “지금 무엇이 들어 있는가”를 나타내는 인덱스를 내부에 함께 저장하고, 잘못된 타입으로 꺼내려 하면 std::bad_variant_access 예외를 던져 조용한 오동작 대신 즉시 실패하게 만듭니다.


활성 타입을 누가 기억하는가

항목별 비교표

항목unionstd::variant
타입 안전성❌ 없음✅ 있음
생성자/소멸자❌ 호출 안 됨✅ 호출됨
활성 타입 추적수동자동
복잡한 타입❌ (제한적)✅
방문자 패턴❌✅
C 호환✅❌
메모리최대 크기최대 크기 + 인덱스
C++ 버전모든 버전C++17 이후

union: 어떤 멤버가 유효한지 프로그래머가 추적

// ❌ union: 수동 타입 추적
enum class DataType {
    INT, DOUBLE, STRING
};

struct Data {
    DataType type;
    union {
        int i;
        double d;
        std::string s;  // ⚠️ C++11부터 선언은 가능하지만 생성자·소멸자가 삭제됨
    };
};

// POD 타입만 가능
union SimpleData {
    int i;
    double d;
    char c;
};

SimpleData data;
data.i = 42;

// ❌ 어떤 타입인지 모름
std::cout << data.d << '\n';  // 미정의 동작

std::variant: 인덱스를 함께 저장해 잘못된 접근을 막음

// ✅ 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 멤버의 생성과 소멸을 직접 호출

// ❌ union: 생성자/소멸자 호출 안 됨
union Data {
    int i;
    std::string s;  // ⚠️ 선언은 되지만 Data의 기본 생성자·소멸자가 삭제됨
};
// Data d;  → error: use of deleted function 'Data::Data()'
// (C++03에서는 멤버 선언 자체가 "member with constructor not allowed in union" 에러)

// 해결책: placement new + 수동 소멸자
union Data {
    int i;
    char s[sizeof(std::string)];  // 원시 메모리
};

enum class Type { INT, STRING };

struct SafeData {
    Type type;
    Data data;
    
    SafeData(int value) : type(Type::INT) {
        data.i = value;
    }
    
    SafeData(const std::string& value) : type(Type::STRING) {
        new (data.s) std::string(value);  // placement new
    }
    
    ~SafeData() {
        if (type == Type::STRING) {
            reinterpret_cast<std::string*>(data.s)->~std::string();  // 수동 소멸
        }
    }
    
    // 복사/이동 생성자도 구현 필요...
};

C++11의 “제약 없는 union(unrestricted union)” 규칙 덕분에 std::string 같은 멤버를 union 안에 선언하는 것 자체는 가능해졌지만, 컴파일러는 어떤 멤버를 생성하고 소멸해야 할지 모르기 때문에 union의 기본 생성자·소멸자·복사 연산을 삭제합니다. 그래서 위의 SafeData처럼 태그(type)를 두고, 생성자에서 placement new로 해당 멤버를 만들고, 소멸자에서 태그를 보고 직접 소멸자를 호출해야 합니다. 여기에 복사 생성자, 이동 생성자, 복사 대입, 이동 대입을 모두 태그 조합별로 구현해야 하며, 대입 도중 새 멤버의 생성자가 예외를 던지면 이전 멤버는 이미 소멸된 상태가 되는 예외 안전성 문제까지 챙겨야 합니다. std::variant는 바로 이 수십 줄의 까다로운 코드를 표준 라이브러리가 대신 작성해 둔 것이라고 보면 됩니다. 또 char s[sizeof(std::string)] 버퍼는 std::string의 정렬 요구를 보장하지 않으므로, 이 방식을 직접 쓴다면 alignas(std::string)을 붙여야 합니다.

std::variant: 대입 시 이전 값을 자동으로 소멸

// ✅ 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::visit와 overloaded 패턴

std::visit로 타입별 처리

// ✅ std::visit: 방문자 패턴
std::variant<int, double, std::string> data = 42;

// 방문자 함수
std::visit([](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 패턴

// ✅ 오버로드 패턴 (C++17)
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&) 같은 포괄 람다를 넣으면 이 검사가 사라진다는 점도 알아 두어야 합니다.

오버로드 해석 규칙 때문에 생기는 미묘한 문제도 있습니다. 위 방문자에 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로 타입을 명시하는 습관이 안전합니다.


크기·접근 속도·메모리 레이아웃 비교

벤치마크: 저장과 접근

#include <benchmark/benchmark.h>
#include <variant>

// union
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);

// std::variant
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);

결과 해석: 이 정도로 단순한 코드에서는 최적화된 빌드에서 두 방식 모두 컴파일러가 거의 상수로 접어 버리므로, 측정값이 사실상 같게 나오는 것이 일반적입니다. 구체적인 수치는 컴파일러와 CPU에 따라 다르니 직접 실행해 보는 것이 좋습니다. variant의 실제 비용은 std::get의 인덱스 검사(분기 하나)와 std::visit의 디스패치에서 생기는데, 구현에 따라 std::visit이 함수 포인터 테이블을 거치는 간접 호출로 만들어져 인라인되지 않는 경우가 있습니다. 성능이 중요한 루프라면 벤치마크보다 생성된 어셈블리를 확인하는 편이 더 정확합니다.

sizeof 비교

union Data {
    int i;      // 4바이트
    double d;   // 8바이트
    char c;     // 1바이트
};

std::cout << sizeof(Data) << '\n';  // 8 (최대 크기)

std::variant<int, double, char> data;
std::cout << sizeof(data) << '\n';  // 16 (최대 크기 8 + 인덱스 + 패딩)

판별자와 패딩이 붙는 메모리 레이아웃

// union: 최대 크기
union Data {
    char c;     // 1바이트
    int i;      // 4바이트
    double d;   // 8바이트
};
sizeof(Data);  // 8바이트 (최대 크기)

// std::variant: 최대 크기 + 인덱스
std::variant<char, int, double> data;
sizeof(data);  // 16바이트 (8 + 인덱스 + 패딩)

// 메모리 레이아웃
// [데이터 8바이트][인덱스 1바이트][패딩 7바이트]

get_if와 visit의 접근 비용

// 접근 비용의 차이
// union (직접 접근): 분기 없음
// variant (std::get): 인덱스 검사 분기 1회 (틀리면 예외)
// variant (std::get_if): 인덱스 검사 후 포인터 반환
// variant (std::visit): 구현에 따라 switch 또는 함수 포인터 테이블 디스패치

JSON 값·AST·프로토콜 메시지·설정 값 모델링

JSON 값 타입

// ⚠️ 설명용: 별칭이 자기 자신을 참조하므로 이대로는 컴파일되지 않음 (아래 설명 참고)
using JsonValue = std::variant<
    std::nullptr_t,
    bool,
    int64_t,
    double,
    std::string,
    std::vector<JsonValue>,
    std::map<std::string, JsonValue>
>;

class Json {
    JsonValue value_;
    
public:
    Json() : value_(nullptr) {}
    Json(bool v) : value_(v) {}
    Json(int v) : value_(static_cast<int64_t>(v)) {}
    Json(double v) : value_(v) {}
    Json(const std::string& v) : value_(v) {}
    
    template <typename T>
    T get() const {
        return std::get<T>(value_);
    }
    
    void print() const {
        std::visit(overloaded{
            [](std::nullptr_t) { std::cout << "null"; },
            [](bool v) { std::cout << (v ? "true" : "false"); },
            [](int64_t v) { std::cout << v; },
            [](double v) { std::cout << v; },
            [](const std::string& v) { std::cout << "\"" << v << "\""; },
            [](const auto&) { std::cout << "[complex]"; }
        }, value_);
    }
};

int main() {
    Json j1 = 42;
    Json j2 = 3.14;
    Json j3 = std::string("Hello");
    
    j1.print();  // 42
    std::cout << '\n';
    j2.print();  // 3.14
    std::cout << '\n';
    j3.print();  // "Hello"
    std::cout << '\n';
}

이 예제의 JsonValue는 구조를 보여 주기 위한 것으로, 실제로는 “‘JsonValue’ was not declared in this scope” 같은 에러로 컴파일되지 않습니다. using 별칭은 선언이 끝나기 전에는 자기 자신을 이름으로 쓸 수 없기 때문입니다. 재귀 구조를 variant로 만들려면 struct JsonValue;로 먼저 선언한 뒤 struct JsonValue { std::variant<std::nullptr_t, bool, int64_t, double, std::string, std::vector<JsonValue>, std::map<std::string, JsonValue>> v; };처럼 구조체로 한 번 감싸야 합니다. std::vector는 C++17부터 불완전 타입을 원소로 선언하는 것을 표준으로 허용하고, std::map은 표준 보장은 없지만 주요 구현에서 동작하므로 이 방식이 흔히 쓰입니다. 또 print의 방문자는 마지막에 [](const auto&) 포괄 람다를 두어 배열과 객체를 [complex]로 뭉뚱그리는데, 앞에서 말했듯 이런 포괄 람다는 새로 추가한 타입의 처리 누락을 컴파일러가 잡아 주지 못하게 만든다는 점도 함께 고려해야 합니다.

수식 AST 노드

// ⚠️ 설명용: AstNode를 선언 전에 사용하므로 이대로는 컴파일되지 않음
struct NumberNode {
    double value;
};

struct StringNode {
    std::string value;
};

struct BinaryOpNode {
    char op;
    std::unique_ptr<AstNode> left;
    std::unique_ptr<AstNode> right;
};

using AstNode = std::variant<NumberNode, StringNode, BinaryOpNode>;

double evaluate(const AstNode& node) {
    return std::visit(overloaded{
        [](const NumberNode& n) { return n.value; },
        [](const StringNode& s) { return static_cast<double>(s.value.size()); },
        [](const BinaryOpNode& 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;
                default: return 0.0;
            }
        }
    }, node);
}

예시 2도 같은 문제가 있습니다. BinaryOpNode가 아직 선언되지 않은 AstNode를 unique_ptr로 참조하기 때문입니다. 아래 “컴파일러 AST와 재귀 variant” 예제처럼 노드 구조체를 먼저 전방 선언하고 variant에 std::unique_ptr<BinaryOp>를 넣는 방식으로 순서를 바꾸면 해결됩니다. 재귀 구조에 포인터가 필요한 이유는 variant가 값을 자기 안에 직접 담기 때문입니다. 크기가 정해지지 않은 자기 자신을 값으로 품을 수는 없으므로, 재귀가 들어가는 지점에서는 힙에 할당하고 포인터로 연결해야 합니다. 이 때문에 variant 기반 AST는 상속 기반 AST보다 노드 종류 추가가 번거롭지만(모든 방문자를 고쳐야 함), 대신 새로운 연산을 추가하기 쉽고 가상 함수 호출이 없다는 장단점을 가집니다.

// ✅ std::variant: Result 타입
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 std::holds_alternative<T>(value_);
    }
    
    bool isErr() const {
        return std::holds_alternative<E>(value_);
    }
    
    T& value() {
        return std::get<T>(value_);
    }
    
    E& error() {
        return std::get<E>(value_);
    }
    
    T valueOr(T defaultValue) const {
        if (isOk()) {
            return std::get<T>(value_);
        }
        return defaultValue;
    }
};

Result<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return std::string("0으로 나눌 수 없음");
    }
    return a / b;
}

int main() {
    auto result1 = divide(10, 2);
    if (result1.isOk()) {
        std::cout << "결과: " << result1.value() << '\n';  // 5
    }
    
    auto result2 = divide(10, 0);
    if (result2.isErr()) {
        std::cout << "에러: " << result2.error() << '\n';  // 0으로 나눌 수 없음
    }
}

컴파일러 AST와 재귀 variant

// ✅ 실무 예시: AST 노드
struct BinaryOp;
struct UnaryOp;
struct Literal;
struct Variable;

using Expr = std::variant<
    Literal,
    Variable,
    std::unique_ptr<BinaryOp>,
    std::unique_ptr<UnaryOp>
>;

struct BinaryOp {
    char op;  // '+', '-', '*', '/'
    Expr left;
    Expr right;
};

struct Literal {
    double value;
};

// 평가
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);
}

프로토콜 메시지 디스패치

// ✅ 실무 예시: 네트워크 메시지
struct LoginMessage {
    std::string username;
    std::string password;
};

struct ChatMessage {
    std::string sender;
    std::string content;
};

struct FileTransfer {
    std::string filename;
    std::vector<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";
    }
};

여러 타입을 갖는 설정 값

// ✅ 실무 예시: 설정 시스템
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] = 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 (size_t i = 0; i < v.size(); ++i) {
                    if (i > 0) result += ", ";
                    result += v[i];
                }
                result += "]";
                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';  // [*]

그래도 union이 필요한 곳

C API와의 호환

// ✅ union: C 호환
extern "C" {
    union IpAddress {
        uint32_t ipv4;
        uint8_t ipv6[16];
    };
    
    void process_ip(IpAddress addr, bool isIpv6);
}

비트 단위 타입 재해석

// ⚠️ C에서는 허용되지만 C++에서는 정의되지 않은 동작
union FloatBits {
    float f;
    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)
uint32_t bits = std::bit_cast<uint32_t>(3.14f);

union으로 비트 패턴을 읽는 “타입 퍼닝”은 C에서는 명시적으로 허용되지만, C++ 표준에서는 비활성 멤버를 읽는 것이므로 정의되지 않은 동작입니다. GCC와 Clang은 확장으로 이 동작을 보장해 주기 때문에 실제로는 잘 돌아가는 경우가 많지만, 이식성이 필요한 코드라면 C++20의 std::bit_cast나 std::memcpy를 쓰는 것이 정석입니다. memcpy는 호출처럼 보여도 컴파일러가 레지스터 이동 하나로 최적화하므로 성능 걱정은 필요 없습니다. 이 점에서 C++에서 union이 여전히 필요한 곳은 C와 공유하는 구조체 레이아웃, 메모리를 극도로 아껴야 하는 임베디드 코드, 그리고 variant 같은 라이브러리 타입을 직접 구현하는 경우 정도로 좁혀집니다.


variant 타입 설계와 리뷰 기준

대안 타입 목록 설계

// ✅ 명확한 타입 이름
using JsonValue = std::variant<
    std::nullptr_t,
    bool,
    int64_t,
    double,
    std::string,
    std::vector<JsonValue>,
    std::map<std::string, JsonValue>
>;

// ✅ 작은 타입 우선
std::variant<int, double, std::string>;  // int가 가장 작음

// ⚠️ 중복 타입: 선언은 가능하지만 타입으로 구분할 수 없음
std::variant<int, int>;  // v = 1; 이나 std::get<int>(v)는 모호해서 컴파일 에러
                         // std::in_place_index와 std::get<0>(v)로만 다룰 수 있음

방문자 작성 방식

// ✅ 타입별 처리
template <typename... Ts>
struct Visitor : Ts... { using Ts::operator()...; };

template <typename... Ts>
Visitor(Ts...) -> Visitor<Ts...>;

// 사용
std::variant<int, double, std::string> data = 42;

std::visit(Visitor{
    [](int x) { std::cout << "정수: " << x << '\n'; },
    [](double x) { std::cout << "실수: " << x << '\n'; },
    [](const std::string& x) { std::cout << "문자열: " << x << '\n'; }
}, data);

코드 리뷰 체크포인트

// 🔍 리뷰 시 확인사항

// 1. union 타입 추적
union Data { int i; double d; };  // ⚠️ 타입 추적?

// 2. variant 타입 체크
std::get<int>(data);  // ⚠️ 예외 처리?

// 3. 생성자/소멸자
union Data { std::string s; };  // ⚠️ 컴파일 에러

// 4. C 호환
union IpAddr { /* ... */ };  // ✅ C 호환 필요?

union 비POD 멤버·타입 체크 누락·variant 크기 문제

union에 std::string 같은 비POD 타입을 넣음

// ❌ 실수: 생성자 있는 타입
union Data {
    int i;
    std::string s;  // 컴파일 에러
};
// error: union member 'std::string s' with non-trivial constructor

// ✅ std::variant 사용
std::variant<int, std::string> data;
data = 42;
data = std::string("Hello");

variant에서 get을 타입 확인 없이 호출

// ❌ 실수: 타입 체크 없음
std::variant<int, double, std::string> data = 42;
auto value = std::get<double>(data);  // ❌ 예외 던짐

// ✅ 타입 체크
if (std::holds_alternative<int>(data)) {
    auto value = std::get<int>(data);
}

// ✅ get_if 사용
if (auto* ptr = std::get_if<int>(&data)) {
    std::cout << *ptr << '\n';
}

union의 활성 멤버를 추적하지 않음

// ❌ 실수: 타입 추적 없음
union Data {
    int i;
    double d;
};

Data data;
data.i = 42;
std::cout << data.d << '\n';  // ❌ 미정의 동작

// ✅ 타입 추적
enum class Type { INT, DOUBLE };
struct TaggedData {
    Type type;
    union {
        int i;
        double d;
    };
};

큰 대안 타입 하나 때문에 variant가 커질 때

증상:

std::variant<char, int, std::string> data;
std::cout << sizeof(data) << '\n';  // 40바이트 (예상보다 큼)

원인: 가장 큰 타입 + 인덱스 + 패딩

해결:

// 1. 작은 타입만 사용
std::variant<char, short, int> data;  // 8바이트

// 2. 포인터 사용
std::variant<char, int, std::string*> data;  // 16바이트

// 3. 타입 재설계
using SmallString = std::array<char, 16>;
std::variant<char, int, SmallString> data;

방문자 분기가 복잡해질 때

증상:

// 복잡한 방문자 코드
std::visit([](auto arg) {
    using T = std::decay_t<decltype(arg)>;
    if constexpr (std::is_same_v<T, int>) { /* ... */ }
    else if constexpr (std::is_same_v<T, double>) { /* ... */ }
    // ...
}, data);

해결:

// 오버로드 패턴 헬퍼
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

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);

방문자 안에서 현재 인덱스 알아내기

std::visit의 방문자는 값만 받고 인덱스를 따로 받지 않습니다. 인덱스가 필요하면 index()를 캡처해서 씁니다.

std::variant<int, double, std::string> data = 42;

std::visit([idx = data.index()](const auto& arg) {
    std::cout << "인덱스: " << idx << ", 값: " << arg << '\n';
}, data);

C++20에서는 방문자의 반환 타입을 명시하는 std::visit<R>(...)이 추가되어, 앞의 toString 예제처럼 람다마다 반환 타입이 다를 때 공통 타입으로 맞출 수 있게 되었습니다. C++26에는 data.visit(visitor)처럼 멤버 함수로 호출하는 형태가 추가될 예정입니다.


variant와 union 선택 요약

상황별 선택표

상황사용
타입 안전성std::variant
복잡한 타입std::variant
생성자/소멸자std::variant
방문자 패턴std::variant
C 호환union
POD 타입만union
비트 조작union

네 가지 규칙

  1. 기본: std::variant (타입 안전)
  2. C 호환: union
  3. 복잡한 타입: std::variant
  4. POD만: union

다음 단계: std::variant를 이해했다면, C++ std::any와 비교해 보세요. variant는 담을 수 있는 타입 목록이 컴파일 시점에 고정되는 반면, any는 어떤 타입이든 담는 대신 꺼낼 때 타입을 정확히 알아야 합니다.


같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. std::variant의 sizeof가 가장 큰 타입보다 큰 이유는 무엇인가요?

A. std::variant는 가장 큰 대안 타입을 담을 공간에 더해, 지금 어떤 타입이 들어 있는지 나타내는 인덱스(판별자)를 함께 저장하고 정렬을 맞추기 위한 패딩이 붙습니다. 그래서 std::string 같은 큰 타입이 하나만 섞여도 char나 int를 담을 때조차 전체 크기가 그만큼 커집니다. 드물게 쓰이는 큰 대안은 std::unique_ptr 등으로 간접 보관하면 variant 자체의 크기를 줄일 수 있지만, 대신 힙 할당이 추가된다는 점을 함께 따져야 합니다.