- Published on
C++23 std - -expected로 예외 제거·누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
예외 기반 코드가 항상 나쁜 선택은 아니지만, 시스템/라이브러리 경계나 성능 민감 구간에서는 예외가 비용과 복잡도를 동시에 키우는 경우가 많습니다. 특히 다음 상황에서 문제가 자주 터집니다.
- 예외가 켜져 있는지(컴파일 옵션) 모호한 라이브러리 경계
- 생성자/팩토리에서 예외가 던져지며 부분 초기화 상태가 생김
new/malloc/핸들 같은 자원을 여러 단계로 획득하다가 중간에 실패해 정리 코드가 누락됨- 실패를 값으로 다루고 싶은데(로깅, 재시도, 폴백) 예외는 흐름을 너무 멀리 점프함
C++23의 std::expected는 “성공 값” 또는 “오류 값”을 명시적으로 표현하는 표준 타입입니다. 이 글에서는 std::expected로 예외를 제거하면서도, RAII와 함께 누수(메모리/파일/소켓/OS 핸들)를 막는 설계 패턴을 코드로 정리합니다.
이미 std::expected 자체의 기본 사용법이 필요하다면 아래 글도 함께 보면 좋습니다.
왜 std::expected가 “누수 방지”와 연결되는가
누수는 보통 “정리 코드가 실행되지 않음”에서 시작합니다. 예외를 쓰면 스택 언와인딩으로 소멸자가 호출되어 오히려 안전해 보이지만, 현실에서는 다음이 섞이며 누수/리소스 고갈이 생깁니다.
- C API/OS 핸들을 RAII로 감싸지 않고 맨몸으로 다룸
- 여러 자원 획득 단계 중간에 예외/조기 반환이 발생
- 예외를 잡아서 로그만 찍고 계속 진행(불완전 상태)
- 예외가 ABI 경계를 넘어가며
terminate로 끝나 정리 자체가 안 됨
std::expected는 “실패도 정상 흐름의 일부”로 만들기 때문에, 각 단계에서 실패를 값으로 전달하고, 자원은 RAII 객체로 소유하게끔 코드를 유도합니다. 결과적으로:
- 실패 경로가 코드에 드러나고
- 단계별로 안전한 정리가 이루어지며
- 라이브러리 경계에서의 예외/ABI 문제도 줄어듭니다.
핵심 규칙: 자원은 RAII, 실패는 expected
가장 중요한 규칙은 단순합니다.
- 파일/소켓/FD/핸들/메모리 등은 반드시 RAII 타입이 소유한다.
- “실패할 수 있는 연산”은
std::expected<T, E>를 반환한다. E는 로깅/재시도/사용자 메시지에 충분한 정보를 담는다.
이 조합이 누수 방지에 강력합니다. 실패해도 이미 생성된 RAII 객체는 스코프를 벗어나며 자동 정리되기 때문입니다.
오류 타입 설계: 문자열보다 구조화된 에러
std::expected의 E를 무조건 std::string으로 두면 편하지만, 운영 환경에서 디버깅/분기 처리가 약해집니다. 추천은 다음 중 하나입니다.
enum class코드 + 메시지std::error_code+ 컨텍스트- 프로젝트 전용
struct Error { ... }
예시:
#include <expected>
#include <string>
#include <system_error>
enum class Errc {
kOpenFailed,
kReadFailed,
kParseFailed,
};
struct Error {
Errc code;
std::string context; // 파일명, 파라미터 등
std::error_code sys; // errno 계열 또는 플랫폼 에러
};
template <class T>
using Result = std::expected<T, Error>;
이렇게 두면 호출자는 code로 분기하고, 로그에는 context와 sys를 함께 남길 수 있습니다.
예제 1: 파일 열기부터 파싱까지, 예외 없이 안전하게
여기서는 “파일 열기 → 읽기 → 파싱” 단계 중 어디서든 실패할 수 있다고 가정합니다. 포인트는 다음입니다.
- 파일 디스크립터는 RAII로 감싼다.
- 각 함수는
Result<T>를 반환한다. - 중간 실패 시에도 누수가 없다.
#include <expected>
#include <string>
#include <vector>
#include <cerrno>
#include <system_error>
#include <fcntl.h>
#include <unistd.h>
enum class Errc { kOpenFailed, kReadFailed, kParseFailed };
struct Error {
Errc code;
std::string context;
std::error_code sys;
};
template <class T>
using Result = std::expected<T, Error>;
class UniqueFd {
public:
explicit UniqueFd(int fd = -1) : fd_(fd) {}
~UniqueFd() { if (fd_ >= 0) ::close(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) {
if (fd_ >= 0) ::close(fd_);
fd_ = other.fd_;
other.fd_ = -1;
}
return *this;
}
int get() const { return fd_; }
explicit operator bool() const { return fd_ >= 0; }
private:
int fd_;
};
Result<UniqueFd> open_file(const std::string& path) {
int fd = ::open(path.c_str(), O_RDONLY);
if (fd < 0) {
return std::unexpected(Error{
.code = Errc::kOpenFailed,
.context = path,
.sys = std::error_code(errno, std::generic_category()),
});
}
return UniqueFd(fd);
}
Result<std::string> read_all(const UniqueFd& fd, const std::string& path) {
std::string out;
std::vector<char> buf(4096);
for (;;) {
ssize_t n = ::read(fd.get(), buf.data(), buf.size());
if (n < 0) {
return std::unexpected(Error{
.code = Errc::kReadFailed,
.context = path,
.sys = std::error_code(errno, std::generic_category()),
});
}
if (n == 0) break;
out.append(buf.data(), static_cast<size_t>(n));
}
return out;
}
struct Config {
int value;
};
Result<Config> parse_config(const std::string& text) {
// 예시: 아주 단순 파싱
if (text.empty()) {
return std::unexpected(Error{
.code = Errc::kParseFailed,
.context = "empty config",
.sys = {},
});
}
return Config{.value = 42};
}
Result<Config> load_config(const std::string& path) {
auto fd = open_file(path);
if (!fd) return std::unexpected(fd.error());
auto text = read_all(*fd, path);
if (!text) return std::unexpected(text.error());
auto cfg = parse_config(*text);
if (!cfg) return std::unexpected(cfg.error());
return *cfg;
}
여기서 중요한 점은 open_file이 성공하면 UniqueFd가 소유권을 갖고, 이후 어떤 단계에서 실패하더라도 스코프를 벗어나며 자동으로 close됩니다. 예외를 쓰지 않아도 “정리 누락”이 구조적으로 줄어듭니다.
예제 2: 다단계 자원 획득(누수의 진짜 온상)
누수는 보통 “A 획득 성공 → B 획득 실패 → A 정리 누락” 패턴에서 나옵니다. expected는 이를 다음처럼 정리하게 만듭니다.
- 각 획득 함수는
Result<Resource> - 상위 조합 함수는 실패 시
std::unexpected로 즉시 반환 - 이미 획득한 리소스는 RAII로 자동 정리
예: 파일 열고, 그 파일로부터 버퍼를 만들고, 추가 핸들을 여는 상황을 가정합니다.
#include <expected>
#include <memory>
#include <string>
struct Error { int code; std::string context; };
template <class T>
using Result = std::expected<T, Error>;
struct Handle {
int h;
};
class UniqueHandle {
public:
explicit UniqueHandle(Handle h) : h_(h), ok_(true) {}
~UniqueHandle() { if (ok_) release(); }
UniqueHandle(UniqueHandle&& other) noexcept : h_(other.h_), ok_(other.ok_) {
other.ok_ = false;
}
UniqueHandle& operator=(UniqueHandle&& other) noexcept {
if (this != &other) {
if (ok_) release();
h_ = other.h_;
ok_ = other.ok_;
other.ok_ = false;
}
return *this;
}
UniqueHandle(const UniqueHandle&) = delete;
UniqueHandle& operator=(const UniqueHandle&) = delete;
private:
void release() {
// 실제로는 OS/API 호출
// close(h_.h);
}
Handle h_{};
bool ok_{false};
};
Result<std::unique_ptr<char[]>> alloc_buffer(size_t n) {
auto p = std::make_unique<char[]>(n);
return p;
}
Result<UniqueHandle> open_handle(const std::string& name) {
if (name.empty()) {
return std::unexpected(Error{.code = 1, .context = "empty name"});
}
return UniqueHandle(Handle{.h = 123});
}
struct Session {
std::unique_ptr<char[]> buf;
UniqueHandle h;
};
Result<Session> make_session(const std::string& name) {
auto buf = alloc_buffer(1024);
if (!buf) return std::unexpected(buf.error());
auto h = open_handle(name);
if (!h) return std::unexpected(h.error());
return Session{.buf = std::move(*buf), .h = std::move(*h)};
}
open_handle에서 실패하면 buf는 스코프 종료로 자동 해제됩니다. “중간 실패 시 누수”를 사람이 기억해서 처리할 필요가 거의 없어집니다.
expected를 더 깔끔하게: 반복되는 보일러플레이트 줄이기
위 예제처럼 if (!x) return std::unexpected(x.error()); 패턴이 반복됩니다. C++에는 언어 차원의 try 연산자(러스트의 ?)가 없지만, 프로젝트 레벨에서 다음 중 하나로 정리합니다.
- 작은 헬퍼 함수로 플랫맵 스타일 구성
- 매크로(호불호 있지만 코드 양을 크게 줄임)
매크로로 조기 반환 패턴 만들기
#define TRY_ASSIGN(lhs, expr) \
do { \
auto _tmp = (expr); \
if (!_tmp) return std::unexpected(_tmp.error()); \
lhs = std::move(*_tmp); \
} while (0)
Result<int> f();
Result<int> g(int);
Result<int> h() {
int a;
TRY_ASSIGN(a, f());
int b;
TRY_ASSIGN(b, g(a));
return a + b;
}
주의할 점은 매크로가 디버깅과 스코프에 영향을 줄 수 있다는 것입니다. 팀 컨벤션에 맞춰 헬퍼 함수/매크로 중 선택하세요.
예외를 완전히 없앨 때 생기는 설계 변화
expected로 전환하면 “실패는 값”이 되므로 API 설계가 더 엄격해집니다.
- 생성자에서 실패를 표현하기 어렵기 때문에
static factory를 쓰는 경우가 늘어남 - 소멸자는 실패를 반환할 수 없으므로,
close()같은 명시적 연산이 필요할 수 있음 - 호출자는 반드시 성공/실패를 처리해야 하므로, 무시 비용이 증가(하지만 안전성도 증가)
권장 패턴:
- 실패 가능한 생성은
make_xxx()로 분리하고Result<T>반환 - 리소스 해제는 소멸자에서 하되, “해제 실패”가 의미 있다면
flush()/sync()같은 별도 API로 노출
std::expected를 쓸 때 흔한 함정 5가지
1) 오류에 너무 많은 문자열을 넣기
문자열만 쌓으면 기계적 분기(재시도, 폴백, 사용자 메시지 분리)가 어려워집니다. 최소한 enum class 코드와 컨텍스트를 분리하세요.
2) expected<T, bool> 같은 빈약한 에러 타입
bool은 디버깅 지옥을 부릅니다. 실패 이유가 최소 1개 이상이면 구조화된 타입을 쓰는 편이 장기적으로 이득입니다.
3) 성공 값이 무거운데 계속 복사하는 문제
expected는 값 타입이라 잘못 쓰면 복사가 늘 수 있습니다. 아래를 점검하세요.
T가 큰 경우std::move(*res)로 이동- 반환형 최적화(RVO)가 가능한 형태로 반환
- 필요하면
T를std::unique_ptr<T>로 두는 것도 고려
4) RAII 없이 expected만 도입
expected는 오류 전달을 개선할 뿐, 리소스 정리를 자동으로 해주지 않습니다. “자원은 RAII”가 함께 가야 누수 방지가 됩니다.
5) 경계에서 예외가 새어 나오는 혼종 상태
내부는 expected인데 일부 함수가 예외를 던지면 호출자는 다시 try를 써야 합니다. 경계 계층에서 정책을 정하세요.
- 앱 코어:
expected로 통일 - 외부 라이브러리 예외: 경계에서 잡아서
expected로 변환
예외를 expected로 변환하는 어댑터
외부 라이브러리가 예외를 던질 때, 경계에서 한 번만 변환하면 내부는 예외 없는 세계로 유지할 수 있습니다.
#include <expected>
#include <string>
#include <exception>
struct Error { std::string context; };
template <class T>
using Result = std::expected<T, Error>;
template <class F>
auto to_expected(F&& f) -> Result<decltype(f())> {
try {
return f();
} catch (const std::exception& e) {
return std::unexpected(Error{.context = e.what()});
} catch (...) {
return std::unexpected(Error{.context = "unknown exception"});
}
}
Result<int> parse_int_noexcept(const std::string& s) {
return to_expected([&] {
// 예: 어떤 라이브러리 함수가 예외를 던진다고 가정
return std::stoi(s);
});
}
이 방식은 “예외를 없애자”가 아니라 “예외를 한 지점에 격리하자”에 가깝고, 대규모 코드베이스 전환에 특히 유용합니다.
운영 관점: 실패를 값으로 만들면 관측 가능성이 좋아진다
expected는 실패가 함수 시그니처에 드러나므로, 로깅/메트릭/재시도 정책을 넣기가 쉬워집니다.
- 어떤 에러 코드가 가장 많이 나는지 집계
- 특정 코드만 재시도(예: 일시적 I/O)
- 폴백 경로 적용(예: 캐시 사용)
예를 들어 Errc::kReadFailed는 재시도할 수 있지만 Errc::kParseFailed는 즉시 실패시키는 식의 정책이 자연스럽습니다.
마무리
C++23 std::expected는 예외를 대체하는 “새 문법”이 아니라, 실패를 타입 시스템으로 끌어올리는 도구입니다. 여기에 RAII를 결합하면 다음 효과가 큽니다.
- 실패 경로가 명시적이라 코드 리뷰에서 놓치기 어려움
- 다단계 자원 획득에서 중간 실패가 나도 누수가 줄어듦
- 라이브러리/ABI 경계에서 예외 전파로 인한
terminate위험 감소
전환은 한 번에 다 하지 않아도 됩니다. 예외를 던지는 경계에서 expected로 변환하는 어댑터를 두고, 핵심 로직부터 “자원은 RAII, 실패는 expected” 규칙으로 정리해 나가면 안정성과 디버깅 효율이 체감될 정도로 좋아집니다.