C++ optional | "선택적 값" 가이드

C++ optional | "선택적 값" 가이드

이 글의 핵심

std::optional 은 C++17에서 도입된 타입으로, 값이 있거나 없을 수 있는 상태를 표현합니다. null 포인터나 특수 값(-1, -999 등)을 사용하지 않고도 "값 없음"을 타입 안전하게 표현할 수 있습니다.

들어가며

**std::optional**은 C++17에서 도입된 타입으로, 값이 있거나 없을 수 있는 상태를 표현합니다. null 포인터나 특수 값(-1, -999 등)을 사용하지 않고도 “값 없음”을 타입 안전하게 표현할 수 있습니다.

#include <optional>
#include <vector>
#include <algorithm>
#include <iostream>

// std::optional<int>: int 값이 있거나 없을 수 있음을 명시
// 반환 타입으로 "찾지 못할 수 있음"을 타입 시스템에 표현
std::optional<int> findValue(const std::vector<int>& v, int target) {
    // std::find: 벡터에서 target 값 찾기
    auto it = std::find(v.begin(), v.end(), target);
    if (it != v.end()) {
        // 찾았으면 값 반환 (int → optional<int> 자동 변환)
        return *it;
    }
    // std::nullopt: "값 없음"을 표현하는 특수 값
    // -1 같은 특수 값보다 명확하고 타입 안전
    return std::nullopt;  // 값 없음
}

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};
    
    // optional 반환값 받기
    auto result = findValue(v, 3);
    // optional은 bool로 변환 가능: 값이 있으면 true
    if (result) {
        // *result: optional에서 실제 값 추출 (역참조 연산자)
        std::cout << "찾음: " << *result << std::endl;
    }
    
    auto notFound = findValue(v, 10);
    // !notFound: 값이 없으면 true
    if (!notFound) {
        std::cout << "못 찾음" << std::endl;
    }
    
    return 0;
}

왜 필요한가?:

  • 타입 안전: null 포인터나 특수 값 대신 명시적으로 “값 없음” 표현
  • 명확한 의도: 함수가 값을 반환하지 않을 수 있음을 명확히 표시
  • 예외 회피: 값 없음을 예외 대신 반환 값으로 처리
  • 안전성: 값 없음 상태를 컴파일러가 강제로 확인하게 함

1. optional vs 전통적 방식

특수 값 vs optional

#include <vector>
#include <algorithm>
#include <iostream>
#include <optional>

// ❌ 전통적 방식: 특수 값 사용 (모호함)
int findIndexOld(const std::vector<int>& v, int target) {
    auto it = std::find(v.begin(), v.end(), target);
    if (it != v.end()) {
        return std::distance(v.begin(), it);
    }
    return -1;  // -1이 "값 없음"인지 실제 인덱스인지 모호
}

// ✅ optional: 명확함
std::optional<size_t> findIndex(const std::vector<int>& v, int target) {
    auto it = std::find(v.begin(), v.end(), target);
    if (it != v.end()) {
        return std::distance(v.begin(), it);
    }
    return std::nullopt;  // 명확하게 "값 없음"
}

int main() {
    std::vector<int> v = {10, 20, 30, 40, 50};
    
    // 구식
    int idx1 = findIndexOld(v, 30);
    if (idx1 != -1) {
        std::cout << "인덱스: " << idx1 << std::endl;
    }
    
    int idx2 = findIndexOld(v, 99);
    if (idx2 == -1) {
        std::cout << "못 찾음" << std::endl;
    }
    
    // 현대적
    auto idx3 = findIndex(v, 30);
    if (idx3) {
        std::cout << "인덱스: " << *idx3 << std::endl;
    }
    
    auto idx4 = findIndex(v, 99);
    if (!idx4) {
        std::cout << "못 찾음" << std::endl;
    }
    
    return 0;
}

출력:

인덱스: 2
못 찾음
인덱스: 2
못 찾음

optional vs 포인터

특징std::optionalT* (포인터)
메모리스택 (값 포함)힙 (동적 할당 필요)
소유권소유소유하지 않음
null 표현std::nulloptnullptr
타입 안전✅ 강함❌ 약함
복사값 복사포인터 복사
크기sizeof(T) + 1sizeof(void*)
// optional: 값 소유
std::optional<int> opt = 42;
// 스택에 저장, 자동 관리

// 포인터: 메모리 관리 필요
int* ptr = new int(42);
// 힙에 저장, 수동 관리
delete ptr;

2. 값 접근

다양한 접근 방법

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt = 42;
    
    // 방법 1: has_value() + value()
    // has_value(): 값이 있는지 명시적으로 확인
    if (opt.has_value()) {
        // value(): 값 추출 (값 없으면 예외)
        std::cout << "값: " << opt.value() << std::endl;
    }
    
    // 방법 2: bool 변환 + * 연산자 (가장 간결)
    // optional은 bool로 암묵적 변환: 값 있으면 true
    if (opt) {
        // *opt: 역참조로 값 추출 (포인터처럼 사용)
        // 값 없을 때 사용하면 UB (Undefined Behavior)
        std::cout << "값: " << *opt << std::endl;
    }
    
    // 방법 3: value_or() - 기본값 제공
    // 값이 있으면 그 값, 없으면 기본값(0) 반환
    // 예외 없이 안전하게 값 추출
    int val = opt.value_or(0);
    std::cout << "값: " << val << std::endl;
    
    // 방법 4: value() - 예외 처리
    // 값 없으면 std::bad_optional_access 예외 발생
    try {
        int val = opt.value();
        std::cout << "값: " << val << std::endl;
    } catch (const std::bad_optional_access& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

출력:

값: 42
값: 42
값: 42
값: 42

값 없을 때

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt;  // 값 없음
    
    // has_value()
    std::cout << "has_value: " << opt.has_value() << std::endl;  // 0
    
    // bool 변환
    if (!opt) {
        std::cout << "값 없음" << std::endl;
    }
    
    // value_or()
    int val = opt.value_or(99);
    std::cout << "value_or: " << val << std::endl;  // 99
    
    // value() - 예외
    try {
        int val = opt.value();
    } catch (const std::bad_optional_access& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    return 0;
}

출력:

has_value: 0
값 없음
value_or: 99
예외: bad optional access

3. 실전 예제

예제 1: 검색 함수

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

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

std::optional<User> findUser(const std::vector<User>& users, int id) {
    for (const auto& user : users) {
        if (user.id == id) {
            return user;
        }
    }
    return std::nullopt;
}

std::optional<User> findUserByName(const std::vector<User>& users, const std::string& name) {
    for (const auto& user : users) {
        if (user.name == name) {
            return user;
        }
    }
    return std::nullopt;
}

int main() {
    std::vector<User> users = {
        {1, "Alice", 25},
        {2, "Bob", 30},
        {3, "Charlie", 28}
    };
    
    // ID로 검색
    auto user1 = findUser(users, 2);
    if (user1) {
        std::cout << "찾음: " << user1->name << " (" << user1->age << "세)" << std::endl;
    }
    
    // 이름으로 검색
    auto user2 = findUserByName(users, "Charlie");
    if (user2) {
        std::cout << "찾음: ID=" << user2->id << ", " << user2->name << std::endl;
    }
    
    // 못 찾음
    auto user3 = findUser(users, 99);
    if (!user3) {
        std::cout << "사용자 없음" << std::endl;
    }
    
    return 0;
}

출력:

찾음: Bob (30세)
찾음: ID=3, Charlie
사용자 없음

예제 2: 파싱

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

std::optional<int> parseInt(const std::string& str) {
    std::istringstream iss{str};
    int value;
    
    if (iss >> value) {
        char remaining;
        if (!(iss >> remaining)) {  // 남은 문자 없음
            return value;
        }
    }
    
    return std::nullopt;
}

std::optional<double> parseDouble(const std::string& str) {
    try {
        size_t pos;
        double value = std::stod(str, &pos);
        if (pos == str.length()) {
            return value;
        }
    } catch (...) {
    }
    
    return std::nullopt;
}

int main() {
    auto result1 = parseInt("123");
    if (result1) {
        std::cout << "파싱 성공: " << *result1 << std::endl;
    }
    
    auto result2 = parseInt("abc");
    if (!result2) {
        std::cout << "파싱 실패" << std::endl;
    }
    
    auto result3 = parseDouble("3.14");
    if (result3) {
        std::cout << "파싱 성공: " << *result3 << std::endl;
    }
    
    auto result4 = parseDouble("3.14abc");
    if (!result4) {
        std::cout << "파싱 실패" << std::endl;
    }
    
    return 0;
}

출력:

파싱 성공: 123
파싱 실패
파싱 성공: 3.14
파싱 실패

예제 3: 설정 관리

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

class Configuration {
    std::optional<std::string> databaseUrl_;
    std::optional<int> port_;
    std::optional<int> timeout_;
    std::optional<bool> debug_;
    
public:
    void setDatabaseUrl(const std::string& url) {
        databaseUrl_ = url;
    }
    
    void setPort(int port) {
        port_ = port;
    }
    
    void setTimeout(int timeout) {
        timeout_ = timeout;
    }
    
    void setDebug(bool debug) {
        debug_ = debug;
    }
    
    std::string getDatabaseUrl() const {
        return databaseUrl_.value_or("localhost");
    }
    
    int getPort() const {
        return port_.value_or(5432);
    }
    
    int getTimeout() const {
        return timeout_.value_or(30);
    }
    
    bool isDebug() const {
        return debug_.value_or(false);
    }
    
    void print() const {
        std::cout << "Database: " << getDatabaseUrl() << std::endl;
        std::cout << "Port: " << getPort() << std::endl;
        std::cout << "Timeout: " << getTimeout() << "s" << std::endl;
        std::cout << "Debug: " << (isDebug() ? "true" : "false") << std::endl;
    }
};

int main() {
    Configuration config;
    
    std::cout << "=== 기본 설정 ===" << std::endl;
    config.print();
    
    std::cout << "\n=== 커스텀 설정 ===" << std::endl;
    config.setDatabaseUrl("postgres://db.example.com");
    config.setPort(5433);
    config.setDebug(true);
    config.print();
    
    return 0;
}

출력:

=== 기본 설정 ===
Database: localhost
Port: 5432
Timeout: 30s
Debug: false

=== 커스텀 설정 ===
Database: postgres://db.example.com
Port: 5433
Timeout: 30s
Debug: true

4. 실전 예제: 캐시 시스템

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

template<typename Key, typename Value>
class Cache {
    std::map<Key, Value> storage_;
    size_t maxSize_;
    
public:
    Cache(size_t maxSize = 100) : maxSize_(maxSize) {}
    
    std::optional<Value> get(const Key& key) const {
        auto it = storage_.find(key);
        if (it != storage_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
    
    void put(const Key& key, const Value& value) {
        if (storage_.size() >= maxSize_) {
            // 캐시 가득 참 (간단한 예시: 첫 번째 제거)
            storage_.erase(storage_.begin());
        }
        storage_[key] = value;
    }
    
    bool contains(const Key& key) const {
        return storage_.find(key) != storage_.end();
    }
    
    size_t size() const {
        return storage_.size();
    }
};

int main() {
    Cache<std::string, int> cache(3);
    
    // 캐시에 추가
    cache.put("user:1", 100);
    cache.put("user:2", 200);
    cache.put("user:3", 300);
    
    std::cout << "캐시 크기: " << cache.size() << std::endl;
    
    // 캐시 히트
    if (auto value = cache.get("user:2")) {
        std::cout << "캐시 히트: user:2 = " << *value << std::endl;
    }
    
    // 캐시 미스
    if (auto value = cache.get("user:99")) {
        std::cout << "캐시 히트: " << *value << std::endl;
    } else {
        std::cout << "캐시 미스: user:99" << std::endl;
    }
    
    // 기본값 사용
    int value = cache.get("user:99").value_or(-1);
    std::cout << "value_or: " << value << std::endl;
    
    return 0;
}

출력:

캐시 크기: 3
캐시 히트: user:2 = 200
캐시 미스: user:99
value_or: -1

5. 자주 발생하는 문제

문제 1: 예외

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt;
    
    // ❌ 값 없을 때 예외
    try {
        int val = opt.value();  // std::bad_optional_access
    } catch (const std::bad_optional_access& e) {
        std::cout << "예외: " << e.what() << std::endl;
    }
    
    // ✅ 확인 후 접근
    if (opt) {
        int val = *opt;
        std::cout << "값: " << val << std::endl;
    } else {
        std::cout << "값 없음" << std::endl;
    }
    
    // ✅ 기본값
    int val = opt.value_or(0);
    std::cout << "value_or: " << val << std::endl;
    
    return 0;
}

출력:

예외: bad optional access
값 없음
value_or: 0

문제 2: 참조

#include <optional>
#include <iostream>
#include <functional>

int main() {
    int x = 42;
    
    // ❌ optional<int&>: 불가능
    // std::optional<int&> opt{x};
    
    // ✅ reference_wrapper 사용
    std::optional<std::reference_wrapper<int>> opt1{std::ref(x)};
    opt1->get() = 100;
    std::cout << "x: " << x << std::endl;  // 100
    
    // ✅ 포인터 사용
    std::optional<int*> opt2{&x};
    *(*opt2) = 200;
    std::cout << "x: " << x << std::endl;  // 200
    
    return 0;
}

출력:

x: 100
x: 200

문제 3: 비교

#include <optional>
#include <iostream>

int main() {
    std::optional<int> opt1 = 42;
    std::optional<int> opt2 = 42;
    std::optional<int> opt3;
    
    // 값 비교
    std::cout << "opt1 == opt2: " << (opt1 == opt2) << std::endl;  // 1
    std::cout << "opt1 == opt3: " << (opt1 == opt3) << std::endl;  // 0
    
    // nullopt 비교
    std::cout << "opt3 == nullopt: " << (opt3 == std::nullopt) << std::endl;  // 1
    
    // 값과 직접 비교
    std::cout << "opt1 == 42: " << (opt1 == 42) << std::endl;  // 1
    
    return 0;
}

출력:

opt1 == opt2: 1
opt1 == opt3: 0
opt3 == nullopt: 1
opt1 == 42: 1

6. 실전 예제: 변환 체인

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

// 문자열을 정수로 파싱 (실패 시 nullopt)
std::optional<int> parseInt(const std::string& str) {
    try {
        // std::stoi: string to int 변환
        return std::stoi(str);
    } catch (...) {
        // 변환 실패 시 (예: "abc") nullopt 반환
        return std::nullopt;
    }
}

// 양수 검증 (0 이하면 nullopt)
std::optional<int> validatePositive(int value) {
    if (value > 0) {
        return value;
    }
    // 음수나 0이면 "유효하지 않음"을 nullopt로 표현
    return std::nullopt;
}

// 제곱 계산 (항상 성공)
std::optional<int> square(int value) {
    return value * value;
}

// 제곱근 계산 (음수면 nullopt)
std::optional<double> squareRoot(int value) {
    if (value >= 0) {
        return std::sqrt(value);
    }
    // 음수의 제곱근은 실수 범위에서 불가능
    return std::nullopt;
}

int main() {
    // 체이닝 1: and_then으로 연속 변환
    // and_then: optional이 값을 가지면 함수 적용, 없으면 nullopt 전파
    // "10" → 10 → 양수 확인 → 100
    auto result1 = parseInt("10")
        .and_then(validatePositive)  // 10 > 0 → 10
        .and_then(square);            // 10 * 10 → 100
    
    if (result1) {
        std::cout << "결과: " << *result1 << std::endl;  // 100
    }
    
    // 체이닝 2 (실패): 중간에 nullopt 발생
    // "-5" → -5 → 양수 아님 → nullopt (이후 함수 실행 안됨)
    auto result2 = parseInt("-5")
        .and_then(validatePositive)  // -5 ≤ 0 → nullopt
        .and_then(square);            // 실행 안됨 (nullopt 전파)
    
    if (!result2) {
        std::cout << "유효하지 않은 입력" << std::endl;
    }
    
    // 체이닝 3: 다른 타입으로 변환
    // "16" → 16 → √16 = 4.0
    auto result3 = parseInt("16")
        .and_then(squareRoot);  // int → optional<double>
    
    if (result3) {
        std::cout << "제곱근: " << *result3 << std::endl;  // 4
    }
    
    return 0;
}

출력:

결과: 100
유효하지 않은 입력
제곱근: 4

정리

핵심 요약

  1. optional: 값이 있거나 없을 수 있는 상태
  2. nullopt: 값 없음 표현
  3. 타입 안전: 특수 값 대신 명시적 표현
  4. 접근 방법: *opt, value(), value_or()
  5. 실무: 검색, 파싱, 설정, 캐시

값 접근 방법

방법값 없을 때용도
*optUB빠름, 확인 후 사용
opt.value()예외안전, 예외 처리
opt.value_or(default)기본값안전, 기본값
opt.has_value()false확인

실전 팁

사용 원칙:

  • 검색 결과
  • 파싱 결과
  • 설정 값
  • 체이닝

성능:

  • 스택 할당
  • 크기: sizeof(T) + 1 (정렬 포함)
  • 동적 할당 없음
  • 캐시 친화적

주의사항:

  • 값 없을 때 접근 주의
  • 참조 저장 불가
  • value() 예외 처리
  • value_or() 기본값

다음 단계

  • C++ variant
  • C++ any
  • C++ expected

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

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

  • C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴
  • C++ variant | “타입 안전 union” 가이드
  • C++ any | “타입 소거” 가이드

관련 글

  • C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴
  • C++ std::optional vs 포인터 |
  • C++ any |
  • C++ Buffer Overflow |
  • 모던 C++ (C++11~C++20) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기