C++17 optional·variant·any: nullptr 체크 대신 타입으로 표현하기

들어가며

사용자 정보를 조회하는 함수는 “사용자가 없음”을 어떻게든 표현해야 합니다. std::optional은 “값이 있거나 없거나”를 타입으로 표현해서 nullptr(널 포인터—아무 객체도 가리키지 않음을 나타내는 값)·예외·bool+참조 패턴보다 명확하며, 호출하는 쪽에서 반드시 유무를 확인하게 만들 수 있습니다. std::variant(정해진 타입 목록 중 하나를 담는 타입)·std::any(어떤 타입이든 담을 수 있는 타입)는 “여러 타입 중 하나”나 “타입을 나중에 정하는 값”을 타입 안전하게 다룰 때 쓰면, void*나 공용체보다 안전합니다.

C++17 이전에 흔히 쓰던 세 가지 방법은 각각 단점이 있습니다.

// ❌ 방법 1: 포인터 (메모리 관리 복잡)
User* findUser(int id) {
    if (존재하지않음) return nullptr;
    return new User{...};  // 누가 delete?
}

// ❌ 방법 2: 예외 (없음은 흔한 결과인데 예외 경로로 처리)
User findUser(int id) {
    if (존재하지않음) throw std::runtime_error("Not found");
    return User{...};
}

// ❌ 방법 3: bool + 참조 (복잡)
bool findUser(int id, User& out) {
    if (존재하지않음) return false;
    out = User{...};
    return true;
}

optional을 쓰면 반환 타입 자체가 “없을 수 있음”을 말해 줍니다.

std::optional<User> findUser(int id) {
    if (존재하지않음) {
        return std::nullopt;  // 값 없음
    }
    return User{...};  // 값 있음
}

int main() {
    auto user = findUser(123);
    
    if (user) {
        std::cout << "Found: " << user->name << "\n";
    } else {
        std::cout << "Not found\n";
    }
}

“값이 없을 수 있다”만 표현하면 optional, “여러 타입 중 하나”면 variant, “타입을 아예 나중에 정한다”면 any입니다. any는 타입 안전성이 떨어지므로 꼭 필요할 때만 쓰며, 대부분은 optional과 variant로 커버할 수 있습니다.

nullptr 체크 누락, 타입 혼동, 동적 타입 전달 문제

시나리오 1: nullptr 체크 누락으로 크래시

// ❌ 문제: nullptr 체크를 깜빡하면 크래시
User* user = findUser(123);
std::cout << user->name << "\n";  // user가 nullptr면 Segmentation fault!

// ✅ 해결: optional은 타입으로 "없을 수 있음"을 강제
std::optional<User> user = findUser(123);
if (user) {
    std::cout << user->name << "\n";  // 확인 후 접근
}

주의할 점은 optional도 ->와 *를 제공하기 때문에 user->name을 확인 없이 쓰면 컴파일은 됩니다(값이 없으면 UB). optional이 주는 이점은 컴파일러가 막아 준다는 것이 아니라, 반환 타입만 보고도 “없을 수 있다”는 사실이 드러나 호출부가 확인을 빠뜨리기 어려워진다는 데 있습니다. 확인 없이 꺼내야 하는 곳이라면 value()(없으면 예외)나 value_or()를 쓰는 편이 안전합니다.

시나리오 2: JSON/설정 파싱 시 타입 혼동

// ❌ 문제: "port"가 정수인지 문자열인지 런타임에만 알 수 있음
void* getConfig(const std::string& key);  // 반환 타입이 뭔지 모름

// ✅ 해결: variant로 허용 타입을 명시
using ConfigValue = std::variant<int, double, std::string, bool>;
std::optional<ConfigValue> getConfig(const std::string& key);

시나리오 3: 플러그인/스크립트에서 동적 타입 전달

// ❌ 문제: C++ API에 Python/JS에서 온 값을 넘겨야 함
void setProperty(const std::string& name, ??? value);  // 타입을 미리 알 수 없음

// ✅ 해결: any (최후의 수단, 타입 체크는 런타임에)
void setProperty(const std::string& name, std::any value);

시나리오 4: 이벤트·메시지가 여러 타입 중 하나

// ❌ 상속 + dynamic_cast 사슬: 새 이벤트를 추가해도 빠진 분기를 컴파일러가 알려 주지 않음
if (auto* e = dynamic_cast<MouseEvent*>(event)) { /* ... */ }
else if (auto* e = dynamic_cast<KeyEvent*>(event)) { /* ... */ }

// ✅ variant + visit: 처리하지 않은 타입이 있으면 컴파일 단계에서 드러남
using Event = std::variant<MouseClick, KeyPress, TimerTick>;

GUI 이벤트, 네트워크 메시지, 상태 머신의 상태처럼 종류가 정해져 있고 자주 늘지 않는 값은 variant가 잘 맞습니다. 전체 예제는 variant 이벤트 시스템 예제에 있습니다.

시나리오 5: 파싱 실패를 예외로 처리하는 부담

// ❌ 사용자 입력은 틀린 게 정상 경로인데 예외로 흐름 제어
int port = std::stoi(str);  // "abc"면 std::invalid_argument

// ✅ 실패도 반환값으로
std::optional<int> port = parseInt(str);

설정 파일이나 사용자 입력에서 “형식이 틀림”은 예외적인 상황이 아니라 자주 일어나는 분기입니다. 이런 곳에서 예외를 쓰면 호출부마다 try-catch가 붙고, 어떤 함수가 무엇을 던지는지 시그니처로는 알 수 없습니다. optional(실패 이유가 필요 없을 때)이나 variant 기반 Result(이유가 필요할 때)로 바꾸면 실패 가능성이 반환 타입에 드러납니다.

타입 선택 흐름도

flowchart TD
    A[값이 없을 수 있나?] -->|예| B[optional]
    A -->|아니오| C[여러 타입 중 하나?]
    C -->|예, 타입 고정| D[variant]
    C -->|예, 타입 미정| E[any]
    C -->|아니오| F[일반 타입]

std::optional: 값이 없을 수 있음을 타입으로 표현

기본 사용법

std::optional<T>는 “T 타입 값이 있거나, 없거나”를 하나의 타입으로 표현합니다. 아래 divide의 0으로 나누는 경우처럼 유효한 값을 반환할 수 없는 상황에서 std::nullopt를 반환하며, 정상일 때만 return a / b로 값을 담아 반환합니다. 호출부에서는 if (result)로 값 존재 여부를 확인한 뒤 *result 또는 result.value()로 접근하면, 포인터나 예외 없이 “값 없음”을 다룰 수 있습니다.

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o optional_divide optional_divide.cpp && ./optional_divide
#include <optional>
#include <iostream>

std::optional<int> divide(int a, int b) {
    if (b == 0) {
        return std::nullopt;  // 값 없음
    }
    return a / b;  // 값 있음
}

int main() {
    auto result = divide(10, 2);
    if (result) {
        std::cout << "Result: " << *result << "\n";  // 5
    }
    auto fail = divide(10, 0);
    if (!fail) {
        std::cout << "Division by zero\n";
    }
    return 0;
}

실행하면 Result: 5 한 줄, 이어서 Division by zero 한 줄이 출력됩니다.

값 확인과 접근

값이 있는지 확인하는 방법은 has_value(), if (opt)(bool 변환), value_or(기본값)이 있습니다. value()는 값이 없으면 std::bad_optional_access 예외를 던지므로, 먼저 has_value()나 if (opt)로 확인한 뒤 사용하는 것이 안전합니다. 기본값으로 대체하고 싶을 때는 value_or("Unknown")처럼 쓰면, 값이 없으면 “Unknown”이 반환됩니다.

#include <optional>
#include <iostream>
#include <string>

std::optional<std::string> getName() {
    return "Alice";
}

int main() {
    auto name = getName();
    
    // 방법 1: has_value()
    if (name.has_value()) {
        std::cout << name.value() << "\n";
    }
    
    // 방법 2: operator bool
    if (name) {
        std::cout << *name << "\n";
    }
    
    // 방법 3: value_or (기본값)
    std::string result = name.value_or("Unknown");
}

값이 있을 때만 쓰려면 if (opt) 또는 has_value()로 확인한 뒤 value() 또는 *opt로 접근합니다. 값이 없을 때 기본값으로 대체하려면 value_or(기본값)이 가장 간단합니다. *opt는 값이 없을 때 정의되지 않은 동작(UB)이므로 확실히 값이 있을 때만 씁니다.

예외 처리

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt;

    // ❌ 값이 없으면 예외
    try {
        int value = opt.value();  // std::bad_optional_access
    } catch (const std::bad_optional_access& e) {
        std::cerr << "No value\n";
    }

    // ✅ 권장: value_or
    int value = opt.value_or(0);
    return 0;
}

optional 생성

#include <optional>
#include <string>

// 빈 optional
std::optional<int> opt1;
std::optional<int> opt2 = std::nullopt;

// 값 있는 optional
std::optional<int> opt3 = 42;
std::optional<int> opt4{42};
std::optional<int> opt5 = std::make_optional(42);

// in-place 생성
std::optional<std::string> opt6(std::in_place, 10, 'x');  // "xxxxxxxxxx"

optional 수정

#include <optional>

int main() {
    std::optional<int> opt;

    // 값 할당
    opt = 42;

    // 값 제거
    opt.reset();
    opt = std::nullopt;

    // emplace
    opt.emplace(100);
    return 0;
}

완전한 optional 예제: 사용자 조회 시스템

// g++ -std=c++17 -o optional_user optional_user.cpp && ./optional_user
#include <optional>
#include <iostream>
#include <string>
#include <map>

struct User {
    int id;
    std::string name;
    int age;
};

std::map<int, User> db = {
    {1, {1, "Alice", 25}},
    {2, {2, "Bob", 30}},
};

std::optional<User> findUser(int id) {
    auto it = db.find(id);
    if (it == db.end()) {
        return std::nullopt;
    }
    return it->second;
}

int main() {
    // 케이스 1: 사용자 있음
    if (auto user = findUser(1)) {
        std::cout << "Found: " << user->name << ", " << user->age << "\n";
    }

    // 케이스 2: 사용자 없음
    auto missing = findUser(999);
    std::cout << "User 999: " << (missing ? "Found" : "Not found") << "\n";

    // 케이스 3: 기본값 사용
    auto user = findUser(2).value_or(User{0, "Guest", 0});
    std::cout << "User 2 or Guest: " << user.name << "\n";
    return 0;
}

std::variant: 정해진 타입 중 하나와 std::visit

기본 사용법

#include <variant>
#include <string>

std::variant<int, double, std::string> value;

value = 42;                    // int
value = 3.14;                  // double
value = std::string("hello");  // string

값 접근

#include <variant>
#include <iostream>
#include <string>

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

// 방법 1: std::get<T>
try {
    int i = std::get<int>(v);  // OK
    // double d = std::get<double>(v);  // 예외!
} catch (const std::bad_variant_access& e) {
    std::cerr << "Wrong type\n";
}

// 방법 2: std::get<index>
int i = std::get<0>(v);  // 첫 번째 타입

// 방법 3: std::get_if
if (auto ptr = std::get_if<int>(&v)) {
    std::cout << "int: " << *ptr << "\n";
}

타입 확인

#include <variant>
#include <iostream>
#include <string>

std::variant<int, double, std::string> v = 3.14;

// index()
std::cout << v.index() << "\n";  // 1 (double은 두 번째)

// holds_alternative
if (std::holds_alternative<double>(v)) {
    std::cout << "It's a double\n";
}

std::visit

#include <variant>
#include <iostream>
#include <string>
#include <type_traits>

std::variant<int, double, std::string> v = 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";
    }
}, v);

“지금 variant가 들고 있는 타입이 뭐든, 그에 맞는 동작을 한 번에 적용”하고 싶을 때 std::visit를 씁니다. get<T>나 get_if<T>는 타입을 미리 알고 있을 때 쓰며, visit는 담긴 타입이 무엇이든 처리해야 할 때(로깅, 직렬화, 포맷 출력 등) 씁니다.

오버로드 패턴

#include <variant>
#include <iostream>
#include <string>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    std::variant<int, double, std::string> v = "hello";
    
    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);
}

overloaded는 여러 람다(함수 객체)를 하나로 묶어서, visit가 variant에 들어 있는 값의 타입에 따라 해당하는 람다만 호출하게 합니다. 타입별로 get_if로 분기하는 대신, “int면 이렇게, double이면 이렇게, string이면 이렇게”를 한 번에 나열해 두는 방식이라 가독성이 좋습니다.

완전한 variant 예제: Result 타입 (에러 처리)

// g++ -std=c++17 -o variant_result variant_result.cpp && ./variant_result
#include <variant>
#include <iostream>
#include <string>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

struct Error {
    std::string message;
    int code;
};

template <typename T>
using Result = std::variant<T, Error>;

Result<int> divide(int a, int b) {
    if (b == 0) {
        return Error{"Division by zero", -1};
    }
    return a / b;
}

int main() {
    auto r1 = divide(10, 2);
    std::visit(overloaded{
         [](int v) { std::cout << "Result: " << v << "\n"; },
         [](const Error& e) { std::cerr << "Error: " << e.message << "\n"; }
    }, r1);

    auto r2 = divide(10, 0);
    std::visit(overloaded{
         [](int v) { std::cout << "Result: " << v << "\n"; },
         [](const Error& e) { std::cerr << "Error: " << e.message << " (code=" << e.code << ")\n"; }
    }, r2);
    return 0;
}

완전한 variant 예제: 이벤트 시스템

시나리오 4의 이벤트 처리를 variant로 옮긴 예제입니다. 이벤트 타입은 서로 상속 관계가 없는 평범한 구조체이고, 처리 로직은 handle 한곳에 모입니다.

// g++ -std=c++17 -o variant_event variant_event.cpp && ./variant_event
#include <variant>
#include <vector>
#include <iostream>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

struct MouseClick { int x, y; };
struct KeyPress   { char key; };
struct TimerTick  { int id; };

using Event = std::variant<MouseClick, KeyPress, TimerTick>;

void handle(const Event& e) {
    std::visit(overloaded{
        [](const MouseClick& c) { std::cout << "Mouse: (" << c.x << ", " << c.y << ")\n"; },
        [](const KeyPress& k)   { std::cout << "Key: " << k.key << "\n"; },
        [](const TimerTick& t)  { std::cout << "Timer: " << t.id << "\n"; }
    }, e);
}

int main() {
    std::vector<Event> queue{MouseClick{10, 20}, KeyPress{'A'}, TimerTick{1}};
    for (const auto& e : queue) {
        handle(e);
    }
    return 0;
}

상속 대신 variant를 고르는 기준은 타입 목록이 닫혀 있는지입니다. variant는 타입 목록이 닫혀 있는(closed set) 경우에 강합니다. Event에 Resize를 추가하면 overloaded에 해당 람다가 없는 모든 visit 호출부가 컴파일 에러가 나므로, 처리 누락을 빌드 단계에서 잡을 수 있습니다. 값으로 저장되니 std::vector<Event>에 힙 할당 없이 연속으로 담기는 것도 장점입니다. 반대로 플러그인처럼 외부에서 새 타입이 계속 추가되는(open set) 구조라면, 매번 variant 정의를 고쳐야 하므로 가상 함수 기반 상속이 더 자연스럽습니다. 또 variant의 크기는 가장 큰 대안 타입에 맞춰지므로, 대안 하나만 유난히 크다면 그 타입만 std::unique_ptr로 감싸는 방법을 고려합니다.


std::any: 어떤 타입이든 담는 대가

기본 사용법

#include <any>
#include <string>
#include <vector>

std::any value;

value = 42;                    // int
value = 3.14;                  // double
value = std::string("hello");  // string
value = std::vector<int>{1, 2, 3};  // vector

값 접근

#include <any>
#include <iostream>

std::any a = 42;

// any_cast
try {
    int i = std::any_cast<int>(a);  // OK
    // double d = std::any_cast<double>(a);  // 예외!
} catch (const std::bad_any_cast& e) {
    std::cerr << "Wrong type\n";
}

// 포인터 버전 (예외 안 던짐)
if (auto ptr = std::any_cast<int>(&a)) {
    std::cout << "int: " << *ptr << "\n";
} else {
    std::cout << "Not an int\n";
}

타입 확인

#include <any>
#include <iostream>
#include <typeinfo>

std::any a = 42;

// has_value()
if (a.has_value()) {
    std::cout << "Has value\n";
}

// type()
if (a.type() == typeid(int)) {
    std::cout << "It's an int\n";
}

any 수정

#include <any>
#include <string>

int main() {
    std::any a;

    // 값 할당
    a = 42;

    // 값 제거
    a.reset();

    // emplace
    a.emplace<std::string>("hello");
    return 0;
}

완전한 any 예제: 속성 저장소

// g++ -std=c++17 -o any_properties any_properties.cpp && ./any_properties
#include <any>
#include <optional>
#include <iostream>
#include <string>
#include <map>

class PropertyStore {
    std::map<std::string, std::any> data;

public:
    template <typename T>
    void set(const std::string& key, const T& value) {
        data[key] = value;
    }

    template <typename T>
    std::optional<T> get(const std::string& key) const {
        auto it = data.find(key);
        if (it == data.end()) {
            return std::nullopt;
        }
        try {
            return std::any_cast<T>(it->second);
        } catch (const std::bad_any_cast&) {
            return std::nullopt;
        }
    }
};

int main() {
    PropertyStore store;
    store.set("port", 8080);
    store.set("host", std::string("localhost"));
    store.set("ratio", 0.95);

    if (auto port = store.get<int>("port")) {
        std::cout << "Port: " << *port << "\n";
    }
    if (auto host = store.get<std::string>("host")) {
        std::cout << "Host: " << *host << "\n";
    }
    if (auto ratio = store.get<double>("ratio")) {
        std::cout << "Ratio: " << *ratio << "\n";
    }
    if (!store.get<int>("missing")) {
        std::cout << "Missing key returns nullopt\n";
    }
    return 0;
}

optional, variant, any 중 고르는 기준

optional vs variant vs any

특징optionalvariantany
용도값 있음/없음여러 타입 중 하나임의 타입
타입 개수1개고정 (컴파일 타임)무제한 (런타임)
타입 안전성높음높음낮음
성능빠름빠름느림 (힙 할당 가능)
메모리객체 안에 값 보관객체 안에 값 보관작은 값은 내부 버퍼, 큰 값은 힙

사용 예

optional은 값이 없을 수 있는 결과에 씁니다.

// 값이 없을 수 있음
std::optional<User> findUser(int id);
std::optional<int> parseInt(const std::string& str);
std::optional<std::string> getEnv(const std::string& key);

variant는 정해진 타입 중 하나를 담습니다.

// 여러 타입 중 하나 (고정)
using ConfigValue = std::variant<int, double, std::string>;
using Result = std::variant<Success, Error>;
using Shape = std::variant<Circle, Rectangle, Triangle>;

any는 타입을 미리 알 수 없을 때만 씁니다.

// 타입을 미리 알 수 없음
std::map<std::string, std::any> properties;
std::vector<std::any> heterogeneousContainer;

bad_optional_access, bad_variant_access, bad_any_cast

문제 1: optional에 값 없는데 value()나 *opt 호출

// ❌ 잘못된 사용
std::optional<int> opt;
int x = opt.value();  // 예외!
int y = *opt;         // UB!

// ✅ 해결
if (opt) {
    int x = opt.value();
}
int y = opt.value_or(0);

문제 2: variant에 get으로 잘못된 타입 요청

// ❌ 잘못된 사용
std::variant<int, double> v = 42;
double d = std::get<double>(v);  // 예외!
// ✅ 해결: get_if 또는 holds_alternative 먼저 확인
if (auto ptr = std::get_if<double>(&v)) {
    double d = *ptr;
}

문제 3: any_cast로 잘못된 타입 요청

// ❌ 잘못된 사용
std::any a = 42;
std::string s = std::any_cast<std::string>(a);  // 예외!
// ✅ 해결: 포인터 버전 사용 또는 예외 처리
if (auto ptr = std::any_cast<int>(&a)) {
    int i = *ptr;
} else {
    // 다른 타입 처리
}

문제 4: optional 중첩

std::optional<std::optional<T>>는 “바깥이 비었음”과 “안쪽이 비었음”을 구분해야 해서 호출부가 헷갈립니다. 대개 의도한 것은 단일 optional입니다.

// ❌ 혼란스러운 반환
std::optional<std::optional<int>> weird() {
    return std::optional<int>(42);  // optional을 optional로 감쌈
}

// ✅ 해결: 단일 optional
std::optional<int> clean() {
    return 42;
}

문제 5: variant에 void 타입 포함

variant는 void를 대안으로 담을 수 없어 컴파일 에러가 납니다. “값 없음” 상태가 필요하면 std::monostate를 씁니다.

// ❌ 잘못된 사용
std::variant<int, void> v;  // 컴파일 에러!

// ✅ 해결: std::monostate로 "빈" 상태 표현
std::variant<std::monostate, int> v;
v = std::monostate{};  // "값 없음" 의미

문제 6: any에 참조 저장

std::any는 참조를 decay해서 값으로 복사해 저장합니다.

// ❌ 의도와 다름
int x = 42;
std::any a = x;  // int 복사본 저장, x와 연결 없음
*std::any_cast<int>(&a) = 100;  // a 내부만 변경, x는 여전히 42
// (참고: std::any_cast<int>(a)는 복사본을 반환하므로 여기에 대입할 수도 없음)

// ✅ 참조가 필요하면 std::reference_wrapper
#include <functional>
std::any b = std::ref(x);
std::any_cast<std::reference_wrapper<int>>(b).get() = 100;  // x도 100

문제 7: visit에서 빠진 타입이 엉뚱한 람다로 들어감

variant에 새 타입을 추가했는데 컴파일 에러 없이 다른 분기가 실행되는 경우가 있습니다.

using V = std::variant<int, bool, std::string>;   // bool을 새로 추가
std::visit(overloaded{
    [](int i) { std::cout << "int " << i; },      // bool도 여기로 들어옴!
    [](const std::string& s) { std::cout << s; }
}, v);

overloaded는 일반 오버로드 해석을 따르므로, 정확히 맞는 람다가 없으면 암시적 변환이 가능한 람다가 선택됩니다. bool→int, char→int, float→double 같은 조합에서 누락이 조용히 숨어 버립니다. [](auto&&) 같은 포괄 람다에 기본 동작을 넣어 두는 경우도 마찬가지로 누락이 드러나지 않습니다. 반대로 이 성질을 이용할 수 있습니다. 포괄 람다는 어떤 타입이든 변환 없이 받으므로 변환이 필요한 람다보다 우선 선택되는데, 여기에 static_assert를 넣어 두면 처리하지 않은 타입이 생기는 순간 컴파일이 실패합니다.

template<class> inline constexpr bool always_false = false;

std::visit(overloaded{
    [](int i) { /* ... */ },
    [](bool b) { /* ... */ },
    [](const std::string& s) { /* ... */ },
    [](auto&& other) {
        static_assert(always_false<std::decay_t<decltype(other)>>, "처리하지 않은 타입");
    }
}, v);

문제 8: optional에 참조를 담고 싶음

C++23까지는 std::optional<int&>가 컴파일 에러입니다.

C++17·20·23의 optional은 참조 타입을 담을 수 없습니다. “없을 수도 있는 참조”는 지금까지 T*(가장 흔함)나 std::optional<std::reference_wrapper<T>>로 표현해 왔고, std::optional<T&>는 C++26에 들어갔습니다. 컨테이너에서 찾은 원소를 복사 없이 돌려주고 싶다면, C++26 이전에는 널이 될 수 있는 포인터 반환이 여전히 가장 간단한 선택입니다.

#include <functional>
std::optional<std::reference_wrapper<User>> findRef(int id);  // C++17 우회
User* findPtr(int id);                                         // 흔한 대안

비용 구조 비교

연산optionalvariantany
생성T 생성 + 플래그 설정, 힙 없음대안 타입 생성 + 인덱스 설정, 힙 없음작은 타입은 내부 버퍼(SBO), 큰 타입은 힙 할당
복사T 복사 비용현재 담긴 타입의 복사 비용타입 소거된 복사 함수 호출 (+ 힙 할당 가능)
접근플래그 확인 후 직접 접근, 인라인 가능인덱스 기반 분기타입 비교 후 캐스트
메모리sizeof(T) + 플래그(정렬 패딩 포함)가장 큰 대안 + 인덱스(정렬 포함)구현마다 다른 고정 크기 + 필요 시 힙

std::any의 내부 버퍼 크기는 표준이 정하지 않고 구현마다 다르므로, “어떤 크기부터 힙에 가는지”를 코드가 가정하면 안 됩니다. 실제 차이가 궁금하다면 대상 컴파일러에서 sizeof와 프로파일러로 직접 확인하는 것이 정확합니다.

성능 팁

std::optional<std::string_view>는 문자열을 복사하지 않아 std::optional<std::string>보다 가볍지만, 가리키는 원본 문자열이 살아 있는 동안만 유효하므로 반환값으로 쓸 때는 수명을 확인해야 합니다. std::visit는 보통 인덱스로 분기하는 점프 테이블이나 switch로 구현되고, 대안이 적으면 인라인되는 경우가 많지만 컴파일러와 최적화 수준에 따라 다릅니다. std::any는 담는 타입에 따라 힙 할당과 타입 소거된 복사가 생기므로, 핫 루프보다는 설정 로드나 플러그인 경계처럼 드물게 지나가는 곳에 둡니다.

// ❌ any를 반복문에서 매번 사용
for (int i = 0; i < 1000000; ++i) {
    std::any val = getValue(i);  // 매번 힙 할당 가능
    process(val);
}

// ✅ variant로 타입 고정
using Value = std::variant<int, double, std::string>;
for (int i = 0; i < 1000000; ++i) {
    Value val = getValueVariant(i);  // variant 자체는 힙 할당 없음 (string 내용은 별개)
    process(val);
}

안전한 파싱, 설정 값, 모나딕 연산 체이닝

패턴 1: 안전한 파싱

#include <optional>
#include <iostream>
#include <string>
#include <stdexcept>

std::optional<int> parseInt(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        return std::nullopt;
    }
}

// 주의: std::stoi("123abc")는 예외 없이 123을 반환하므로 엄격한 검증에는
// 끝까지 소비했는지 확인하거나 std::from_chars를 쓰는 편이 낫습니다.
int main() {
    auto num = parseInt("123");
    if (num) {
        std::cout << "Parsed: " << *num << "\n";
    }
    
    auto fail = parseInt("abc");
    int value = fail.value_or(0);  // 기본값
}

패턴 2: 설정 값

#include <variant>
#include <optional>
#include <map>
#include <iostream>
#include <string>

class Config {
    std::map<std::string, std::variant<int, double, std::string>> data;
    
public:
    template <typename T>
    void set(const std::string& key, const T& value) {
        data[key] = value;
    }
    
    template <typename T>
    std::optional<T> get(const std::string& key) const {
        auto it = data.find(key);
        if (it == data.end()) {
            return std::nullopt;
        }
        
        if (auto ptr = std::get_if<T>(&it->second)) {
            return *ptr;
        }
        return std::nullopt;
    }
};

int main() {
    Config config;
    config.set("port", 8080);
    config.set("host", std::string("localhost"));
    
    if (auto port = config.get<int>("port")) {
        std::cout << "Port: " << *port << "\n";
    }
}

패턴 3: 에러 처리

#include <variant>
#include <iostream>
#include <string>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

template <typename T, typename E>
using Result = std::variant<T, E>;

struct Error {
    std::string message;
};

Result<int, Error> divide(int a, int b) {
    if (b == 0) {
        return Error{"Division by zero"};
    }
    return a / b;
}

int main() {
    auto result = divide(10, 2);

    std::visit(overloaded{
         [](int value) { std::cout << "Result: " << value << "\n"; },
         [](const Error& err) { std::cerr << "Error: " << err.message << "\n"; }
    }, result);
}

std::variant<T, E>로 Result를 만들 때 흔히 걸리는 함정이 있습니다. T와 E가 같은 타입이면 성공과 실패를 구분할 수 없습니다. Result<std::string, std::string>은 std::variant<std::string, std::string>이 되어 return "ok";가 모호해지고, std::get<std::string>도 컴파일되지 않습니다. 그래서 위 예제처럼 에러를 Error 같은 전용 구조체로 감싸는 것이 사실상 필수입니다. std::string을 그대로 에러 타입으로 쓰고 싶어지는 순간이 오는데, 그때도 struct Error { std::string message; };로 한 겹 두는 편이 안전합니다.

C++23을 쓸 수 있다면 같은 목적의 표준 타입 std::expected<T, E>가 있습니다. T와 E가 같아도 구분되고(std::unexpected(e)로 에러를 만듦), has_value()·value()·error()와 아래의 모나딕 연산(and_then, transform, or_else, transform_error)을 제공하므로, 직접 만든 variant 기반 Result보다 먼저 검토할 만합니다.

패턴 4: 체이닝과 C++23 모나딕 연산

#include <optional>
#include <iostream>
#include <string>

std::optional<std::string> getName(int id) {
    if (id == 1) return "Alice";
    return std::nullopt;
}

std::optional<int> getAge(const std::string& name) {
    if (name == "Alice") return 25;
    return std::nullopt;
}

int main() {
    auto name = getName(1);
    if (name) {
        auto age = getAge(*name);
        if (age) {
            std::cout << *name << " is " << *age << "\n";
        }
    }
}

C++17에서는 optional을 반환하는 함수를 이어 붙일 때마다 if가 한 단계씩 깊어집니다. C++23은 optional에 세 가지 모나딕 연산을 추가해 이 중첩을 없앴습니다.

연산값이 있을 때값이 없을 때넘기는 함수의 반환 타입
and_then(f)f(값)의 결과(optional)를 그대로 반환빈 optionalstd::optional<U>
transform(f)f(값)을 optional로 감싸 반환빈 optional일반 값 U
or_else(f)자기 자신을 반환f()의 결과를 반환std::optional<T> (같은 타입)
// g++ -std=c++23 -o optional_monadic optional_monadic.cpp && ./optional_monadic
#include <optional>
#include <iostream>
#include <string>

std::optional<std::string> getName(int id) {
    if (id == 1) return "Alice";
    return std::nullopt;
}

std::optional<int> getAge(const std::string& name) {
    if (name == "Alice") return 25;
    return std::nullopt;
}

int main() {
    for (int id : {1, 2}) {
        std::string label = getName(id)
            .and_then(getAge)                                    // optional<int>
            .transform([](int age) { return std::to_string(age) + "세"; })  // optional<string>
            .or_else([] { return std::optional<std::string>{"알 수 없음"}; })
            .value();                                            // or_else 뒤라 항상 값이 있음
        std::cout << "id " << id << ": " << label << "\n";
    }
}

실행하면 id 1: 25세, id 2: 알 수 없음 두 줄이 출력됩니다.

and_then과 transform을 헷갈려 transform에 optional을 반환하는 함수를 넘기면 std::optional<std::optional<int>>가 만들어집니다(앞에서 본 optional 중첩 문제). 넘기는 함수가 실패할 수 있으면 and_then, 항상 성공하는 변환이면 transform으로 구분하면 됩니다. 또 or_else에 넘기는 함수는 대체 값이 아니라 같은 타입의 optional을 반환해야 합니다. 단순 기본값이면 체인 끝에서 value_or를 쓰는 편이 짧습니다. 체인이 길어지면 중간 단계에서 왜 비었는지 알 수 없다는 단점도 있으니, 실패 이유를 남겨야 하는 흐름이라면 std::expected(패턴 3)의 같은 연산을 쓰는 것이 낫습니다.


설정 검증, 플러그인 매개변수, DB NULL 처리

설정 검증 (variant + visit)

// 설정값 검증: 타입별로 범위 체크
#include <variant>
#include <string>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

using ConfigValue = std::variant<int, double, std::string>;

bool validate(const ConfigValue& v) {
    return std::visit(overloaded{
         [](int i) { return i >= 0 && i <= 65535; },
         [](double d) { return d >= 0.0 && d <= 1.0; },
         [](const std::string& s) { return !s.empty() && s.size() <= 256; }
    }, v);
}

플러그인 매개변수 (any 대신 variant)

#include <variant>
#include <string>
#include <vector>

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

// 플러그인: variant로 타입 제한해 any보다 안전하게
using PluginParam = std::variant<int, double, std::string, std::vector<int>>;

void invokePlugin(const std::string& name, const PluginParam& param) {
    std::visit(overloaded{
        [&](int v) { /* ... */ },
        [&](double v) { /* ... */ },
        [&](const std::string& v) { /* ... */ },
        [&](const std::vector<int>& v) { /* ... */ }
    }, param);
}

데이터베이스 NULL 처리

#include <optional>
#include <string>

// DB 컬럼: NULL 가능
struct UserRow {
    int id;
    std::optional<std::string> name;   // NULL 허용
    std::optional<int> age;            // NULL 허용
};

반대로 “성공 데이터”와 “에러 메시지”를 std::optional 두 개로 나란히 두는 구조는 둘 다 있거나 둘 다 없는 잘못된 상태를 허용합니다. 둘 중 정확히 하나만 있어야 한다면 variant 기반 Result나 std::expected가 그 규칙을 타입으로 강제합니다. 그리고 optional이나 Result를 반환하는 함수에 [[nodiscard]]를 붙여 두면 호출부가 결과를 버릴 때 경고가 납니다.

참고 자료

같이 보면 좋은 글