- Published on
C++23 std - -expected로 예외 없이 안전하게 자원관리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/인프라 코드든 라이브러리 코드든, C++에서 예외(throw)를 꺼리는 팀은 꽤 많습니다. 이유는 명확합니다. 예외는 제어 흐름을 비가시적으로 만들고, ABI/컴파일 옵션(예: -fno-exceptions) 제약을 만들며, 실시간/저지연 시스템에서는 비용 예측이 어렵다고 느끼기 쉽습니다. 그렇다고 전통적인 bool 반환 + out 파라미터로 돌아가면 오류 전파가 장황해지고, 누락된 체크가 곧 장애로 이어집니다.
C++23의 std::expected는 이 간극을 메우는 표준 도구입니다. 성공 값 또는 오류 값을 동일한 타입 컨테이너로 전달하고, 호출자는 실패를 강제적으로 다루게 됩니다. 그리고 RAII를 결합하면 예외 없이도 자원 누수/부분 초기화 문제를 강하게 줄일 수 있습니다.
이 글에서는 std::expected로 예외를 제거하면서도 안전한 자원관리(파일/소켓/FD/핸들, 트랜잭션성 작업)를 구성하는 실전 패턴을 다룹니다.
std::expected 핵심 개념
std::expected<T, E>는 다음 중 하나를 담습니다.
- 성공:
T값 - 실패:
E오류
주요 API는 아래와 같습니다.
has_value()/operator bool(): 성공 여부value(): 성공 값(실패 시 UB가 아니라 예외를 던질 수 있으니 주의, 팀 정책에 따라value()사용을 제한하는 경우도 많습니다)error(): 오류 값value_or(default): 기본값 제공and_then,transform,or_else,transform_error: 함수형 체이닝
예외를 쓰지 않는 코드베이스라면 특히 중요한 점은, value()가 내부적으로 std::bad_expected_access를 던질 수 있다는 것입니다. 따라서 예외 비활성 환경에서는 value() 대신 if (!exp) return unexpected(exp.error()); 같은 형태로 다루는 습관이 좋습니다.
왜 std::expected가 “자원관리”와 잘 맞나
자원관리 문제는 보통 두 가지에서 터집니다.
- 부분 초기화: A는 열었는데 B에서 실패해서 A를 닫지 못함
- 오류 전파 누락: 실패를 무시하고 다음 단계 진행
RAII는 1을 해결하는 정석입니다. 자원을 소유하는 타입이 소멸자에서 정리하면, 중간에 실패하더라도 스코프를 벗어나는 순간 자동으로 정리됩니다.
std::expected는 2를 해결합니다. 실패가 반환 타입에 “박혀” 있으니, 호출자는 성공/실패 분기를 반드시 작성하게 되고(또는 체이닝에서 자동으로 중단), 실패를 값으로 전달할 수 있습니다.
결론적으로 RAII + expected는 예외 없이도 안전한 자원관리 파이프라인을 구성하기에 가장 자연스러운 조합입니다.
에러 타입 설계: 문자열보다 “구조화된 오류”
E로 std::string을 쓰면 시작은 쉽지만, 운영/관측성 측면에서 금방 한계가 옵니다. 추천은 다음처럼 구조화된 오류 타입입니다.
- 오류 코드(enum)
- 시스템 에러(
std::error_code) 보관 - 메시지(필요 시)
- 컨텍스트(경로, endpoint 등)
#include <expected>
#include <string>
#include <system_error>
enum class Errc {
OpenFailed,
ReadFailed,
ParseFailed,
Timeout,
};
struct Error {
Errc code;
std::error_code sys; // errno 기반이면 여기에 담기
std::string context; // 파일 경로/호스트 등
std::string message; // 사람이 읽을 메시지
};
template <class T>
using Expected = std::expected<T, Error>;
이렇게 해두면 상위 레이어에서 code로 분기하고, 로깅에는 sys와 context를 함께 출력해 원인 추적이 쉬워집니다.
RAII 핸들 래퍼 + expected로 안전하게 열기
POSIX FD, 윈도우 HANDLE, 소켓 등은 “자원 소유 타입”으로 감싸는 순간 안전성이 크게 올라갑니다.
아래는 간단한 FD RAII 예시입니다.
#include <expected>
#include <cerrno>
#include <cstring>
#include <system_error>
#include <string>
#include <fcntl.h>
#include <unistd.h>
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_(other.fd_) {
other.fd_ = -1;
}
UniqueFd& operator=(UniqueFd&& other) noexcept {
if (this != &other) {
reset();
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
~UniqueFd() { reset(); }
int get() const { return fd_; }
explicit operator bool() const { return fd_ != -1; }
void reset() {
if (fd_ != -1) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_ = -1;
};
struct Error {
int code; // 예시 단순화
std::error_code sys;
std::string context;
std::string message;
};
template <class T>
using Expected = std::expected<T, Error>;
Expected<UniqueFd> open_readonly(std::string path) {
int fd = ::open(path.c_str(), O_RDONLY);
if (fd == -1) {
return std::unexpected(Error{
.code = 1,
.sys = std::error_code(errno, std::generic_category()),
.context = path,
.message = "open failed"
});
}
return UniqueFd(fd);
}
포인트는 UniqueFd가 자원을 소유하므로, 이후 로직에서 실패가 발생해도 자동으로 close()가 호출된다는 점입니다. expected는 “열기 실패”를 값으로 전달합니다.
단계적 초기화: 여러 자원을 묶을 때의 패턴
실제 시스템은 파일 하나만 열지 않습니다. 예를 들어
- 설정 파일 열기
- 읽기
- 파싱
- 소켓 연결
- 핸드셰이크
처럼 여러 단계가 있고, 중간 실패 시 이미 확보한 자원을 정리해야 합니다.
RAII + expected에서는 “각 자원은 소유 타입으로 즉시 감싸고, 실패는 즉시 반환”이 가장 단순하며 안전합니다.
#include <expected>
#include <string>
#include <vector>
struct Error {
std::string message;
};
template <class T>
using Expected = std::expected<T, Error>;
Expected<std::string> read_all(const UniqueFd& fd);
Expected<int> parse_port(const std::string& text);
Expected<int> connect_tcp(std::string host, int port); // 예시: 소켓 fd 반환
Expected<int> init_client(std::string config_path) {
auto fd_exp = open_readonly(config_path);
if (!fd_exp) return std::unexpected(fd_exp.error());
UniqueFd cfg_fd = std::move(*fd_exp);
auto text_exp = read_all(cfg_fd);
if (!text_exp) return std::unexpected(text_exp.error());
auto port_exp = parse_port(*text_exp);
if (!port_exp) return std::unexpected(port_exp.error());
auto sock_exp = connect_tcp("127.0.0.1", *port_exp);
if (!sock_exp) return std::unexpected(sock_exp.error());
return *sock_exp;
}
여기서 read_all이나 parse_port에서 실패하면 함수는 즉시 반환되고, cfg_fd는 스코프 종료로 자동 정리됩니다.
and_then/transform로 오류 전파를 더 짧게
위 코드는 안전하지만 반복이 많습니다. C++23 std::expected는 체이닝을 제공합니다.
and_then(f): 성공 값이면f(T)를 호출해 다음expected로 이어감transform(f): 성공 값이면U f(T)로 바꿔expected<U,E>반환or_else(f): 실패면f(E)실행
예시는 다음처럼 바꿀 수 있습니다.
Expected<int> init_client_chain(std::string config_path) {
return open_readonly(config_path)
.and_then([](UniqueFd fd) {
return read_all(fd);
})
.and_then([](std::string text) {
return parse_port(text);
})
.and_then([](int port) {
return connect_tcp("127.0.0.1", port);
});
}
주의할 점은 람다의 파라미터 전달 방식입니다.
UniqueFd같은 move-only 타입은 값으로 받아 move 흐름을 타게 하거나- 참조로 받되 생명주기를 명확히 해야 합니다.
또한 체이닝은 디버깅 시 스택 트레이스가 단순하지 않을 수 있어, 팀 스타일에 따라 “핫패스는 체이닝, 복잡한 곳은 명시적 if-return”처럼 혼용하기도 합니다.
부분 실패를 “롤백 가능한 작업”으로 모델링하기
자원관리는 파일/소켓 같은 OS 자원에만 국한되지 않습니다. 예를 들어
- 임시 파일 생성 후 최종 파일로 rename
- DB 트랜잭션 시작 후 커밋/롤백
- 설정 적용 중 일부만 반영됨
이런 경우는 RAII로 “스코프 가드”를 두는 게 매우 강력합니다. 예외가 없더라도, 중간 return unexpected(...)가 발생하면 가드 소멸자가 롤백을 수행합니다.
#include <utility>
class ScopeGuard {
public:
explicit ScopeGuard(std::function<void()> f) : f_(std::move(f)) {}
~ScopeGuard() { if (active_) f_(); }
void dismiss() { active_ = false; }
private:
std::function<void()> f_;
bool active_ = true;
};
Expected<void> update_config_atomically(std::string path, std::string new_text) {
// 예시: tmp 파일 쓰고 rename 하는 흐름
auto fd_exp = open_readonly(path + ".tmp");
if (!fd_exp) return std::unexpected(fd_exp.error());
UniqueFd tmp_fd = std::move(*fd_exp);
ScopeGuard cleanup([&]() {
// 실패 시 tmp 정리(여기서는 예시)
// 실제로는 unlink 등을 수행
});
// write_all(tmp_fd, new_text) 같은 단계가 있다고 가정
// 실패하면 unexpected 반환, cleanup이 실행됨
// rename 성공 시 cleanup 해제
cleanup.dismiss();
return {};
}
이 패턴은 장애 대응에서 특히 유용합니다. “중간 실패”가 발생해도 시스템이 불완전한 상태로 남지 않게 만들 수 있습니다.
예외 제거가 운영 안정성으로 이어지는 지점
예외를 제거한다고 자동으로 안정해지는 것은 아닙니다. 하지만 expected는 실패를 명시화하여 다음을 개선합니다.
- 실패 경로가 타입에 드러나므로 코드리뷰에서 놓치기 어려움
- 에러 컨텍스트를 구조화해 로깅/메트릭에 일관되게 반영 가능
- 재시도/백오프/서킷브레이커 같은 정책을 상위 레이어에서 강제하기 쉬움
특히 네트워크/외부 API 호출은 “실패가 정상”인 영역이라, 실패를 값으로 다루는 설계가 잘 맞습니다. 재시도/백오프 패턴은 다른 글의 접근도 참고할 만합니다.
실전 팁: 팀 규칙으로 굳히면 좋은 것들
value() 사용 금지 또는 제한
예외 비활성(-fno-exceptions) 환경에서 value() 호출은 정책 위반이 될 수 있습니다. 팀 차원에서
if (!exp) return unexpected(exp.error());- 또는 체이닝(
and_then) 사용
으로 통일하면 좋습니다.
오류 타입에 std::error_code를 포함
POSIX errno, 소켓 오류, 파일 I/O 오류는 결국 OS 오류 코드로 수렴합니다. 문자열만 남기면 원인 추적이 어려워집니다. 구조화된 오류에 std::error_code를 넣어두면 운영에서 강력합니다.
“자원은 즉시 소유 타입으로”
int fd를 오래 들고 다니지 말고, 얻는 즉시 UniqueFd 같은 소유 타입으로 감싸세요. 이 한 줄이 누수/이중 close/예외적 경로 누락을 대폭 줄입니다.
경계 레이어에서만 로그
expected는 오류를 운반하는 도구입니다. 저수준 함수마다 로그를 찍기 시작하면 중복 로그가 쌓입니다. 보통은
- 저수준:
Error{code, sys, context}만 채워 반환 - 상위(핸들러/작업 단위): 한 번만 로깅 + 메트릭
처럼 레이어링하는 것이 좋습니다.
마무리
C++23 std::expected는 예외를 쓰지 않는 코드베이스에서 “실패를 값으로 다루는” 표준 해법을 제공합니다. 여기에 RAII를 결합하면
- 부분 초기화/중간 실패에도 자원이 자동 정리되고
- 오류 전파가 타입으로 강제되며
- 운영 환경에서 원인 추적 가능한 구조화 오류를 일관되게 전달
할 수 있습니다.
정리하면, 예외를 없애는 목적이 단순히 throw를 금지하는 데 그치면 오히려 코드가 장황해질 수 있습니다. 하지만 std::expected를 중심으로 오류 모델을 설계하고, 자원을 소유 타입으로 감싸며, 롤백 가드까지 더하면 예외 없이도 충분히 견고한 자원관리와 오류 처리를 구축할 수 있습니다.