std::optional로 '값 없음' 표현하기: value_or, C++23 and_then·transform, 에러 처리 패턴
이 글의 핵심
특수 값이나 널 포인터로 실패를 나타내면 호출자가 확인을 빠뜨려도 컴파일러가 알려 주지 않습니다. optional은 값이 없을 수 있다는 사실을 타입에 드러내지만, 실패 이유를 전달할 수 없고 비어 있을 때도 T 크기만큼의 공간을 차지한다는 한계가 있습니다. 그래서 optional을 피해야 하는 경우와 variant, 예외와 비교한 선택 기준, 모나딕 연산으로 만드는 파이프라인까지 함께 정리했습니다.
값이 없을 수 있는 상황을 어떻게 표현할까
검색 실패·설정 누락·파싱 실패·캐시 미스
C++ 개발 중 이런 상황을 자주 겪습니다:
- 검색 실패 — 사용자 ID로 검색했는데 없음.
nullptr반환? 예외? 특수값 -1? - 설정 값 누락 — 설정 파일에 특정 키가 없음. 기본값을 어떻게 처리?
- 파싱 실패 — 문자열을 숫자로 변환 실패. 예외는 과하고 에러 코드는 불편함
- 캐시 미스 — 캐시에 데이터가 없음. 매번
nullptr체크는 번거로움 - 부분 초기화 — 객체의 일부 필드가 선택적. 포인터는 메모리 관리 부담
기존 방법의 문제점:
| 방법 | 문제점 |
|---|---|
nullptr | 메모리 관리 부담, 역참조 시 크래시 위험 |
| 특수값 (-1, INT_MIN) | 유효한 값과 구분 어려움, 타입 안전하지 않음 |
| 예외 | 예상된 실패에는 과함, 성능 오버헤드 |
std::pair<bool, T> | 장황함, 실수하기 쉬움 |
이 방법들의 공통된 문제는 “값이 없을 수 있다”는 정보가 타입이 아니라 문서나 관례에만 있다는 점입니다. int findIndex()가 -1을 돌려줄 수 있다는 사실은 시그니처만 봐서는 알 수 없고, 호출자가 확인을 빠뜨려도 컴파일러는 아무 말도 하지 않습니다. std::optional<T>는 이 정보를 반환 타입으로 옮겨서, 값을 꺼내려면 어떤 형태로든 “비어 있는 경우”를 떠올리게 만듭니다. 또 std::pair<bool, T>와 달리 값이 없을 때 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 기능은 별도 표시)
nullptr·-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는 자동으로 소멸됨
설정 파일의 선택 키
// ❌ 특수값 사용 (유효한 값과 구분 어려움)
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); // ✅ 간결하고 명확
문자열을 숫자로 파싱
// ❌ 예외 사용 (예상된 실패에는 과함)
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;
}
주의사항: 위 코드는 catch (...)로 빈 문자열(std::invalid_argument)과 범위 초과(std::out_of_range)를 모두 nullopt로 바꾸지만, 더 교묘한 문제가 남아 있습니다. std::stoi("12abc")는 예외 없이 12를 돌려주고, std::stoi(" 42")도 앞 공백을 건너뛰고 42를 돌려줍니다. 즉 “문자열 전체가 정수인가”는 검사하지 않습니다. 설정값 검증처럼 엄격해야 한다면 C++17의 std::from_chars로 바꾸고, 반환된 ptr이 문자열 끝까지 갔는지 확인하는 편이 정확하고 예외도 쓰지 않아 더 빠릅니다.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parseIntStrict(std::string_view s) {
int value{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) {
return std::nullopt; // 변환 실패, 범위 초과, 또는 뒤에 남은 문자 있음
}
return value;
}
// parseIntStrict("12abc") → nullopt, parseIntStrict("42") → 42
캐시 조회
// ❌ 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
한 가지 짚어 둘 점은 캐시 예제의 return cache[key];가 문자열을 복사한다는 것입니다. optional은 값을 소유하는 타입이라 참조를 돌려줄 수 없으므로, 큰 객체를 자주 조회하는 캐시라면 복사 비용이 쌓입니다. 이런 경우에는 const std::string*를 돌려주거나(수명은 캐시가 보장), std::shared_ptr로 값을 공유하는 편이 낫습니다. “없을 수 있음”을 표현하는 도구가 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;
}
C++23 and_then·transform·or_else
and_then: 체이닝 (flatMap)
C++23부터 사용 가능합니다. -std=c++23(또는 -std=c++2b)을 켜도 표준 라이브러리가 오래되면 'class std::optional<int>' has no member named 'and_then' 에러가 나므로, 이식성이 필요한 코드에서는 기능 매크로 __cpp_lib_optional >= 202110L로 지원 여부를 확인할 수 있습니다. libstdc++는 GCC 12부터 이 연산들을 제공합니다.
세 연산의 차이는 넘기는 함수가 무엇을 반환하느냐에 있습니다. and_then에 넘기는 함수는 반드시 optional을 반환해야 하고(실패할 수 있는 다음 단계), transform에 넘기는 함수는 일반 값을 반환합니다(실패하지 않는 변환). or_else는 비어 있을 때만 호출되며 같은 타입의 optional을 반환해야 합니다. 이 규칙을 헷갈려 and_then에 int를 반환하는 람다를 넘기면 “반환 타입이 optional의 특수화가 아니다”라는 취지의 긴 템플릿 에러가 나고, 반대로 transform에 optional을 반환하는 함수를 넘기면 에러 없이 optional<optional<T>>가 만들어져 뒤 단계에서 타입이 안 맞는 에러로 드러납니다.
#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([](int x) {
return std::to_string(x);
});
std::cout << *str2 << std::endl; // "42"
// 체이닝
auto result = std::optional<int>{10}
.transform([](int x) { return x * 2; }) // 20
.transform([](int x) { return x + 5; }) // 25
.transform([](int x) { return std::to_string(x); }); // "25"
std::cout << *result << std::endl; // "25"
// 빈 optional은 변환 안 됨
std::optional<int> empty;
auto result2 = empty.transform([](int x) {
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([](const User& u) { return u.email; })
.transform([](const std::string& email) {
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([](const User& u) { return u.email; }) // nullopt 반환
.transform([](const std::string& email) {
// 실행 안 됨
return email.substr(email.find('@') + 1);
});
std::cout << noDomain.has_value() << std::endl; // false
return 0;
}
이 파이프라인에는 조심해야 할 부분이 두 군데 있습니다. 첫째, .and_then([](const User& u) { return u.email; })는 u의 멤버를 복사해서 새 optional을 만듭니다. findUser(1)이 돌려준 임시 optional은 문장이 끝나면 사라지므로, 람다가 u.email의 참조나 std::string_view를 반환하도록 바꾸면 댕글링 참조가 됩니다. 둘째, email.find('@')가 npos를 돌려주면 substr(npos + 1)은 substr(0)이 되어 전체 문자열을 도메인이라고 반환합니다. 실패할 수 있는 단계는 transform이 아니라 and_then으로 쓰고 npos일 때 nullopt를 돌려주는 것이 파이프라인의 의도에 맞습니다.
제가 모나딕 연산을 처음 팀 코드에 도입했을 때 가장 자주 받은 리뷰는 “디버거로 중간값을 볼 수 없다”는 것이었습니다. 체인이 한 문장이라 브레이크포인트를 걸 곳이 람다 내부뿐이고, 어느 단계에서 nullopt가 됐는지 로그로 남기기도 어렵습니다. 그래서 두세 단계까지는 체인으로 쓰고, 단계마다 실패 이유를 기록해야 하는 경로는 중간 변수로 풀거나 std::expected로 바꾸는 것을 기준으로 삼는 편이 좋습니다.
value()·*opt·optional<T&>에서 자주 하는 실수
비어 있을 때 value()가 bad_optional_access를 던짐
// ❌ 잘못된 방법
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);
포인터처럼 사용함
// ❌ 잘못된 방법
std::optional<int> opt = 42;
if (opt != nullptr) { // 💥 컴파일 에러
}
// ✅ 올바른 방법
if (opt.has_value()) {
// ...
}
// 또는
if (opt) {
// ...
}
// nullopt와 비교
if (opt != std::nullopt) {
// ...
}
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: 포인터 사용 (더 간단)
int* p = &x; // nullptr가 곧 "없음"
if (p) {
*p = 30;
}
std::optional<int*>처럼 포인터를 optional로 감싸는 것은 피하세요. “optional이 비어 있음”과 “optional은 있는데 안의 포인터가 nullptr”라는 두 가지 빈 상태가 생겨 오히려 헷갈립니다. 참조 optional은 C++26에서 std::optional<T&>로 표준에 들어오지만, 그 전까지는 T*가 가장 단순한 표현입니다.
복사 비용을 오해함
std::optional<std::string> getLargeString() {
std::string large(10000, 'x');
return large; // 복사가 아니라 이동: 지역 변수 반환은 자동으로 rvalue 취급
}
// std::move는 여기서 불필요 (결과는 같고, 코드만 길어짐)
std::optional<std::string> getLargeString2() {
std::string large(10000, 'x');
return std::move(large);
}
// 진짜 복사가 일어나는 곳: optional 자체를 값으로 꺼낼 때
void use(const std::optional<std::string>& opt) {
std::string s = *opt; // 복사
const std::string& r = *opt; // 복사 없음
}
지역 변수 large의 타입(std::string)이 반환 타입(std::optional<std::string>)과 달라서 RVO는 적용되지 않지만, C++14부터(CWG 1579) 반환되는 지역 변수는 먼저 rvalue로 취급되므로 optional의 이동 생성 경로가 선택됩니다. 그래서 return std::move(large);는 결과가 같고, 반환 타입이 같은 경우에는 오히려 RVO를 막아 Clang의 -Wpessimizing-move 경고를 받습니다. 복사 비용을 줄이고 싶다면 반환문보다 *opt를 값으로 받는 곳을 먼저 찾아보세요.
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;
}
중첩 optional은 직접 선언하기보다 의도치 않게 생기는 경우가 대부분입니다. 앞서 본 것처럼 transform에 optional을 반환하는 함수를 넘기거나, std::map<K, std::optional<V>>에서 찾기 결과를 optional로 감싸면 “키가 없음”과 “키는 있는데 값이 비어 있음”이 섞입니다. 두 상태를 구분해야 한다면 그 의미를 이름 있는 타입(enum이나 variant)으로 드러내는 편이 읽는 사람에게 친절합니다.
비어 있을 때 *opt로 접근함
std::optional<int> opt;
int x = *opt; // 💥 미정의 동작: 예외도 없고 크래시도 보장되지 않음
value()는 비어 있으면 bad_optional_access를 던지지만 *opt는 아무 검사도 하지 않습니다. 미정의 동작이라 쓰레기 값이 조용히 나오는 경우가 많아 발견이 늦어집니다. 디버그 빌드에서 잡고 싶다면 libstdc++에서는 -D_GLIBCXX_ASSERTIONS, libc++에서는 하드닝 모드를 켜면 비어 있는 optional의 역참조를 assert로 멈춰 줍니다.
설정 파싱·캐시·API 응답에 적용한 에러 처리
설정 파일 파싱
#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([](const std::string& s) -> 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([](const std::string& s) {
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;
}
캐시 시스템
#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;
}
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([](const std::string& s) {
return s.length();
});
std::cout << "Body length: " << bodyLength.value_or(0) << std::endl;
return 0;
}
메모리 오버헤드와 optional을 피해야 할 때
메모리 오버헤드
#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<LargeType>은 비어 있어도 4,000바이트 남짓을 그대로 차지합니다. optional은 값을 힙이 아니라 자기 안에 직접 저장하기 때문입니다. 대부분 비어 있는 큰 필드를 수천 개 객체에 멤버로 두면 메모리가 크게 낭비되므로, 그런 경우에는 std::unique_ptr<LargeType>처럼 필요할 때만 할당하는 편이 낫습니다. 또 optional<double>처럼 정렬이 8바이트인 타입은 플래그 1바이트 때문에 크기가 16바이트로 두 배가 되어, 대량 배열에서는 캐시 효율에 영향을 줄 수 있습니다.
언제 optional을 피해야 하는가
// ❌ 나쁜 사용: 항상 값이 있는 경우
std::optional<int> getUserAge(int userId) {
// 항상 나이를 반환한다면 optional 불필요
return 25;
}
// ✅ 좋은 사용: 값이 없을 수 있는 경우
std::optional<int> getUserAge(int userId) {
if (userId < 0) {
return std::nullopt; // 유효하지 않은 사용자
}
return 25;
}
// ❌ 나쁜 사용: 참조를 optional로
// std::optional<T&>는 불가능
// std::optional<std::reference_wrapper<T>>는 복잡함
// ✅ 좋은 사용: 포인터 사용
T* ptr = findObject(); // nullptr 가능
“핫 루프에서는 optional 대신 특수값을 써야 한다”는 조언을 자주 보지만, optional<int> 같은 작은 트리비얼 타입은 최적화 빌드에서 값과 플래그가 레지스터 두 개로 전달되는 수준이라 특수값과 거의 차이가 없습니다. 특수값이 유리한 경우는 수백만 개를 배열로 저장해 크기(4바이트 대 8바이트)가 캐시 효율을 좌우할 때 정도입니다. 추측으로 optional을 걷어내기보다 프로파일러에서 실제로 병목으로 보일 때 바꾸는 것이 순서입니다.
성능 비교 예제
#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();
benchmarkPointer(); // new/delete 때문에 보통 훨씬 느림
return 0;
}
이 비교에서 차이를 만드는 것은 optional 자체가 아니라 힙 할당입니다. 포인터 버전은 짝수 번째마다 new/delete를 하므로 할당자 비용이 그대로 측정되고, optional 버전은 스택에서 끝납니다. 즉 이 예제가 보여주는 것은 “소유권을 넘기려고 힙을 쓰는 설계 대신 값을 돌려주라”는 점이지, optional이 포인터보다 빠르다는 뜻은 아닙니다. 이미 존재하는 객체를 가리키기만 하는 T* 반환은 할당이 없으므로 optional과 비슷하거나 더 빠릅니다. 실제 수치는 컴파일러, 최적화 옵션, 할당자에 따라 크게 달라지므로 직접 -O2로 빌드해 확인하세요.
variant·예외·expected와 비교
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 | 예외 |
|---|---|---|
| 사용 시기 | 예상된 실패 | 예외적 상황 |
| 성능 | 빠름 (분기) | 느림 (스택 언와인딩) |
| 에러 정보 | 없음 | 상세한 메시지 |
| 무시했을 때 | 확인 안 해도 컴파일됨 (value()면 예외) | 아무도 잡지 않으면 std::terminate |
| 코드 흐름 | 명시적 | 암시적 |
“성능: 느림”은 예외가 실제로 던져질 때의 이야기입니다. 주요 컴파일러의 제로 비용 예외 모델에서는 예외가 발생하지 않는 경로의 비용이 거의 없고, 던질 때 스택 되감기와 테이블 탐색 비용이 큽니다. 그래서 “사용자 입력이 숫자가 아닐 수 있다”처럼 자주 일어나는 실패는 optional로, “설정 파일이 손상되어 서비스를 시작할 수 없다”처럼 드물고 상위에서 한꺼번에 처리하는 실패는 예외로 나누는 것이 일반적인 기준입니다.
optional vs expected (C++23)
C++23의 std::expected<T, E>는 optional의 “값 또는 없음”을 “값 또는 에러 값”으로 넓힌 타입입니다. and_then, transform, or_else를 똑같이 제공하고, 추가로 에러 쪽을 변환하는 transform_error가 있습니다.
#include <expected>
#include <string>
std::expected<int, std::string> divide(int a, int b) {
if (b == 0) {
return std::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;
}
// 사용
auto r = divide(10, 0);
if (r) {
// *r 사용
} else {
// r.error() == "Division by zero"
}
API를 처음 설계할 때 optional로 시작했다가 나중에 “왜 실패했는지”를 알려 달라는 요구가 생겨 반환 타입을 바꾸는 일이 흔합니다. 반환 타입을 바꾸면 모든 호출자를 고쳐야 하므로, 호출자가 실패 원인에 따라 분기할 가능성이 조금이라도 보이면 처음부터 expected(C++23 이전이라면 tl::expected 같은 라이브러리)를 쓰는 편이 비용이 적습니다.
관련 글: Optional과 Variant 활용에서 두 타입을 함께 사용하는 실전 패턴을 학습하세요.
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([](const std::string& s) -> 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([](const std::string& s) {
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;
}
value_or·반환 타입·네이밍 원칙
value_or()를 기본으로 사용
// ✅ 간결하고 안전
int port = config.getInt("port").value_or(8080);
// ❌ 장황함
int port;
if (auto p = config.getInt("port")) {
port = *p;
} else {
port = 8080;
}
실패할 수 있는 함수의 반환 타입으로 사용
// ✅ 실패 가능성을 타입으로 표현
std::optional<User> findUser(int id);
// ❌ 포인터 (메모리 관리 부담)
User* findUser(int id);
// ❌ 예외 (예상된 실패에는 과함)
User findUser(int id); // 없으면 예외
C++23 모나딕 연산 활용
// ✅ 체이닝으로 간결하게
auto email = findUser(123)
.and_then([](const User& u) { 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;
}
}
구조화된 바인딩과 함께 쓰기 (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;
}
이름에 maybe·try를 붙여 드러내기
// ✅ 명확한 함수 이름
std::optional<User> tryFindUser(int id);
std::optional<int> maybeParseInt(const std::string& str);
// ❌ 모호한 이름
User getUser(int id); // 없으면 어떻게 되나?
int parseInt(const std::string& str); // 실패하면?
체이닝 헬퍼·여러 optional 결합·지연 평가
옵셔널 체이닝 헬퍼
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, [](int x) { return x * 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)) {
// 모든 값이 있음
}
지연 평가
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_;
}
};
// 사용 (람다는 optional이 아닌 값을 반환해야 cache_가 optional<optional<...>>로 중첩되지 않음)
LazyOptional expensive{[]() {
// 비싼 계산
return 42;
}};
// 필요할 때만 계산
if (auto value = expensive.get()) {
std::cout << *value << std::endl;
}
std::optional 정리
상황별 사용 여부
| 상황 | 사용 여부 | 대안 |
|---|---|---|
| 검색 실패 가능 | ✅ 사용 | - |
| 설정 값 누락 | ✅ 사용 | - |
| 파싱 실패 | ✅ 사용 | - |
| 항상 값 있음 | ❌ 불필요 | 일반 타입 |
| 상세한 에러 정보 필요 | ❌ 부적합 | variant, expected |
| 큰 타입이 대부분 비어 있음 | ⚠️ 신중히 | unique_ptr |
| 참조 저장 | ❌ C++23까지 불가 | 포인터, reference_wrapper |
사용 결정 트리
값이 없을 수 있는가?
├─ Yes → optional 사용 고려
│ ├─ 에러 정보 필요? → expected<T, E> (C++23) 또는 variant<T, Error>
│ ├─ 기존 객체를 가리키기만? → T* (C++26부터 optional<T&>)
│ ├─ 큰 타입이고 대부분 비어 있음? → unique_ptr<T>
│ └─ 일반적인 경우 → std::optional ✅
└─ No → 일반 타입 사용
증상별 빠른 해결
| 증상 | 원인 | 해결법 |
|---|---|---|
| bad_optional_access | value() 호출 시 값 없음 | value_or() 사용 또는 has_value() 체크 |
| 컴파일 에러: optional<T&> | 참조 타입 불가 (C++23까지) | 포인터 또는 optional<reference_wrapper |
| 성능 저하 | 큰 타입을 값으로 복사 | const 참조로 전달 |
| 중첩 optional | transform에 optional 반환 함수 전달 | and_then으로 교체 |
| 쓰레기 값이 나옴 | 빈 optional에 *opt | value()/value_or() 또는 _GLIBCXX_ASSERTIONS |
| 체이닝 복잡 | 너무 깊은 and_then | 중간 변수로 분리 |
optional과 다른 방법 비교
| 방법 | 공간 | 실패 경로 비용 | 안전성 | 실패 이유 전달 |
|---|---|---|---|---|
| optional | sizeof(T) + 플래그·패딩 (비어도 동일) | 분기 한 번 | 높음 | 불가 |
| T* (포인터) | 포인터 크기 | 분기 한 번 | 낮음 (nullptr 역참조) | 불가 |
| unique_ptr | 포인터 크기 + 값이 있을 때 힙 할당 | 분기 한 번 | 높음 | 불가 |
| expected<T, E> | max(T, E) + 태그 | 분기 한 번 | 높음 | 가능 |
| 예외 | 반환값에 추가 없음 | 던질 때 큼 | 높음 | 가능 |
| 특수값 | 추가 없음 | 분기 한 번 | 낮음 (확인 누락) | 제한적 |
이어서 볼 글
- std::variant 가이드에서 여러 타입 중 하나를 표현하는 방법 학습
- Optional과 Variant 활용에서 두 타입의 실전 활용법 학습
- 예외 처리 기초에서 예외 처리 기본 개념 학습
- 예외 처리 가이드에서 예외와 optional의 선택 기준 학습
- 다형성과 Variant에서 상속 대신 variant 사용법 학습
FAQ
Q1: optional과 포인터 중 무엇을 반환해야 하나요?
A: 함수가 새 값을 만들어 돌려준다면 optional, 이미 어딘가에 존재하는 객체를 가리키기만 한다면 포인터가 맞습니다. optional은 값을 복사하거나 이동해서 담기 때문에, 컨테이너 안의 원소를 찾는 함수가 optional을 반환하면 매번 복사가 일어나고 원본을 수정할 수도 없습니다.
Q2: 성능 오버헤드는?
A: 공간은 플래그 1바이트와 정렬 패딩이 추가되고, 비어 있어도 T 크기만큼을 차지합니다. 실행 시간은 값이 있는지 확인하는 분기 한 번 정도라 대부분의 코드에서 측정하기 어려운 수준입니다.
Q3: optional<T&>는?
A: C++23까지는 컴파일 에러입니다. T*를 쓰거나 optional<reference_wrapper<T>>로 우회할 수 있고, C++26에서 std::optional<T&>가 추가됩니다.
Q4: 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) { /* ... */ }
}
Q5: 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");
}
};
Q6: optional은 언제 사용하나요?
A: “true/false/모름” 세 가지 상태가 필요할 때 사용합니다.
// 사용자 동의 상태
std::optional<bool> user_consent; // nullopt = 아직 묻지 않음
if (!user_consent) {
askForConsent();
} else if (*user_consent) {
proceed();
} else {
showError();
}
Q7: optional 체이닝이 너무 깊어지면?
A: 중간 변수로 분리하여 가독성을 높이세요.
// ✅ 중간 변수로 분리
auto user = getUser(id);
if (!user) return std::nullopt;
auto profile = user->getProfile();
if (!profile) return std::nullopt;
return profile->getEmail();