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 기능은 별도 표시)
목차
- 문제 시나리오: 값이 없는 상황 처리
- std::optional 기초
- 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;
}
| 특징 | optional | variant<T, E> |
|---|---|---|
| 용도 | 값이 있거나 없음 | 여러 타입 중 하나 |
| 에러 정보 | 없음 (nullopt만) | 에러 타입 저장 가능 |
| 크기 | sizeof(T) + 1 | max(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 효율적으로 사용하기
-
함수 이름으로 의도 표현
// ✅ 좋은 이름 (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); // 실패하면? -
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; } -
C++23 모나딕 연산 활용
// ✅ 체이닝으로 간결하게 auto result = findUser(id) .and_then( { return u.getEmail(); }) .transform( { return e.toLowerCase(); }) .value_or("[email protected]"); -
에러 로깅과 함께 사용
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_access | value() 호출 시 값 없음 | value_or() 사용 또는 has_value() 체크 |
| 컴파일 에러: optional<T&> | 참조 타입 불가 | optional<reference_wrapper |
| 성능 저하 | 큰 타입을 값으로 복사 | const 참조로 전달 |
| 중첩 optional | optional<optional | 설계 재검토, variant 고려 |
| 체이닝 복잡 | 너무 깊은 and_then | 중간 변수로 분리 |
optional vs 다른 방법 성능 비교
| 방법 | 메모리 오버헤드 | 실행 속도 | 안전성 | 사용 난이도 |
|---|---|---|---|---|
| optional | sizeof(T) + 1~8바이트 | 빠름 | 높음 | 쉬움 |
| T* (포인터) | 8바이트 | 매우 빠름 | 낮음 | 중간 |
| unique_ptr | 8바이트 + 힙 할당 | 느림 | 높음 | 중간 |
| 예외 | 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) 핵심 문법 치트시트 | 현업에서 자주 쓰는 한눈에 보기