- Published on
C++23 std - -expected로 예외 없이 안전한 자원해제
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 임베디드처럼 예외를 꺼두거나(-fno-exceptions) 예외 비용을 민감하게 보는 코드베이스에서는 “실패를 어떻게 전달할 것인가”가 설계의 핵심입니다. 전통적으로는 bool/에러코드/std::optional을 섞어 쓰다 보니, 호출자가 실패 원인을 잃거나(관측 불가), 중간 단계에서 return이 늘어나며(가독성 저하), 무엇보다 “실패 경로에서 자원이 정확히 해제되는가”가 불안해집니다.
C++23의 std::expected는 성공 값 또는 실패 값(에러) 을 명시적으로 담는 타입입니다. 핵심은 단순히 예외를 대체하는 게 아니라, 실패가 타입 시스템에 들어오면서 자원 획득/해제 흐름을 더 예측 가능하게 만든다는 점입니다. 이 글에서는 std::expected를 RAII와 결합해 “예외 없이도 안전한 자원 해제”를 구현하는 실전 패턴을 정리합니다.
문맥상 함께 보면 좋은 글: C++23 std - -expected로 예외 제거·누수 막기
왜 std::expected가 자원 해제에 유리한가
예외 기반 모델에서는 자원 해제가 보통 RAII로 해결됩니다. 하지만 예외를 끄면, 실패는 보통 if (!ok) return err; 같은 조기 반환으로 전파됩니다. 이때도 RAII를 쓰면 안전하지만, 실제 현장에서는 다음과 같은 함정이 자주 생깁니다.
- C API 핸들(
FILE*, 소켓 FD, DB 핸들 등)을 원시 포인터/정수로 들고 다니다가 실패 분기에서close/free를 빼먹음 - 여러 자원을 단계적으로 획득하는 초기화 코드에서, 중간 실패 시 “어디까지 열렸는지” 추적이 어려움
std::optional로 실패를 표현하면 실패 원인이 사라져, 호출자가 복구/로깅을 제대로 못함
std::expected는 실패를 값으로 담아 전파하므로, 함수 시그니처가 다음을 강제합니다.
- 호출자는 성공/실패를 처리해야 함(무시하기 어려움)
- 실패 원인을 구조적으로 전달할 수 있음
- RAII 래퍼를 반환 값으로 사용해도, 실패 시에는 RAII 객체가 생성되지 않거나(구성 실패), 생성된 객체는 스코프 종료로 자동 해제됨
즉, 실패 전파는 expected가 담당하고, 자원 해제는 RAII가 담당하는 역할 분리가 깔끔해집니다.
기본 형태: expected<T, E>와 에러 타입 설계
std::expected는 대략 다음 형태입니다.
- 성공:
T - 실패:
E
에러 타입 E는 std::error_code를 써도 되고, 도메인 전용 구조체를 써도 됩니다. 실무에서는 “로그에 바로 쓸 수 있는 메시지”와 “기계적으로 분기 가능한 코드”를 같이 넣는 편이 유용합니다.
#include <expected>
#include <string>
enum class Errc {
kOpenFailed,
kReadFailed,
kInvalidFormat,
};
struct Error {
Errc code;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
이제부터는 Result<T>를 반환하는 함수는 “성공 값 또는 실패 원인”을 반드시 제공합니다.
핵심: C 자원 핸들을 RAII로 감싸고 expected로 생성한다
예외를 안 쓸 때 가장 흔한 누수 지점은 C API 핸들입니다. 대표적으로 파일 핸들(FILE*)을 RAII로 감싸고, “열기”는 expected로 반환하는 패턴을 보겠습니다.
#include <cstdio>
#include <expected>
#include <string>
struct Error {
int code;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
class File {
public:
explicit File(std::FILE* f) : f_(f) {}
File(const File&) = delete;
File& operator=(const File&) = delete;
File(File&& other) noexcept : f_(other.f_) { other.f_ = nullptr; }
File& operator=(File&& other) noexcept {
if (this != &other) {
close();
f_ = other.f_;
other.f_ = nullptr;
}
return *this;
}
~File() { close(); }
std::FILE* get() const { return f_; }
private:
void close() {
if (f_) {
std::fclose(f_);
f_ = nullptr;
}
}
std::FILE* f_ = nullptr;
};
Result<File> open_file(const std::string& path, const char* mode) {
std::FILE* f = std::fopen(path.c_str(), mode);
if (!f) {
return std::unexpected(Error{1, "fopen failed: " + path});
}
return File{f};
}
포인트는 다음과 같습니다.
File은 소유권을 가지는 RAII 타입이므로 스코프 종료 시 무조건fcloseopen_file은 실패 시std::unexpected(Error{...})로 실패를 값으로 반환- 호출자는
expected를 확인해야 하고, 성공 시에만File이 존재
이 패턴을 쓰면 “파일을 열었는데 중간에 다른 단계에서 실패해서 조기 반환” 같은 코드에서도 누수가 사라집니다.
여러 자원 단계적 획득: 중간 실패에도 자동 정리되는 초기화 흐름
실제 초기화는 파일 하나로 끝나지 않습니다. 예를 들어:
- 설정 파일 열기
- 일부 읽기
- 임시 버퍼 할당
- 다른 리소스(예: 소켓) 생성
여기서 3번에서 실패하면 1번 파일은 닫혀야 합니다. RAII를 쓰면 스코프를 벗어날 때 자동으로 닫히지만, expected와 결합하면 “실패 전파”도 깔끔해집니다.
#include <vector>
#include <expected>
#include <string>
#include <cstdio>
struct Error {
int code;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
class File {
public:
explicit File(std::FILE* f) : f_(f) {}
File(File&& o) noexcept : f_(o.f_) { o.f_ = nullptr; }
File& operator=(File&& o) noexcept {
if (this != &o) { if (f_) std::fclose(f_); f_ = o.f_; o.f_ = nullptr; }
return *this;
}
File(const File&) = delete;
File& operator=(const File&) = delete;
~File() { if (f_) std::fclose(f_); }
std::FILE* get() const { return f_; }
private:
std::FILE* f_;
};
Result<File> open_file(const std::string& path) {
if (auto* f = std::fopen(path.c_str(), "rb")) return File{f};
return std::unexpected(Error{1, "open failed"});
}
Result<std::vector<unsigned char>> read_prefix(std::FILE* f, std::size_t n) {
std::vector<unsigned char> buf(n);
if (std::fread(buf.data(), 1, n, f) != n) {
return std::unexpected(Error{2, "read failed"});
}
return buf;
}
Result<int> init_from_config(const std::string& path) {
auto file = open_file(path);
if (!file) return std::unexpected(file.error());
auto prefix = read_prefix(file->get(), 16);
if (!prefix) return std::unexpected(prefix.error());
// 여기서 추가 자원 획득/검증이 이어져도,
// 실패로 조기 반환하면 file은 자동으로 닫힌다.
return 0;
}
여기서 중요한 점은 init_from_config가 몇 번 조기 반환을 하더라도 File RAII 객체는 스코프를 빠져나가며 정리된다는 사실입니다. expected는 “왜 실패했는지”를 잃지 않게 해주고, RAII는 “실패했을 때도 정리되는지”를 보장합니다.
expected를 더 깔끔하게 쓰는 팁: transform/and_then 스타일
표준 std::expected는 단순히 if (!r) return ...;로 써도 충분하지만, 단계가 많아지면 보일러플레이트가 늘어납니다. 구현체에 따라 and_then, transform, or_else를 제공하며, C++23 표준에도 포함된 멤버들이 있습니다(사용 중인 표준 라이브러리 버전에 따라 지원 상태가 다를 수 있음).
지원되는 환경이라면 다음처럼 “성공 경로를 체이닝”할 수 있습니다.
auto result = open_file(path)
.and_then([](File f) {
return read_prefix(f.get(), 16)
.transform([f = std::move(f)](auto prefix) mutable {
// f는 여기 스코프에서 살아있고, 끝나면 자동 close
return static_cast<int>(prefix.size());
});
});
주의할 점:
- 체이닝 내부에서 RAII 객체를 캡처할 때는 이동 캡처(
f = std::move(f))를 명확히 해야 합니다. - 체이닝이 과해지면 디버깅이 어려울 수 있어, 팀 컨벤션에 맞춰 “2~3단계까지만 체이닝” 같은 규칙을 두는 게 좋습니다.
흔한 실수 1: expected<T, E>에 “부분 생성된 자원”을 넣지 말기
가끔 다음 같은 설계를 봅니다.
- 성공 타입
T자체가 “부분적으로만 초기화된 상태”를 허용 - 실패 시에도
T를 반환하고 내부 플래그로 실패를 표시
이러면 expected의 장점이 사라집니다. expected는 성공이면 완전한 T, 실패면 완전한 E 라는 강한 경계를 만드는 것이 좋습니다.
자원 관점에서도 마찬가지입니다.
- 성공: 소유권이 명확한 RAII 핸들
- 실패: 핸들이 존재하지 않음
이렇게 해야 “실패했는데 핸들이 반쯤 열려 있음” 같은 상태가 사라집니다.
흔한 실수 2: 에러를 string 하나로만 두기
에러 메시지 문자열만 두면 호출자 입장에서 분기가 어렵습니다. 예를 들어 파일이 없을 때는 새로 생성하고, 권한이 없을 때는 관리자 권한을 요구하는 식의 복구 로직이 필요할 수 있습니다.
최소한 다음 둘 중 하나는 넣는 편이 좋습니다.
- 도메인 에러 코드(
enum class) - OS 에러 코드(
errno기반 값, 또는std::error_code)
예시:
#include <system_error>
#include <expected>
#include <string>
struct Error {
std::error_code ec;
std::string context;
};
template <class T>
using Result = std::expected<T, Error>;
이렇게 해두면 로깅은 context로, 분기는 ec로 처리할 수 있습니다.
std::unique_ptr 커스텀 deleter와 expected 조합
C API가 create/destroy 형태로 핸들을 주는 경우, RAII를 직접 클래스로 만들지 않고 std::unique_ptr + 커스텀 deleter로도 깔끔하게 처리할 수 있습니다.
#include <expected>
#include <memory>
#include <string>
struct Error {
int code;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
struct CHandle;
extern "C" {
CHandle* ch_create();
void ch_destroy(CHandle*);
}
struct HandleDeleter {
void operator()(CHandle* p) const noexcept {
if (p) ch_destroy(p);
}
};
using Handle = std::unique_ptr<CHandle, HandleDeleter>;
Result<Handle> make_handle() {
CHandle* raw = ch_create();
if (!raw) return std::unexpected(Error{1, "ch_create failed"});
return Handle{raw};
}
이 패턴의 장점:
- 이동만 가능하고 복사 불가라 소유권이 명확
- 실패 시에는
Handle이 생성되지 않음 - 성공 후에는 어떤 경로로
return하든 자동destroy
예외 없는 환경에서의 운영 팁: 로깅과 관측 가능성
예외를 쓰면 스택 언와인딩과 함께 예외 타입/메시지가 전파되지만, 예외를 끄면 실패 지점의 맥락을 직접 담아야 합니다. expected의 에러 타입에 다음을 포함하면 운영에서 큰 차이가 납니다.
- 어떤 API 호출이 실패했는지(
context) - 입력 파라미터 일부(민감정보 제외)
- OS 에러 코드 또는 도메인 에러 코드
그리고 최상위 경계(예: 요청 핸들러, 메인 루프)에서 “반드시 한 번 로깅”하는 규칙을 두면, 중간 함수들은 에러를 생성만 하고 로깅은 중복되지 않게 할 수 있습니다.
운영 관점에서 자원 누수는 디스크/FD 고갈로도 나타납니다. 리눅스에서 “디스크 100%인데 큰 파일이 안 보이는” 상황처럼, 열린 파일이 삭제되어도 공간을 잡아먹는 케이스는 관측이 중요합니다. 이런 류의 장애 분석에는 Linux 디스크 100%인데 큰 파일이 안 보일 때 lsof로 추적 같은 접근이 도움이 됩니다.
정리: expected는 실패 전파, RAII는 해제 보장
std::expected만으로 자원 해제가 자동이 되지는 않습니다. 하지만 다음 조합을 지키면 예외 없이도 누수 가능성을 크게 낮출 수 있습니다.
- 자원은 무조건 RAII 타입으로 소유(
File,unique_ptr+ deleter, FD wrapper 등) - 자원 획득 함수는
expected<RAII, Error>로 반환 - 실패는
std::unexpected(error)로만 표현하고, “부분 성공” 상태를 만들지 않기 - 에러 타입은 분기 가능한 코드와 로그용 컨텍스트를 함께 담기
결국 목표는 “실패 경로가 많아져도 자원이 새지 않는 코드”입니다. expected는 그 실패 경로를 타입으로 드러내고, RAII는 그 모든 경로에서 해제를 강제합니다. 예외를 쓰지 않는 코드베이스일수록 이 조합의 효과가 더 크게 체감됩니다.