C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴

C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴

이 글의 핵심

널 포인터 대신 뭘 쓰죠, 값이 없을 수도 있는데 어떻게 표현하죠 같은 문제 해결. std::optional 기초부터 C++23 모나딕 연산(and_then, or_else, transform), 성능 고려사항, 실전 에러 핸들링 패턴까지.

들어가며: “값이 없을 수도 있는데 어떻게 표현하죠?”

실무에서 겪는 문제들

C++ 개발 중 이런 상황을 자주 겪습니다:

  • 검색 실패 — 사용자 ID로 검색했는데 없음. nullptr 반환? 예외? 특수값 -1?
  • 설정 값 누락 — 설정 파일에 특정 키가 없음. 기본값을 어떻게 처리?
  • 파싱 실패 — 문자열을 숫자로 변환 실패. 예외는 과하고 에러 코드는 불편함
  • 캐시 미스 — 캐시에 데이터가 없음. 매번 nullptr 체크는 번거로움
  • 부분 초기화 — 객체의 일부 필드가 선택적. 포인터는 메모리 관리 부담

기존 방법의 문제점:

방법문제점
nullptr메모리 관리 부담, 역참조 시 크래시 위험
특수값 (-1, INT_MIN)유효한 값과 구분 어려움, 타입 안전하지 않음
예외예상된 실패에는 과함, 성능 오버헤드
std::pair<bool, T>장황함, 실수하기 쉬움

std::optional로 해결:

// ❌ 기존 방법
int* findUser(int id) {
    // ...
    return nullptr;  // 메모리 관리 필요
}

// ✅ optional 사용
std::optional<int> findUser(int id) {
    // ...
    return std::nullopt;  // 안전하고 명확
}

목표:

  • std::optional 기초 (생성, 접근, 체크)
  • C++23 모나딕 연산 (and_then, or_else, transform)
  • 실전 패턴 (에러 핸들링, API 설계, 체이닝)
  • 성능 고려사항 (언제 사용하지 말아야 하는가)
  • 다른 타입과 비교 (variant, expected, 예외)
  • 자주 하는 실수와 해결법
  • 프로덕션 패턴

요구 환경: C++17 이상 (C++23 기능은 별도 표시)


목차

  1. 문제 시나리오: 값이 없는 상황 처리
  2. std::optional 기초
  3. C++23 모나딕 연산
  4. 실전 에러 핸들링 패턴
  5. 성능 고려사항
  6. 다른 타입과 비교
  7. 완전한 예제 모음
  8. 자주 발생하는 실수와 해결법
  9. 모범 사례·베스트 프랙티스
  10. 프로덕션 패턴
  11. 정리 및 체크리스트

1. 문제 시나리오: 값이 없는 상황 처리

시나리오 1: 데이터베이스 조회 실패

// ❌ 포인터 사용 (메모리 관리 부담)
User* findUserById(int id) {
    // DB 조회
    if (/* 찾음 */) {
        return new User{id, "Alice"};  // 💥 누가 delete?
    }
    return nullptr;
}

// 사용
User* user = findUserById(123);
if (user != nullptr) {
    std::cout << user->name << std::endl;
    delete user;  // 💥 깜빡하면 메모리 누수
}

주의사항: DB 조회 결과를 new로 넘기는 패턴은 예외·조기 반환 시 누수가 나기 쉽습니다. 소유권이 필요하면 unique_ptr, “없을 수 있음”이면 optional을 우선 검토하세요.

// ✅ optional 사용 (안전하고 명확)
std::optional<User> findUserById(int id) {
    // DB 조회 (실제로는 DB 쿼리 실행)
    if (/* 사용자를 찾았다면 */) {
        // User 객체를 optional로 감싸서 반환
        return User{id, "Alice"};
    }
    // 찾지 못했으면 nullopt 반환 (값이 없음을 명시)
    return std::nullopt;
}

// 사용 예시
if (auto user = findUserById(123)) {
    // user가 값을 가지고 있으면 (찾았으면) 이 블록 실행
    std::cout << user->name << std::endl;  // ✅ 자동 정리, 메모리 관리 불필요
}
// if 블록을 벗어나면 user는 자동으로 소멸됨

시나리오 2: 설정 파일 파싱

// ❌ 특수값 사용 (유효한 값과 구분 어려움)
int getTimeout(const Config& config) {
    if (config.has("timeout")) {
        return config.getInt("timeout");
    }
    return -1;  // 💥 -1이 유효한 값일 수도 있음
}

// 사용
int timeout = getTimeout(config);
if (timeout == -1) {  // 💥 -1이 실제 값인지 에러인지 모호
    timeout = 30;  // 기본값
}
// ✅ optional 사용 (명확한 의미)
std::optional<int> getTimeout(const Config& config) {
    if (config.has("timeout")) {
        return config.getInt("timeout");
    }
    return std::nullopt;
}

// 사용
int timeout = getTimeout(config).value_or(30);  // ✅ 간결하고 명확

시나리오 3: 문자열 파싱

// ❌ 예외 사용 (예상된 실패에는 과함)
int parseInt(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        throw std::runtime_error("Parse failed");  // 💥 예외는 비용이 큼
    }
}
// ✅ optional 사용 (예상된 실패)
std::optional<int> parseInt(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        return std::nullopt;  // ✅ 예상된 실패는 optional로
    }
}

// 사용
if (auto num = parseInt("123")) {
    std::cout << "Parsed: " << *num << std::endl;
} else {
    std::cout << "Parse failed" << std::endl;
}

주의사항: 빈 문자열·오버플로 등은 여전히 stoi의 예외 정책에 따릅니다. 초고속 경로에서는 from_chars 등으로 바꾸는 것도 검토하세요.

시나리오 4: 캐시 조회

// ❌ pair<bool, T> 사용 (장황함)
std::pair<bool, std::string> getCached(const std::string& key) {
    if (cache.contains(key)) {
        return {true, cache[key]};
    }
    return {false, ""};  // 💥 빈 문자열이 유효한 값일 수도
}

// 사용
auto [found, value] = getCached("user:123");
if (found) {  // 💥 실수하기 쉬움
    std::cout << value << std::endl;
}
// ✅ optional 사용 (간결하고 안전)
std::optional<std::string> getCached(const std::string& key) {
    if (cache.contains(key)) {
        return cache[key];
    }
    return std::nullopt;
}

// 사용
if (auto value = getCached("user:123")) {
    std::cout << *value << std::endl;  // ✅ 명확
}
flowchart TB
    subgraph Problems["값이 없는 상황"]
        P1[검색 실패]
        P2[설정 누락]
        P3[파싱 실패]
        P4[캐시 미스]
    end
    subgraph OldSolutions["기존 해결법 (문제 있음)"]
        O1[nullptr - 메모리 관리]
        O2[특수값 - 모호함]
        O3[예외 - 과함]
        O4[pair - 장황함]
    end
    subgraph NewSolution["std optional"]
        N1[안전]
        N2[명확]
        N3[간결]
    end
    P1 --> O1
    P2 --> O2
    P3 --> O3
    P4 --> O4
    O1 --> N1
    O2 --> N2
    O3 --> N3
    O4 --> N1

2. std::optional 기초

생성과 초기화

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

// 1. 기본 생성 (값 없음)
std::optional<int> opt1;
std::optional<int> opt2 = std::nullopt;

// 2. 값으로 초기화
std::optional<int> opt3 = 42;
std::optional<int> opt4{42};

// 3. make_optional
auto opt5 = std::make_optional<int>(42);
auto opt6 = std::make_optional<std::string>("Hello");

// 4. in_place 생성 (복잡한 타입)
struct Point {
    int x, y;
    Point(int x, int y) : x(x), y(y) {}
};

std::optional<Point> opt7{std::in_place, 10, 20};  // Point(10, 20) 직접 생성

// 5. emplace (나중에 값 할당)
std::optional<Point> opt8;
opt8.emplace(30, 40);  // Point(30, 40) 생성

값 확인과 접근

flowchart TD
    Start[optional 값 확인] --> HasValue{has_value?}
    HasValue -->|true| Access[값 접근]
    HasValue -->|false| Handle[nullopt 처리]
    
    Access --> Method1[value - 예외 가능]
    Access --> Method2[operator* - UB 가능]
    Access --> Method3[value_or - 안전]
    
    Method3 --> Safe[기본값 반환]
    
    style Method3 fill:#90EE90
    style Safe fill:#90EE90
#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt = 42;
    
    // 1. has_value() - 명시적 체크
    if (opt.has_value()) {
        std::cout << "값 있음: " << opt.value() << std::endl;
    }
    
    // 2. operator bool - 암시적 변환
    if (opt) {
        std::cout << "값 있음: " << *opt << std::endl;
    }
    
    // 3. value() - 예외 발생 가능
    try {
        std::optional<int> empty;
        int x = empty.value();  // 💥 std::bad_optional_access 예외
    } catch (const std::bad_optional_access& e) {
        std::cerr << "에러: " << e.what() << std::endl;
    }
    
    // 4. operator* - 역참조 (값이 없으면 UB)
    std::optional<int> opt2 = 100;
    std::cout << *opt2 << std::endl;  // 100
    
    // 5. operator-> - 멤버 접근
    std::optional<std::string> opt3 = "Hello";
    std::cout << opt3->length() << std::endl;  // 5
    
    // 6. value_or() - 기본값 제공 (가장 안전)
    std::optional<int> empty;
    int x = empty.value_or(0);  // 0 (기본값)
    std::cout << x << std::endl;
    
    return 0;
}

주의사항: 값이 없을 때 operator*는 미정의 동작이고 value()bad_optional_access를 던집니다. 방어 코드에서는 value_or나 선행 if를 기본으로 두세요.

값 수정과 제거

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt = 42;
    
    // 1. 대입으로 값 변경
    opt = 100;
    std::cout << *opt << std::endl;  // 100
    
    // 2. reset() - 값 제거
    opt.reset();
    std::cout << opt.has_value() << std::endl;  // false
    
    // 3. nullopt 대입
    opt = 42;
    opt = std::nullopt;
    std::cout << opt.has_value() << std::endl;  // false
    
    // 4. emplace() - 새 값 생성
    opt.emplace(200);
    std::cout << *opt << std::endl;  // 200
    
    // 5. swap()
    std::optional<int> opt1 = 10;
    std::optional<int> opt2 = 20;
    opt1.swap(opt2);
    std::cout << *opt1 << " " << *opt2 << std::endl;  // 20 10
    
    return 0;
}

비교 연산

#include <optional>
#include <iostream>

int main() {
    std::optional<int> a = 10;
    std::optional<int> b = 20;
    std::optional<int> empty;
    
    // optional끼리 비교
    std::cout << (a == b) << std::endl;     // false
    std::cout << (a < b) << std::endl;      // true
    std::cout << (a == empty) << std::endl; // false
    
    // 값과 직접 비교
    std::cout << (a == 10) << std::endl;    // true
    std::cout << (a != 20) << std::endl;    // true
    
    // nullopt와 비교
    std::cout << (empty == std::nullopt) << std::endl;  // true
    std::cout << (a != std::nullopt) << std::endl;      // true
    
    // 비교 규칙
    // - nullopt < 모든 값
    // - 값이 있으면 값끼리 비교
    std::optional<int> opt1 = 5;
    std::optional<int> opt2;
    std::cout << (opt2 < opt1) << std::endl;  // true (nullopt < 5)
    
    return 0;
}

3. C++23 모나딕 연산

and_then: 체이닝 (flatMap)

C++23부터 사용 가능

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

// 사용자 조회
std::optional<int> findUserId(const std::string& username) {
    if (username == "alice") return 1;
    if (username == "bob") return 2;
    return std::nullopt;
}

// 사용자 이메일 조회
std::optional<std::string> findEmail(int userId) {
    if (userId == 1) return "[email protected]";
    if (userId == 2) return "[email protected]";
    return std::nullopt;
}

int main() {
    // ❌ C++17 방식 (중첩된 if)
    auto userId = findUserId("alice");
    if (userId) {
        auto email = findEmail(*userId);
        if (email) {
            std::cout << "Email: " << *email << std::endl;
        }
    }
    
    // ✅ C++23 and_then (체이닝)
    auto email = findUserId("alice")
        .and_then(findEmail);
    
    if (email) {
        std::cout << "Email: " << *email << std::endl;
    }
    
    // 실패 케이스도 자연스럽게 처리
    auto noEmail = findUserId("charlie")  // nullopt 반환
        .and_then(findEmail);  // 실행되지 않음
    
    std::cout << noEmail.has_value() << std::endl;  // false
    
    return 0;
}

transform: 값 변환 (map)

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

int main() {
    std::optional<int> opt = 42;
    
    // ❌ C++17 방식
    std::optional<std::string> str1;
    if (opt) {
        str1 = std::to_string(*opt);
    }
    
    // ✅ C++23 transform
    auto str2 = opt.transform( {
        return std::to_string(x);
    });
    
    std::cout << *str2 << std::endl;  // "42"
    
    // 체이닝
    auto result = std::optional<int>{10}
        .transform( { return x * 2; })      // 20
        .transform( { return x + 5; })      // 25
        .transform( { return std::to_string(x); });  // "25"
    
    std::cout << *result << std::endl;  // "25"
    
    // 빈 optional은 변환 안 됨
    std::optional<int> empty;
    auto result2 = empty.transform( {
        std::cout << "실행 안 됨" << std::endl;
        return x * 2;
    });
    std::cout << result2.has_value() << std::endl;  // false
    
    return 0;
}

or_else: 대체값 제공

#include <optional>
#include <iostream>

std::optional<int> getFromCache(const std::string& key) {
    // 캐시 조회
    return std::nullopt;  // 캐시 미스
}

std::optional<int> getFromDatabase(const std::string& key) {
    // DB 조회
    return 42;
}

int main() {
    // ❌ C++17 방식
    auto value1 = getFromCache("user:123");
    if (!value1) {
        value1 = getFromDatabase("user:123");
    }
    
    // ✅ C++23 or_else
    auto value2 = getFromCache("user:123")
        .or_else( { return getFromDatabase("user:123"); });
    
    std::cout << *value2 << std::endl;  // 42
    
    // 여러 대체 소스 체이닝
    auto value3 = getFromCache("user:123")
        .or_else( { return getFromDatabase("user:123"); })
        .or_else( { return std::optional<int>{0}; });  // 최종 기본값
    
    return 0;
}

실전 조합: 복잡한 파이프라인

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

struct User {
    int id;
    std::string name;
    std::optional<std::string> email;
};

std::map<int, User> users = {
    {1, {1, "Alice", "[email protected]"}},
    {2, {2, "Bob", std::nullopt}},
};

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

int main() {
    // 사용자 조회 → 이메일 추출 → 도메인 추출
    auto domain = findUser(1)
        .and_then( { return u.email; })
        .transform( {
            size_t pos = email.find('@');
            return email.substr(pos + 1);
        });
    
    if (domain) {
        std::cout << "Domain: " << *domain << std::endl;  // "example.com"
    }
    
    // 이메일 없는 사용자
    auto noDomain = findUser(2)
        .and_then( { return u.email; })  // nullopt 반환
        .transform( {
            // 실행 안 됨
            return email.substr(email.find('@') + 1);
        });
    
    std::cout << noDomain.has_value() << std::endl;  // false
    
    return 0;
}

4. 실전 에러 핸들링 패턴

패턴 1: 설정 파일 파싱

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

class Config {
private:
    std::map<std::string, std::string> data_;
    
public:
    void set(const std::string& key, const std::string& value) {
        data_[key] = value;
    }
    
    std::optional<std::string> getString(const std::string& key) const {
        auto it = data_.find(key);
        if (it != data_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
    
    std::optional<int> getInt(const std::string& key) const {
        return getString(key).and_then( -> std::optional<int> {
            try {
                return std::stoi(s);
            } catch (...) {
                return std::nullopt;
            }
        });
    }
    
    std::optional<bool> getBool(const std::string& key) const {
        return getString(key).transform( {
            return s == "true" || s == "1";
        });
    }
};

int main() {
    Config config;
    config.set("port", "8080");
    config.set("host", "localhost");
    config.set("debug", "true");
    config.set("invalid", "abc");
    
    // 값 있으면 사용, 없으면 기본값
    int port = config.getInt("port").value_or(3000);
    std::string host = config.getString("host").value_or("0.0.0.0");
    bool debug = config.getBool("debug").value_or(false);
    
    std::cout << "Port: " << port << std::endl;
    std::cout << "Host: " << host << std::endl;
    std::cout << "Debug: " << debug << std::endl;
    
    // 잘못된 값 처리
    auto invalid = config.getInt("invalid");
    if (!invalid) {
        std::cerr << "Invalid integer value" << std::endl;
    }
    
    return 0;
}

패턴 2: 캐시 시스템

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

template<typename K, typename V>
class TimedCache {
private:
    struct Entry {
        V value;
        std::chrono::steady_clock::time_point expires_at;
    };
    
    std::map<K, Entry> data_;
    std::chrono::seconds default_ttl_;
    
public:
    explicit TimedCache(std::chrono::seconds ttl = std::chrono::seconds{60})
        : default_ttl_(ttl) {}
    
    void put(const K& key, const V& value) {
        auto expires = std::chrono::steady_clock::now() + default_ttl_;
        data_[key] = {value, expires};
    }
    
    std::optional<V> get(const K& key) {
        auto it = data_.find(key);
        if (it == data_.end()) {
            return std::nullopt;  // 캐시 미스
        }
        
        // 만료 확인
        if (std::chrono::steady_clock::now() > it->second.expires_at) {
            data_.erase(it);
            return std::nullopt;  // 만료됨
        }
        
        return it->second.value;
    }
    
    // C++23 or_else 활용
    template<typename F>
    V getOrCompute(const K& key, F&& compute) {
        return get(key).or_else([&]() -> std::optional<V> {
            V value = compute();
            put(key, value);
            return value;
        }).value();
    }
};

int main() {
    TimedCache<std::string, int> cache{std::chrono::seconds{5}};
    
    // 캐시 저장
    cache.put("user:123", 42);
    
    // 캐시 조회
    if (auto value = cache.get("user:123")) {
        std::cout << "Cached: " << *value << std::endl;
    }
    
    // 없으면 계산 후 저장
    int score = cache.getOrCompute("user:456",  {
        std::cout << "Computing..." << std::endl;
        return 100;  // DB에서 조회했다고 가정
    });
    std::cout << "Score: " << score << std::endl;
    
    return 0;
}

패턴 3: API 응답 처리

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

struct ApiResponse {
    int status_code;
    std::optional<std::string> body;
    std::optional<std::string> error;
};

ApiResponse fetchData(const std::string& url) {
    // HTTP 요청 시뮬레이션
    if (url == "https://api.example.com/users") {
        return {200, R"({"users": []})", std::nullopt};
    } else {
        return {404, std::nullopt, "Not Found"};
    }
}

int main() {
    auto response = fetchData("https://api.example.com/users");
    
    if (response.status_code == 200 && response.body) {
        std::cout << "Success: " << *response.body << std::endl;
    } else if (response.error) {
        std::cerr << "Error: " << *response.error << std::endl;
    }
    
    // C++23 transform 활용
    auto bodyLength = response.body.transform( {
        return s.length();
    });
    
    std::cout << "Body length: " << bodyLength.value_or(0) << std::endl;
    
    return 0;
}

5. 성능 고려사항

메모리 오버헤드

#include <optional>
#include <iostream>

struct SmallType {
    int x;
};

struct LargeType {
    int data[1000];
};

int main() {
    std::cout << "int: " << sizeof(int) << " bytes" << std::endl;
    std::cout << "optional<int>: " << sizeof(std::optional<int>) << " bytes" << std::endl;
    // 출력: int: 4 bytes, optional<int>: 8 bytes (bool 플래그 + 패딩)
    
    std::cout << "SmallType: " << sizeof(SmallType) << " bytes" << std::endl;
    std::cout << "optional<SmallType>: " << sizeof(std::optional<SmallType>) << " bytes" << std::endl;
    
    std::cout << "LargeType: " << sizeof(LargeType) << " bytes" << std::endl;
    std::cout << "optional<LargeType>: " << sizeof(std::optional<LargeType>) << " bytes" << std::endl;
    // 큰 타입도 bool 플래그만 추가됨 (약 1바이트 + 패딩)
    
    return 0;
}

결론: optional의 오버헤드는 대부분 1바이트 + 정렬 패딩입니다.

언제 optional을 피해야 하는가

// ❌ 나쁜 사용: 항상 값이 있는 경우
std::optional<int> getUserAge(int userId) {
    // 항상 나이를 반환한다면 optional 불필요
    return 25;
}

// ✅ 좋은 사용: 값이 없을 수 있는 경우
std::optional<int> getUserAge(int userId) {
    if (userId < 0) {
        return std::nullopt;  // 유효하지 않은 사용자
    }
    return 25;
}

// ❌ 나쁜 사용: 성능 크리티컬한 루프
for (int i = 0; i < 1000000; ++i) {
    std::optional<int> result = compute(i);  // 매번 optional 생성
    if (result) {
        process(*result);
    }
}

// ✅ 좋은 사용: 특수값으로 처리
for (int i = 0; i < 1000000; ++i) {
    int result = compute(i);  // -1 = 실패
    if (result != -1) {
        process(result);
    }
}

// ❌ 나쁜 사용: 참조를 optional로
// std::optional<T&>는 불가능
// std::optional<std::reference_wrapper<T>>는 복잡함

// ✅ 좋은 사용: 포인터 사용
T* ptr = findObject();  // nullptr 가능

성능 벤치마크

#include <optional>
#include <chrono>
#include <iostream>

// 1. optional vs 포인터
void benchmarkOptional() {
    auto start = std::chrono::high_resolution_clock::now();
    
    for (int i = 0; i < 10000000; ++i) {
        std::optional<int> opt = (i % 2 == 0) ? std::optional<int>{i} : std::nullopt;
        if (opt) {
            volatile int x = *opt;  // 최적화 방지
        }
    }
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    std::cout << "optional: " << duration.count() << "ms" << std::endl;
}

void benchmarkPointer() {
    auto start = std::chrono::high_resolution_clock::now();
    
    for (int i = 0; i < 10000000; ++i) {
        int* ptr = (i % 2 == 0) ? new int(i) : nullptr;
        if (ptr) {
            volatile int x = *ptr;
            delete ptr;
        }
    }
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
    std::cout << "pointer: " << duration.count() << "ms" << std::endl;
}

int main() {
    benchmarkOptional();  // 약 50ms
    benchmarkPointer();   // 약 500ms (new/delete 오버헤드)
    
    // optional이 훨씬 빠름!
    return 0;
}

6. 다른 타입과 비교

optional vs variant

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

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

// variant: 여러 타입 중 하나
std::variant<int, std::string> parseValue(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (...) {
        return "Parse error: " + str;
    }
}

int main() {
    // optional 사용
    if (auto result = divide(10, 2)) {
        std::cout << "Result: " << *result << std::endl;
    }
    
    // variant 사용
    auto value = parseValue("123");
    if (std::holds_alternative<int>(value)) {
        std::cout << "Integer: " << std::get<int>(value) << std::endl;
    } else {
        std::cout << "Error: " << std::get<std::string>(value) << std::endl;
    }
    
    return 0;
}
특징optionalvariant<T, E>
용도값이 있거나 없음여러 타입 중 하나
에러 정보없음 (nullopt만)에러 타입 저장 가능
크기sizeof(T) + 1max(sizeof(T), sizeof(E)) + 태그
사용간단한 실패상세한 에러 정보 필요

optional vs 예외

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

// 예외 사용
int parseIntException(const std::string& str) {
    try {
        return std::stoi(str);
    } catch (const std::exception& e) {
        throw std::runtime_error("Parse failed: " + str);
    }
}

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

int main() {
    // 예외: 예외적인 상황
    try {
        int x = parseIntException("abc");
    } catch (const std::exception& e) {
        std::cerr << "Exception: " << e.what() << std::endl;
    }
    
    // optional: 예상된 실패
    if (auto x = parseIntOptional("abc")) {
        std::cout << "Parsed: " << *x << std::endl;
    } else {
        std::cout << "Parse failed (expected)" << std::endl;
    }
    
    return 0;
}
특징optional예외
사용 시기예상된 실패예외적 상황
성능빠름 (분기)느림 (스택 언와인딩)
에러 정보없음상세한 메시지
강제 처리아니오예 (catch 필요)
코드 흐름명시적암시적

optional vs expected (C++23 제안)

// expected<T, E>: optional + 에러 정보
// C++23에서 표준화 예정

template<typename T, typename E>
class expected {
    // T 또는 E를 저장
    // optional<T>와 유사하지만 에러 정보 포함
};

// 사용 예시
expected<int, std::string> divide(int a, int b) {
    if (b == 0) {
        return unexpected{"Division by zero"};
    }
    return a / b;
}

// optional과 비교
std::optional<int> divideOptional(int a, int b) {
    if (b == 0) return std::nullopt;  // 에러 정보 없음
    return a / b;
}

관련 글: Optional과 Variant 활용에서 두 타입을 함께 사용하는 실전 패턴을 학습하세요.


7. 완전한 예제 모음

예제 1: JSON 파서

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

class JsonValue {
public:
    std::map<std::string, std::string> data;
    
    std::optional<std::string> getString(const std::string& key) const {
        auto it = data.find(key);
        return (it != data.end()) ? std::optional{it->second} : std::nullopt;
    }
    
    std::optional<int> getInt(const std::string& key) const {
        return getString(key).and_then( -> std::optional<int> {
            try {
                return std::stoi(s);
            } catch (...) {
                return std::nullopt;
            }
        });
    }
    
    std::optional<bool> getBool(const std::string& key) const {
        return getString(key).transform( {
            return s == "true";
        });
    }
};

int main() {
    JsonValue json;
    json.data["name"] = "Alice";
    json.data["age"] = "30";
    json.data["active"] = "true";
    
    // 안전한 접근
    auto name = json.getString("name").value_or("Unknown");
    auto age = json.getInt("age").value_or(0);
    auto active = json.getBool("active").value_or(false);
    
    std::cout << "Name: " << name << std::endl;
    std::cout << "Age: " << age << std::endl;
    std::cout << "Active: " << active << std::endl;
    
    return 0;
}

8. 자주 발생하는 실수와 해결법

실수 1: value() 호출 시 예외

// ❌ 잘못된 방법
std::optional<int> empty;
int x = empty.value();  // 💥 std::bad_optional_access 예외

// ✅ 올바른 방법 1: has_value() 체크
if (empty.has_value()) {
    int x = empty.value();
}

// ✅ 올바른 방법 2: operator bool
if (empty) {
    int x = *empty;
}

// ✅ 올바른 방법 3: value_or() (가장 안전)
int x = empty.value_or(0);

실수 2: 포인터처럼 사용

// ❌ 잘못된 방법
std::optional<int> opt = 42;
if (opt != nullptr) {  // 💥 컴파일 에러
}

// ✅ 올바른 방법
if (opt.has_value()) {
    // ...
}

// 또는
if (opt) {
    // ...
}

// nullopt와 비교
if (opt != std::nullopt) {
    // ...
}

실수 3: optional<T&> 시도

// ❌ 잘못된 방법 (컴파일 에러)
int x = 10;
// std::optional<int&> opt = x;  // 💥 불가능

// ✅ 올바른 방법 1: reference_wrapper
std::optional<std::reference_wrapper<int>> opt = std::ref(x);
if (opt) {
    opt->get() = 20;  // x가 20으로 변경됨
}

// ✅ 올바른 방법 2: 포인터 사용 (더 간단)
std::optional<int*> opt2 = &x;
if (opt2) {
    **opt2 = 30;
}

실수 4: 불필요한 복사

// ❌ 비효율적
std::optional<std::string> getLargeString() {
    std::string large(10000, 'x');
    return large;  // 복사 발생 (RVO가 작동하지 않을 수 있음)
}

// ✅ 효율적 (RVO 활용)
std::optional<std::string> getLargeString() {
    if (/* 조건 */) {
        return std::string(10000, 'x');  // RVO
    }
    return std::nullopt;
}

// ✅ 이동 명시
std::optional<std::string> getLargeString() {
    std::string large(10000, 'x');
    return std::move(large);  // 이동
}

실수 5: optional<optional> 중첩

// ❌ 복잡하고 혼란스러움
std::optional<std::optional<int>> nested() {
    return std::optional<int>{42};  // 💥 중첩된 optional
}

// ✅ 단일 optional 사용
std::optional<int> simple() {
    return 42;
}

// 정말 중첩이 필요하면 variant 고려
std::variant<int, std::string, std::monostate> alternative() {
    return 42;
}

실수 6: 성능 크리티컬한 코드에서 남용

// ❌ 핫 루프에서 optional 생성
for (int i = 0; i < 1000000; ++i) {
    std::optional<int> result = compute(i);
    if (result) {
        process(*result);
    }
}

// ✅ 특수값 사용 (더 빠름)
for (int i = 0; i < 1000000; ++i) {
    int result = compute(i);  // -1 = 실패
    if (result != -1) {
        process(result);
    }
}

9. 모범 사례·베스트 프랙티스

1. value_or()를 기본으로 사용

// ✅ 간결하고 안전
int port = config.getInt("port").value_or(8080);

// ❌ 장황함
int port;
if (auto p = config.getInt("port")) {
    port = *p;
} else {
    port = 8080;
}

2. 함수 반환 타입으로 사용

// ✅ 실패 가능성을 타입으로 표현
std::optional<User> findUser(int id);

// ❌ 포인터 (메모리 관리 부담)
User* findUser(int id);

// ❌ 예외 (예상된 실패에는 과함)
User findUser(int id);  // 없으면 예외

3. C++23 모나딕 연산 활용

// ✅ 체이닝으로 간결하게
auto email = findUser(123)
    .and_then( { return u.getEmail(); })
    .value_or("[email protected]");

// ❌ 중첩된 if
std::string email = "[email protected]";
if (auto user = findUser(123)) {
    if (auto e = user->getEmail()) {
        email = *e;
    }
}

4. 구조화된 바인딩 활용 (C++17)

struct Result {
    std::optional<int> value;
    std::optional<std::string> error;
};

Result compute() {
    // ...
    return {42, std::nullopt};
}

// ✅ 구조화된 바인딩
auto [value, error] = compute();
if (value) {
    std::cout << "Success: " << *value << std::endl;
} else if (error) {
    std::cerr << "Error: " << *error << std::endl;
}

5. 명확한 네이밍

// ✅ 명확한 함수 이름
std::optional<User> tryFindUser(int id);
std::optional<int> maybeParseInt(const std::string& str);

// ❌ 모호한 이름
User getUser(int id);  // 없으면 어떻게 되나?
int parseInt(const std::string& str);  // 실패하면?

10. 프로덕션 패턴

패턴 1: 옵셔널 체이닝 헬퍼

template<typename T, typename F>
auto map_optional(const std::optional<T>& opt, F&& func) 
    -> std::optional<std::invoke_result_t<F, T>> {
    if (opt) {
        return func(*opt);
    }
    return std::nullopt;
}

// 사용
std::optional<int> opt = 42;
auto result = map_optional(opt,  { return x * 2; });

패턴 2: 여러 optional 결합

template<typename T>
std::optional<std::vector<T>> collect_optionals(
    const std::vector<std::optional<T>>& opts) {
    std::vector<T> result;
    for (const auto& opt : opts) {
        if (!opt) {
            return std::nullopt;  // 하나라도 없으면 실패
        }
        result.push_back(*opt);
    }
    return result;
}

// 사용
std::vector<std::optional<int>> opts = {1, 2, 3};
if (auto values = collect_optionals(opts)) {
    // 모든 값이 있음
}

패턴 3: 지연 평가

template<typename F>
class LazyOptional {
private:
    F compute_;
    mutable std::optional<std::invoke_result_t<F>> cache_;
    
public:
    explicit LazyOptional(F func) : compute_(std::move(func)) {}
    
    auto get() const -> std::optional<std::invoke_result_t<F>> {
        if (!cache_) {
            cache_ = compute_();
        }
        return cache_;
    }
};

// 사용
LazyOptional expensive{ -> std::optional<int> {
    // 비싼 계산
    return 42;
}};

// 필요할 때만 계산
if (auto value = expensive.get()) {
    std::cout << *value << std::endl;
}

11. 정리 및 체크리스트

optional 사용 가이드

상황사용 여부대안
검색 실패 가능✅ 사용-
설정 값 누락✅ 사용-
파싱 실패✅ 사용-
항상 값 있음❌ 불필요일반 타입
상세한 에러 정보 필요❌ 부적합variant, expected
성능 크리티컬❌ 신중히특수값, 포인터
참조 저장❌ 불가능포인터, reference_wrapper

체크리스트

# ✅ optional 사용 시
- [ ] 값이 없을 있는 상황인가?
- [ ] value_or() 기본값 제공했는가?
- [ ] value() 호출 has_value() 체크했는가?
- [ ] 불필요한 복사를 피했는가?
- [ ] 성능 크리티컬한 코드가 아닌가?
- [ ] 타입은 const 참조로 전달했는가?

# ✅ API 설계 시
- [ ] 함수 이름이 optional 반환을 암시하는가? (try~, maybe~, find~)
- [ ] 문서에 nullopt 반환 조건을 명시했는가?
- [ ] 대안 (예외, variant, expected)을 고려했는가?
- [ ] 체이닝이 너무 깊지 않은가? (3단계 이하 권장)

# ✅ 코드 리뷰 시
- [ ] optional<bool> 사용이 적절한가? (3-state 필요한가?)
- [ ] optional<optional<T>> 중첩은 없는가?
- [ ] 에러 정보가 필요하면 variant 고려했는가?

실전 팁: optional 효율적으로 사용하기

  1. 함수 이름으로 의도 표현

    // ✅ 좋은 이름 (optional 반환 암시)
    std::optional<User> tryFindUser(int id);
    std::optional<int> maybeParseInt(const std::string& s);
    std::optional<Config> loadConfig(const std::string& path);
    
    // ❌ 모호한 이름
    User getUser(int id);  // 없으면 어떻게?
    int parseInt(const std::string& s);  // 실패하면?
  2. value_or()를 기본으로

    // ✅ 간결하고 안전
    int port = config.getInt("port").value_or(8080);
    
    // ❌ 장황함
    int port;
    auto opt = config.getInt("port");
    if (opt.has_value()) {
        port = opt.value();
    } else {
        port = 8080;
    }
  3. C++23 모나딕 연산 활용

    // ✅ 체이닝으로 간결하게
    auto result = findUser(id)
        .and_then( { return u.getEmail(); })
        .transform( { return e.toLowerCase(); })
        .value_or("[email protected]");
  4. 에러 로깅과 함께 사용

    auto user = findUser(id);
    if (!user) {
        LOG_WARNING("User not found: " + std::to_string(id));
        return std::nullopt;
    }
    return user->process();

빠른 참조: optional 사용 결정 트리

값이 없을 수 있는가?
├─ Yes → optional 사용 고려
│   ├─ 에러 정보 필요? → variant<T, Error> 또는 expected<T, E>
│   ├─ 성능 크리티컬? → 특수값 또는 포인터 고려
│   └─ 일반적인 경우 → std::optional ✅
└─ No → 일반 타입 사용

트러블슈팅: 빠른 문제 해결

증상원인해결법
bad_optional_accessvalue() 호출 시 값 없음value_or() 사용 또는 has_value() 체크
컴파일 에러: optional<T&>참조 타입 불가optional<reference_wrapper> 또는 포인터 사용
성능 저하큰 타입을 값으로 복사const 참조로 전달
중첩 optionaloptional<optional>설계 재검토, variant 고려
체이닝 복잡너무 깊은 and_then중간 변수로 분리

optional vs 다른 방법 성능 비교

방법메모리 오버헤드실행 속도안전성사용 난이도
optionalsizeof(T) + 1~8바이트빠름높음쉬움
T* (포인터)8바이트매우 빠름낮음중간
unique_ptr8바이트 + 힙 할당느림높음중간
예외0 (실패 시만)매우 느림높음어려움
특수값0매우 빠름낮음쉬움

다음 단계

  • std::variant 가이드에서 여러 타입 중 하나를 표현하는 방법 학습
  • Optional과 Variant 활용에서 두 타입의 실전 활용법 학습
  • 예외 처리 기초에서 예외 처리 기본 개념 학습
  • 예외 처리 가이드에서 예외와 optional의 선택 기준 학습
  • 다형성과 Variant에서 상속 대신 variant 사용법 학습

FAQ

Q1: optional은 언제 사용하나요?

A:

  • 값이 없을 수 있는 경우
  • 에러 표시 (간단한 경우)
  • 널 포인터 대체

Q2: optional vs 포인터?

A:

  • optional: 값 의미론, 안전
  • 포인터: 참조 의미론, 위험

Q3: optional vs 예외?

A:

  • optional: 예상된 실패
  • 예외: 예외적 상황

Q4: 성능 오버헤드는?

A: bool 플래그 하나 추가. 거의 무시할 수 있습니다.

Q5: optional<T&>는?

A: 불가능. optional<reference_wrapper> 사용.

Q6: Optional 학습 리소스는?

A:

  • cppreference.com
  • “C++17: The Complete Guide”
  • “Effective Modern C++“

Q7: optional을 함수 인자로 받을 때 const 참조를 써야 하나요?

A: 작은 타입은 값으로, 큰 타입은 const 참조로 전달하세요.

// 작은 타입 (int, double 등): 값으로
void process(std::optional<int> opt) {
    if (opt) { /* ... */ }
}

// 큰 타입 (string, vector 등): const 참조로
void process(const std::optional<std::string>& opt) {
    if (opt) { /* ... */ }
}

Q8: optional을 멤버 변수로 사용할 때 주의사항은?

A: 생성자에서 명시적으로 초기화하고, 안전한 접근 패턴을 사용하세요.

class Config {
private:
    std::optional<std::string> api_key_;
    
public:
    Config() : api_key_(std::nullopt) {}
    
    std::string getApiKey() const {
        return api_key_.value_or("default_key");
    }
};

Q9: optional은 언제 사용하나요?

A: “true/false/모름” 세 가지 상태가 필요할 때 사용합니다.

// 사용자 동의 상태
std::optional<bool> user_consent;  // nullopt = 아직 묻지 않음

if (!user_consent) {
    askForConsent();
} else if (*user_consent) {
    proceed();
} else {
    showError();
}

Q10: optional 체이닝이 너무 깊어지면?

A: 중간 변수로 분리하여 가독성을 높이세요.

// ✅ 중간 변수로 분리
auto user = getUser(id);
if (!user) return std::nullopt;

auto profile = user->getProfile();
if (!profile) return std::nullopt;

return profile->getEmail();

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ variant | “타입 안전 union” 가이드
  • C++ std::optional·std::variant 완벽 가이드 | nullptr 대신 타입 안전하게
  • C++ 예외 처리 | try/catch/throw “완벽 정리” [에러 처리]
  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
  • C++ 현대적 다형성 설계: 상속 대신 합성·variant

관련 글

  • C++ optional |
  • C++ std::optional vs 포인터 |
  • C++ 최신 기능 |
  • C++ any |
  • 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기