본문으로 건너뛰기
Previous
Next
C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴

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. 문제 시나리오: 값이 없는 상황 처리

시나리오 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);  // 실패하면?
  1. 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;
   }
  1. C++23 모나딕 연산 활용
   // ✅ 체이닝으로 간결하게
// 변수 선언 및 초기화
   auto result = findUser(id)
       .and_then( { return u.getEmail(); })
       .transform( { return e.toLowerCase(); })
       .value_or("[email protected]");
  1. 에러 로깅과 함께 사용
   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++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ Optional 완벽 가이드 | nullopt·value_or·C++23 모나딕 연산·성능·실전 패턴」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, optional, C++17, C++23, nullable, 선택값, 모나딕연산 등으로 검색하시면 이 글이 도움이 됩니다.