- Published on
C++23 std - -expected로 예외 제거·RAII 안전화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·게임·금융처럼 지연시간과 안정성이 중요한 C++ 코드베이스에서는 예외(throw)를 전면 금지하거나, 최소한 경계(ABI/플러그인/스레드 경계)에서 차단하는 규칙을 두는 경우가 많습니다. 문제는 예외를 없애면 오류를 어떻게 전달하고, 어떻게 “안전하게” 자원을 정리할지입니다.
C++23의 std::expected는 이 지점을 정확히 겨냥합니다. 성공 값과 오류 값을 하나의 타입으로 묶어 오류를 타입 시스템으로 끌어올리고, 호출자가 반드시 분기하도록 강제합니다. 여기에 RAII를 결합하면 “예외 없는 코드”에서도 누수 없이, 그리고 부분 초기화 없이 안전한 흐름을 만들 수 있습니다.
아래에서는 std::expected의 핵심 사용법, RAII 안전화 패턴, 그리고 실무에서 흔히 겪는 함정과 팀 규칙까지 정리합니다.
왜 std::expected인가
예외 기반 오류 처리는 깔끔해 보이지만, 다음 비용을 자주 유발합니다.
- 제어 흐름이 숨겨짐: 호출부에서 실패 가능성이 잘 보이지 않습니다.
- 경계 문제: 스레드 경계, C API 경계, 플러그인 ABI 경계에서 예외가 새면 치명적입니다.
- 성능/예측성: 예외는 “정상 경로”가 아니므로, 실패가 잦은 상황에서 비용이 커집니다.
반대로 std::expected는 다음 장점이 있습니다.
- 실패 가능성을 반환 타입으로 드러냄
- 호출부에서
if (!res)같은 형태로 분기를 강제 - 오류 타입을 도메인에 맞게 설계 가능(에러 코드, 메시지, 컨텍스트)
- RAII와 결합 시, 실패 경로에서도 자동 정리 가능
std::expected 기본 형태
std::expected<T, E>는 “T 또는 E”를 담습니다.
- 성공이면
T를 보관 - 실패면
E를 보관
간단 예시부터 보겠습니다.
#include <expected>
#include <string>
enum class Errc {
invalid_input,
io_error,
};
std::expected<int, Errc> parse_port(const std::string& s) {
if (s.empty()) {
return std::unexpected(Errc::invalid_input);
}
try {
int v = std::stoi(s);
if (v < 1 || v > 65535) {
return std::unexpected(Errc::invalid_input);
}
return v;
} catch (...) {
return std::unexpected(Errc::invalid_input);
}
}
int main() {
auto port = parse_port("8080");
if (!port) {
// port.error() 로 Errc 확인
return 1;
}
int v = *port; // 또는 port.value()
(void)v;
}
여기서 포인트는 std::unexpected(E)로 실패를 명시한다는 점입니다.
RAII 안전화: “부분 초기화”를 없애는 방식
예외를 쓰지 않으면 흔히 다음 같은 코드가 나옵니다.
- 리소스를 단계적으로 얻다가 중간에 실패
- 실패 시점에 이미 얻은 리소스를 수동으로 해제해야 함
- 해제 누락이나 순서 오류가 발생
RAII는 원래 예외 안전을 위한 핵심 도구지만, 예외가 없더라도 동일하게 강력합니다. std::expected와 조합하면 다음이 가능합니다.
- 리소스 획득 함수는
std::expected<Resource, Error>를 반환 - 상위 조립 함수는
and_then스타일로 연결 - 중간 실패 시점에도 이미 생성된 RAII 객체가 스코프 종료로 정리
예시: 파일 디스크립터 RAII + expected
#include <expected>
#include <string>
#include <utility>
#include <unistd.h>
#include <fcntl.h>
struct SysError {
int code;
std::string what;
};
class UniqueFd {
public:
UniqueFd() = default;
explicit UniqueFd(int fd) : fd_(fd) {}
UniqueFd(const UniqueFd&) = delete;
UniqueFd& operator=(const UniqueFd&) = delete;
UniqueFd(UniqueFd&& other) noexcept : fd_(std::exchange(other.fd_, -1)) {}
UniqueFd& operator=(UniqueFd&& other) noexcept {
if (this != &other) {
reset();
fd_ = std::exchange(other.fd_, -1);
}
return *this;
}
~UniqueFd() { reset(); }
int get() const { return fd_; }
explicit operator bool() const { return fd_ >= 0; }
void reset() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_ = -1;
};
std::expected<UniqueFd, SysError> open_readonly(const std::string& path) {
int fd = ::open(path.c_str(), O_RDONLY);
if (fd < 0) {
return std::unexpected(SysError{errno, "open failed"});
}
return UniqueFd{fd};
}
이제 상위 함수에서 조합해도, 실패 시 자동으로 닫힙니다.
and_then, transform, or_else로 파이프라인 만들기
C++23 std::expected는 멤버 함수로 조합 연산을 제공합니다(구현/표준 라이브러리 버전에 따라 제공 범위가 다를 수 있으니, 사용 중인 컴파일러와 libstdc++/libc++ 버전을 확인하세요).
and_then(f): 성공 값이면f(T)를 호출해 다음expected로 이어감transform(f): 성공 값이면f(T)로 값을 변환(오류는 그대로)or_else(f): 실패면f(E)로 대체/로깅/매핑
예시: 읽기 + 파싱 + 변환
#include <expected>
#include <string>
#include <vector>
struct Error {
int code;
std::string msg;
};
std::expected<std::string, Error> read_all_text(int fd);
std::expected<int, Error> parse_int(const std::string& s);
std::expected<int, Error> read_and_parse_int(const std::string& path) {
return open_readonly(path)
.and_then([](const UniqueFd& fd) {
return read_all_text(fd.get());
})
.and_then([](const std::string& text) {
return parse_int(text);
})
.transform([](int v) {
return v * 2;
})
.or_else([](const Error& e) {
// 여기서 로깅하거나, 에러를 다른 코드로 매핑할 수 있음
return std::unexpected(e);
});
}
이 스타일의 장점은 “실패 시 즉시 중단”이 자연스럽고, 성공 경로가 위에서 아래로 읽힌다는 점입니다.
오류 타입 설계: E를 어떻게 잡을 것인가
std::expected<T, E>에서 E는 매우 중요합니다. 실무에서는 보통 다음 중 하나를 선택합니다.
enum class기반 에러 코드- 장점: 작고 빠름, 비교/스위치 용이
- 단점: 메시지/컨텍스트가 부족
- 구조체 기반(코드 + 메시지 + 컨텍스트)
- 장점: 디버깅/관측성에 유리
- 단점: 복사 비용이 커질 수 있음
std::error_code/std::error_condition사용- 장점: 표준 생태계와 결합, OS 에러에 강함
- 단점: 도메인 에러를 억지로 끼우면 표현력이 약해질 수 있음
실무 팁은 “경계에서는 풍부하게, 내부에서는 가볍게”입니다.
- 라이브러리 내부 깊숙한 곳은
enum class같은 작은 에러 - API 경계(로그/리턴/메트릭)로 나올수록 메시지와 컨텍스트를 덧붙여 관측성을 높임
이 관점은 장애 진단에서도 동일합니다. 예를 들어 인프라 레이어에서 원인 분류를 잘 해두면, 장애 시 트러블슈팅 시간이 급감합니다. 비슷한 맥락으로 운영 진단 글도 함께 참고할 만합니다: Kubernetes CrashLoopBackOff 10가지 원인과 15분 진단
예외 제거 시 흔한 실수: “무시 가능한 실패”가 늘어나는 문제
expected로 바꾸면 호출부에 if (!res)가 많아지고, 팀이 바쁘면 다음 형태가 슬금슬금 들어옵니다.
- 실패를 무시하고 기본값으로 대체
- 실패를 로그만 찍고 계속 진행
이건 예외를 쓰던 시절의 “catch 후 무시”와 같은 안티패턴입니다. 방지책은 다음과 같습니다.
[[nodiscard]]를 적극 활용(많은 표준 라이브러리 구현에서expected는nodiscard가 적용되지만, 팀 규칙으로도 강제)- “오류는 반드시 상위로 전파하거나, 여기서 완전히 처리한다”는 규칙을 코드 리뷰 체크리스트에 넣기
or_else에서 로깅만 하고std::unexpected(e)로 다시 반환하도록 팀 컨벤션화
RAII + expected로 “롤백 가능한” 초기화 만들기
자원 획득이 여러 단계인 초기화에서 특히 유용합니다.
예시: 소켓 생성, 옵션 설정, 연결
#include <expected>
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
struct NetError {
int code;
std::string msg;
};
class UniqueSocket {
public:
explicit UniqueSocket(int fd = -1) : fd_(fd) {}
UniqueSocket(const UniqueSocket&) = delete;
UniqueSocket& operator=(const UniqueSocket&) = delete;
UniqueSocket(UniqueSocket&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
UniqueSocket& operator=(UniqueSocket&& o) noexcept {
if (this != &o) {
reset();
fd_ = std::exchange(o.fd_, -1);
}
return *this;
}
~UniqueSocket() { reset(); }
int get() const { return fd_; }
void reset() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_;
};
std::expected<UniqueSocket, NetError> make_tcp_socket() {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) return std::unexpected(NetError{errno, "socket failed"});
return UniqueSocket{fd};
}
std::expected<void, NetError> set_nodelay(int fd) {
int yes = 1;
if (::setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &yes, sizeof(yes)) < 0) {
return std::unexpected(NetError{errno, "setsockopt TCP_NODELAY failed"});
}
return {};
}
std::expected<void, NetError> connect_ipv4(int fd, const std::string& ip, int port) {
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
if (::inet_pton(AF_INET, ip.c_str(), &addr.sin_addr) != 1) {
return std::unexpected(NetError{EINVAL, "invalid ip"});
}
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) < 0) {
return std::unexpected(NetError{errno, "connect failed"});
}
return {};
}
std::expected<UniqueSocket, NetError> dial(const std::string& ip, int port) {
auto sock = make_tcp_socket();
if (!sock) return std::unexpected(sock.error());
if (auto r = set_nodelay(sock->get()); !r) return std::unexpected(r.error());
if (auto r = connect_ipv4(sock->get(), ip, port); !r) return std::unexpected(r.error());
return std::move(*sock);
}
핵심은 UniqueSocket이 스코프를 벗어나면 자동으로 닫히므로, 중간 단계에서 실패해도 “롤백”이 자연스럽게 된다는 점입니다.
expected<void, E>로 “성공/실패만” 표현하기
리턴할 값이 없고 성공/실패만 필요한 경우 std::expected<void, E>가 깔끔합니다.
- 성공:
return {}; - 실패:
return std::unexpected(err);
이 패턴은 설정 적용, 검증, 커밋 같은 단계형 로직에서 특히 유용합니다.
예외를 완전히 없앨 것인가: 경계에서만 바꿀 것인가
현실적인 마이그레이션 전략은 보통 두 가지입니다.
- 코어 로직은
expected, 경계에서만 예외 변환- 외부 프레임워크가 예외를 기대하는 경우(테스트 프레임워크, 일부 RPC 프레임워크)
- 경계에서
expected실패를 예외로 변환해도, 내부는 예측 가능한 흐름 유지
- 경계도 포함해 전면 금지
- 게임 엔진, 저지연 트레이딩, 임베디드 등
전면 금지로 갈수록 “관측성”이 중요해집니다. 에러 코드만 던져주면 운영에서 원인 파악이 어려워지고, 결국 장애 대응이 늦어집니다. 운영에서 원인-증상 매핑이 중요하다는 점은 다른 분야에서도 동일합니다. 예를 들어 인증 오류도 원인을 잘 분류하면 해결이 빨라집니다: OAuth2 PKCE에서 invalid_grant 나는 7가지
성능 관점 체크리스트
expected는 예외보다 “항상 빠르다”가 아니라, 실패가 정상적으로 발생할 수 있는 흐름에서 예측 가능하다는 점이 중요합니다. 실무에서 다음을 체크하세요.
E가 너무 무거우면(큰std::string여러 개) 복사 비용이 커질 수 있음- 해결:
E를 작게 유지하거나, 메시지는 필요 시점에만 구성
- 해결:
- 반환 값이 큰
T라면 이동 비용 확인- 해결:
T를 이동 친화적으로 설계, 또는 핸들/포인터 패턴 사용
- 해결:
- “실패가 드문데 예외를 없애야 하는가”는 정책 문제
- ABI 경계 안전, 코드 가독성, 테스트 용이성까지 포함해 결정
팀 컨벤션 추천
- 함수가 실패할 수 있으면
std::expected를 기본으로 고려 - 오류 타입
E는 계층화- 저수준: 작은 코드
- 상위: 컨텍스트를 덧붙인 구조체
value()남용 금지(실패 시 종료/예외 가능)- 대신
if (!res)또는and_then체이닝
- 대신
or_else는 “처리 후 종료” 또는 “처리 후 재전파” 중 하나로 통일- 테스트에서는 실패 케이스를 먼저 고정
error()가 기대한 코드/메시지를 담는지 검증
마무리
C++23 std::expected는 예외를 대체하는 단순한 문법 설탕이 아니라, 오류를 타입으로 모델링해 제어 흐름을 드러내고, RAII와 결합해 부분 초기화와 누수 위험을 구조적으로 제거하는 도구입니다.
마이그레이션은 “핵심 경로부터”가 좋습니다.
- 실패가 자주 발생하거나(입력 파싱, 네트워크, 파일 I/O)
- 예외가 경계를 넘기 쉬운 곳부터(스레드, C API, 플러그인)
이 두 영역에 expected를 먼저 적용하면, 코드 안정성과 디버깅 효율이 빠르게 체감될 것입니다.