- Published on
C++23 std - -expected로 예외 없이 자원누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 시스템 프로그래밍에서 "예외를 쓰지 않는다"는 선택은 흔합니다. ABI 경계(플러그인, C API), 성능/코드사이즈, 예외 비활성화 빌드(-fno-exceptions) 같은 이유로 오류를 반환값으로 다루곤 하죠.
문제는 그 순간부터 자원 관리가 급격히 어려워진다는 점입니다. 파일 디스크립터, FILE*, 소켓, malloc 버퍼, DB 핸들처럼 "획득 후 반드시 해제"가 필요한 자원은, 오류 분기마다 close/free를 직접 호출하다가 한 군데만 빠져도 누수가 납니다.
C++의 정답은 여전히 RAII지만, RAII 객체를 만드는 과정 자체가 실패할 수 있다는 점이 곤란합니다. C++23의 std::expected는 이 지점을 깔끔하게 메워줍니다. 이 글에서는 std::expected로 실패 가능한 자원 획득을 표현하고, 예외 없이도 누수 없는 코드를 만드는 패턴을 실전 관점에서 정리합니다.
관련해서 "실패를 값으로 다룬다"는 철학은 Rust에서 더 강하게 드러납니다. 빌림/소유권이 다른 언어지만, 오류 전파를 값으로 모델링한다는 관점은 참고할 만합니다. 필요하면 Rust E0502/E0499 빌림 충돌 6가지 패턴도 함께 읽어보면 좋습니다.
왜 std::expected가 자원 누수에 강한가
핵심은 두 가지입니다.
- 실패를 "반환"하므로 제어 흐름이 명시적이다
- 성공 경로에서 RAII 객체를 반환해 소유권을 이동시킨다
즉, "자원 획득" 함수는 성공 시 소유권을 가진 RAII 래퍼를 반환하고, 실패 시 에러를 반환합니다. 호출자는 if (!r) 같은 분기로 즉시 처리하거나, 상위로 그대로 전파합니다. 이때 RAII 객체는 스코프를 벗어나면 자동 해제되므로, 중간에 어떤 분기에서 빠져나가도 누수 가능성이 줄어듭니다.
std::expected는 대략 다음 형태입니다.
- 성공:
std::expected<T, E>안에T - 실패:
std::expected<T, E>안에E
여기서 T를 "자원을 소유하는 타입"으로 만들면 됩니다.
준비: 에러 타입 설계
에러를 단순 문자열로 할 수도 있지만, 운영에서 디버깅 가능한 형태가 좋습니다.
- 에러 코드(열거형)
- 메시지(옵션)
errno같은 OS 에러(옵션)
#include <expected>
#include <string>
#include <cerrno>
enum class Errc {
OpenFailed,
ReadFailed,
WriteFailed,
InvalidInput,
};
struct Error {
Errc code;
int sys_errno = 0;
std::string message;
};
template <class T>
using Result = std::expected<T, Error>;
이렇게 해두면 모든 함수 시그니처가 Result<T>로 통일됩니다.
실패 가능한 자원 획득을 RAII로 감싸기
예제로 POSIX 파일 디스크립터를 다뤄보겠습니다. open이 실패할 수 있으니 RAII 래퍼를 만들고, 생성은 static 팩토리로 제공합니다.
주의: 본문에 부등호 문자가 노출되면 MDX 빌드가 깨질 수 있으니, 제네릭/템플릿 표기 등은 코드 블록 안에서만 사용합니다.
#include <expected>
#include <string>
#include <unistd.h>
#include <fcntl.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_; }
bool valid() const { return fd_ >= 0; }
void reset() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
private:
int fd_ = -1;
};
Result<UniqueFd> open_readonly(const std::string& path) {
int fd = ::open(path.c_str(), O_RDONLY);
if (fd < 0) {
return std::unexpected(Error{
.code = Errc::OpenFailed,
.sys_errno = errno,
.message = "open failed: " + path,
});
}
return UniqueFd{fd};
}
포인트는 다음입니다.
UniqueFd는 소유권을 가진 RAII 타입- 실패는
std::unexpected(Error{...}) - 성공은
UniqueFd반환(이동)
이제 호출자가 어떤 경로로 빠져나가든 파일은 자동으로 닫힙니다.
expected로 단계별 작업 조합하기
자원 획득 후 읽기 같은 후속 작업도 실패할 수 있습니다. 흔한 실수는 실패 시점마다 수동으로 close를 호출하는 것인데, RAII가 있으면 그럴 필요가 없습니다.
#include <vector>
#include <sys/stat.h>
Result<std::vector<char>> read_all(const std::string& path) {
auto fd_res = open_readonly(path);
if (!fd_res) return std::unexpected(fd_res.error());
UniqueFd fd = std::move(*fd_res);
struct stat st {};
if (::fstat(fd.get(), &st) != 0) {
return std::unexpected(Error{
.code = Errc::ReadFailed,
.sys_errno = errno,
.message = "fstat failed",
});
}
std::vector<char> buf(static_cast<size_t>(st.st_size));
ssize_t n = ::read(fd.get(), buf.data(), buf.size());
if (n < 0) {
return std::unexpected(Error{
.code = Errc::ReadFailed,
.sys_errno = errno,
.message = "read failed",
});
}
buf.resize(static_cast<size_t>(n));
return buf;
}
여기서 fstat나 read가 실패해도 fd는 스코프 종료 시 자동으로 닫힙니다. 즉, "예외가 없어서" 오히려 더 명시적인 제어 흐름을 갖고도 누수는 방지됩니다.
조합을 더 깔끔하게: and_then, transform
C++23 std::expected에는 조합용 멤버 함수가 있습니다.
transform: 성공 값T를U로 변환and_then: 성공 값으로 다음expected를 이어붙임or_else: 실패를 다른 실패로 매핑하거나 로깅
표준 라이브러리 구현/버전에 따라 사용 가능 여부가 다를 수 있으니, 컴파일러/라이브러리 버전을 확인하세요.
아래는 and_then 스타일로 "열기 → 읽기"를 분리해 조합하는 예입니다.
Result<std::vector<char>> read_all2(const std::string& path) {
return open_readonly(path).and_then([](UniqueFd fd) -> Result<std::vector<char>> {
std::vector<char> buf(4096);
ssize_t n = ::read(fd.get(), buf.data(), buf.size());
if (n < 0) {
return std::unexpected(Error{
.code = Errc::ReadFailed,
.sys_errno = errno,
.message = "read failed",
});
}
buf.resize(static_cast<size_t>(n));
return buf;
});
}
람다 인자로 UniqueFd가 값으로 들어오므로(이동), 람다 블록을 벗어날 때 자동으로 닫힙니다. 중간에 실패 반환을 해도 동일합니다.
실전에서 자주 터지는 누수 포인트와 expected 패턴
1) 부분 초기화 후 실패
예: 소켓 생성 후 옵션 설정 중 실패하면 소켓을 닫아야 합니다. RAII로 소켓을 감싼 뒤, 각 단계는 Result<void>로 반환하면 됩니다.
class UniqueSocket {
public:
explicit UniqueSocket(int fd) : fd_(fd) {}
UniqueSocket(UniqueSocket&& o) noexcept : fd_(o.fd_) { o.fd_ = -1; }
UniqueSocket& operator=(UniqueSocket&& o) noexcept {
if (this != &o) { reset(); fd_ = o.fd_; o.fd_ = -1; }
return *this;
}
~UniqueSocket() { reset(); }
int get() const { return fd_; }
void reset() { if (fd_ >= 0) { ::close(fd_); fd_ = -1; } }
private:
int fd_ = -1;
};
Result<void> set_nonblocking(int fd) {
int flags = ::fcntl(fd, F_GETFL, 0);
if (flags < 0) {
return std::unexpected(Error{.code = Errc::InvalidInput, .sys_errno = errno, .message = "F_GETFL failed"});
}
if (::fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
return std::unexpected(Error{.code = Errc::InvalidInput, .sys_errno = errno, .message = "F_SETFL failed"});
}
return {};
}
Result<UniqueSocket> make_socket_nonblocking() {
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
return std::unexpected(Error{.code = Errc::OpenFailed, .sys_errno = errno, .message = "socket failed"});
}
UniqueSocket s(fd);
auto r = set_nonblocking(s.get());
if (!r) return std::unexpected(r.error());
return s; // 이동
}
부분 초기화든 중간 실패든 소켓 누수는 원천 차단됩니다.
2) C API와 혼합될 때
C API는 보통 nullptr/음수로 실패를 표현합니다. 이때 "바로 RAII로 감싸서" 반환을 expected로 올리면 됩니다.
예: FILE*를 unique_ptr에 커스텀 deleter로 감싸기.
#include <cstdio>
#include <memory>
struct FileCloser {
void operator()(std::FILE* f) const {
if (f) std::fclose(f);
}
};
using UniqueFile = std::unique_ptr<std::FILE, FileCloser>;
Result<UniqueFile> fopen_read(const std::string& path) {
std::FILE* f = std::fopen(path.c_str(), "rb");
if (!f) {
return std::unexpected(Error{
.code = Errc::OpenFailed,
.sys_errno = errno,
.message = "fopen failed: " + path,
});
}
return UniqueFile(f);
}
이 패턴은 라이브러리 핸들(sqlite3*, EVP_PKEY*, CURL*)에도 그대로 적용됩니다.
팀 규칙으로 굳히기: 함수 경계에서의 원칙
std::expected를 도입해도, 팀 코드가 섞이면 누수/중복 해제가 다시 생깁니다. 다음 원칙을 권합니다.
- "자원 획득" 함수는 RAII 타입을 반환한다
- 실패는
std::unexpected(Error)로만 표현한다 - RAII 타입은 복사 금지, 이동만 허용한다
- raw 핸들은 가능한 한 외부로 노출하지 않는다(
get()은 최후 수단) - 상위로 전파할 때는 에러 컨텍스트를 덧붙인다
컨텍스트 덧붙이기는 or_else로도 가능하고, 단순히 에러 메시지를 감싸는 헬퍼를 둬도 됩니다.
Error with_context(Error e, const std::string& ctx) {
if (!e.message.empty()) e.message = ctx + ": " + e.message;
else e.message = ctx;
return e;
}
template <class T>
Result<T> add_context(Result<T> r, const std::string& ctx) {
if (!r) return std::unexpected(with_context(r.error(), ctx));
return r;
}
Result<std::vector<char>> read_all3(const std::string& path) {
return add_context(read_all(path), "read_all3(" + path + ")");
}
운영에서 장애를 줄이는 건 "원인 파악 속도"인 경우가 많습니다. 이 컨텍스트 누적은 체감이 큽니다. 장애 분석/진단 관점은 K8s CrashLoopBackOff 진단 - OOMKilled·Probe 같은 글의 접근과도 통합니다. 현상보다 "경계에서 어떤 정보가 사라지는가"를 관리하는 게 핵심입니다.
std::expected를 써도 RAII가 깨지는 경우
expected가 만능은 아닙니다. 아래 상황은 여전히 조심해야 합니다.
- RAII 객체 내부에서 또 다른 자원을 다루는데 이동/소멸 규칙이 틀린 경우(이중
close) get()으로 raw 핸들을 꺼내 외부에 넘긴 뒤, 외부가 소유권을 가져가 버리는 경우- 콜백 기반 API에 raw 포인터를 넘기고, 콜백이 호출되기 전 스코프가 끝나는 경우(수명 문제)
이런 경우엔 소유권 이전 규칙을 명확히 하거나, "소유권 이전"을 표현하는 별도 타입(예: release() 제공)을 설계해야 합니다.
빌드/도구체인 체크: C++23 expected 사용 조건
- 컴파일러: GCC, Clang, MSVC의 C++23 모드에서 지원이 진행 중이며, 실제로는 표준 라이브러리 구현 버전이 더 중요합니다.
- 설정:
-std=c++23또는 MSVC/std:c++latest - 헤더:
#include <expected>
만약 표준 라이브러리에서 and_then/transform 지원이 불완전하다면, 우선은 if (!r) return std::unexpected(r.error()); 스타일로 시작해도 충분히 가치가 있습니다.
CI에서 도구체인/캐시가 꼬이면 표준 라이브러리 버전이 달라져 재현이 어려울 수 있습니다. 그런 경우엔 GitHub Actions 캐시가 안 먹을 때 - 키 전략·디버깅처럼 "환경을 고정"하는 접근이 도움이 됩니다.
정리: 예외 없는 코드에서 누수를 막는 가장 현실적인 방법
- 예외를 쓰지 않는다면, 실패는 반드시 반환값으로 전파해야 합니다.
- 반환값 기반 오류 처리에서 자원 누수를 막는 핵심은 "성공 값으로 RAII 소유권을 반환"하는 것입니다.
- C++23
std::expected는 성공/실패를 하나의 타입으로 묶어, 분기와 전파를 표준화합니다. - 결과적으로 코드가 길어지기보다, 오히려 "실패 경로"가 정리되고 리뷰 포인트가 명확해집니다.
다음 단계로는
- 프로젝트 공통
Error정책(코드, 컨텍스트, 로깅) - 자원별 RAII 래퍼 템플릿화(예:
UniqueHandlewith deleter) expected조합 함수(and_then,transform,or_else) 기반 파이프라인
까지 확장하면, 예외 없이도 충분히 견고한 시스템 코드를 만들 수 있습니다.