- Published on
C++23 std - -expected로 에러·자원 누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 CLI 같은 실전 C++ 코드에서 “에러 처리”는 곧 “자원 정리”와 동의어입니다. 파일 핸들, 소켓, DB 커넥션, 임시 버퍼 등은 성공 경로보다 실패 경로에서 더 자주 누수됩니다. C++23의 std::expected는 실패를 값으로 다루게 해주고, RAII와 결합하면 에러 경로가 늘어나도 자원 정리가 자동으로 유지되는 구조를 만들 수 있습니다.
이 글에서는 std::expected의 핵심 사용법, 에러 타입 설계, 함수 합성(체이닝) 패턴, 그리고 자원 누수를 막는 실전 코딩 스타일을 정리합니다. 운영 환경에서의 “재시도/백오프” 같은 정책은 애플리케이션 레벨에서 별도로 다루는데, 이런 관점은 예를 들어 OpenAI API 429 Rate Limit 재시도·큐잉 설계 같은 글에서 다룬 방식과도 연결됩니다.
std::expected가 해결하는 문제
전통적인 에러 처리 방식은 크게 세 가지입니다.
- 예외(
throw) 기반 - 에러 코드 반환(
int,errno) std::optional로 “있음/없음”만 표현
예외는 전파가 편하지만, 다음 문제가 있습니다.
- 예외 안전성(특히 강한 보장)을 지키기 어렵고, 코드 리뷰 비용이 큼
- 예외를 금지하는 코드베이스(게임, 임베디드, 일부 고성능 서버)도 많음
- 실패가 “예상된 상황”인 경우에도 예외가 과한 메커니즘이 될 수 있음
에러 코드는 반대로 너무 가볍습니다.
- 실패 원인/맥락이 소실되기 쉬움
- 호출자가 “반드시 체크”하도록 강제하기 어려움
- 에러 처리 분기가 산재하며 조기 반환이 많아짐
std::expected는 “성공 값 T 또는 실패 값 E”를 타입으로 표현합니다. 호출자는 성공/실패를 반드시 분기해야 하고, 실패 값에 원하는 만큼의 정보를 담을 수 있습니다.
요약:
expected는 “예상 가능한 실패를 값으로 모델링”하고, 그 결과 에러 경로에서도 RAII가 안정적으로 작동하도록 코드를 구조화하는 데 유리합니다.
기본 사용법: 성공 값과 에러 값
아래 예시는 파일을 열고 내용을 읽는 작업을 std::expected로 표현합니다.
#include <expected>
#include <fstream>
#include <string>
#include <system_error>
using ReadResult = std::expected<std::string, std::error_code>;
ReadResult read_all_text(const std::string& path) {
std::ifstream in(path, std::ios::binary);
if (!in) {
return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
}
std::string data;
in.seekg(0, std::ios::end);
data.resize(static_cast<size_t>(in.tellg()));
in.seekg(0, std::ios::beg);
if (!in.read(data.data(), static_cast<std::streamsize>(data.size()))) {
return std::unexpected(std::make_error_code(std::errc::io_error));
}
return data;
}
int main() {
auto r = read_all_text("/tmp/a.txt");
if (!r) {
// r.error()로 실패 원인 접근
return 1;
}
// r.value()로 성공 값 접근
auto text = r.value();
return 0;
}
포인트는 두 가지입니다.
- 실패 시
std::unexpected(e)를 반환한다. - 호출자는
if (!r)또는r.has_value()로 분기한다.
이 구조는 “실패 가능”이 함수 시그니처에 드러나기 때문에, 에러를 무시하기가 더 어려워집니다.
자원 누수 관점에서 expected가 좋은 이유
자원 누수는 보통 다음 패턴에서 발생합니다.
- 여러 단계 작업 중간에 실패 → 중간까지 획득한 자원을 정리하지 못함
- 조기 반환이 많은 함수에서 실수로
close/free를 빼먹음 - 에러 처리 코드가 성공 코드와 분리되지 않고 뒤엉킴
expected는 “실패를 값으로 리턴”하니 조기 반환이 많아질 수 있는데, 그럼 오히려 누수가 늘지 않을까요?
핵심은 RAII입니다. 자원을 “스코프에 묶인 객체”로 만들면, 조기 반환이 많아져도 파괴자가 자동으로 정리합니다. expected는 예외가 없어도 조기 반환을 자연스럽게 만들고, RAII로 누수 없는 코드를 유지하기 좋습니다.
나쁜 예: 수동 정리로 인한 누수
int do_work(const char* path) {
FILE* f = std::fopen(path, "rb");
if (!f) return -1;
void* buf = std::malloc(1024);
if (!buf) return -2; // 여기서 f 누수
// ...
std::free(buf);
std::fclose(f);
return 0;
}
좋은 예: RAII + expected
#include <expected>
#include <cstdio>
#include <memory>
#include <system_error>
struct FileCloser {
void operator()(FILE* f) const noexcept {
if (f) std::fclose(f);
}
};
using FilePtr = std::unique_ptr<FILE, FileCloser>;
using OpenFileResult = std::expected<FilePtr, std::error_code>;
OpenFileResult open_file(const char* path) {
FILE* f = std::fopen(path, "rb");
if (!f) {
return std::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));
}
return FilePtr{f};
}
using Buffer = std::unique_ptr<unsigned char[]>;
using BufferResult = std::expected<Buffer, std::error_code>;
BufferResult alloc_buffer(size_t n) {
try {
return Buffer{new unsigned char[n]};
} catch (...) {
return std::unexpected(std::make_error_code(std::errc::not_enough_memory));
}
}
std::expected<int, std::error_code> do_work(const char* path) {
auto f = open_file(path);
if (!f) return std::unexpected(f.error());
auto buf = alloc_buffer(1024);
if (!buf) return std::unexpected(buf.error());
// 여기서 어떤 조기 반환이 생겨도 f.value(), buf.value()는 RAII로 정리됨
return 0;
}
여기서 “예외를 쓰지 않는다”가 목표라면, new가 예외를 던질 수 있다는 점이 걸립니다. 이런 경우 std::nothrow를 쓰거나, 아예 할당 실패가 예외가 아닌 allocator를 쓰는 방식으로 더 엄격하게 갈 수 있습니다. 중요한 건 expected가 “실패를 표현하는 표준 컨테이너”라는 점입니다.
에러 타입 설계: std::error_code vs 커스텀 에러
expected의 E는 어떤 타입이든 됩니다. 실무에서 자주 쓰는 선택지는 다음입니다.
std::error_code: OS/표준 라이브러리 친화적, 범용enum class기반 커스텀 에러: 도메인 에러를 명확히- 구조체 에러(코드 + 메시지 + 컨텍스트): 관측 가능성 강화
예를 들어 “파싱 실패 위치” 같은 컨텍스트가 필요하면 구조체가 유리합니다.
#include <expected>
#include <string>
enum class ParseErrc {
invalid_format,
out_of_range,
};
struct ParseError {
ParseErrc code;
size_t position;
std::string message;
};
using ParseResult = std::expected<int, ParseError>;
ParseResult parse_int(std::string_view s) {
if (s.empty()) {
return std::unexpected(ParseError{ParseErrc::invalid_format, 0, "empty"});
}
// ...
return 42;
}
운영에서 트러블슈팅을 해보면 “원인 분류”와 “재현 가능한 힌트”가 중요합니다. 쿠버네티스에서 장애를 디버깅할 때도 로그/원인 분류가 핵심이듯, 애플리케이션 에러도 구조화된 에러 타입이 큰 도움이 됩니다. 관련해서는 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅 같은 글의 관점과도 닮아 있습니다.
체이닝(합성) 패턴: 실패를 자연스럽게 전파하기
expected를 쓰다 보면 “A 성공하면 B, B 성공하면 C” 같은 파이프라인이 많아집니다. C++23 표준 expected는 and_then, transform, or_else, transform_error 같은 멤버를 제공합니다(구현/컴파일러에 따라 제공 범위는 확인 필요).
and_then으로 단계 연결
#include <expected>
#include <string>
struct Err { int code; };
auto step1() -> std::expected<int, Err>;
auto step2(int) -> std::expected<std::string, Err>;
auto step3(const std::string&) -> std::expected<double, Err>;
std::expected<double, Err> pipeline() {
return step1()
.and_then([](int v) { return step2(v); })
.and_then([](const std::string& s) { return step3(s); });
}
이 방식의 장점:
- 중간 실패 시 자동으로 다음 단계가 실행되지 않음
- 에러 전파가 “보일러플레이트
if (!r) return unexpected(...)”보다 줄어듦 - 단계가 함수로 분리되어 테스트가 쉬움
에러 매핑: transform_error
라이브러리 레벨 에러를 서비스 레벨 에러로 “의미 있게 변환”하는 게 중요합니다.
#include <expected>
#include <string>
enum class ServiceErrc { dependency_failed, bad_request };
struct ServiceError {
ServiceErrc code;
std::string message;
};
std::expected<int, int> lib_call(); // 에러를 int로 돌려주는 레거시 API
std::expected<int, ServiceError> service_call() {
return lib_call().transform_error([](int ec) {
return ServiceError{ServiceErrc::dependency_failed, "lib error=" + std::to_string(ec)};
});
}
이 패턴은 “에러를 숨기지 않고, 계층 경계를 넘을 때 의미를 재구성”하게 해줍니다. 장애 대응에서 재시도 정책을 세울 때도, 에러를 분류해야(일시적/영구적) 올바른 재시도를 할 수 있습니다. 이런 분류 기반 설계는 OpenAI Responses API 429 쿼터·레이트리밋 대응 같은 주제와도 맞닿아 있습니다.
expected와 RAII로 “자원 획득 단계”를 쪼개기
자원 누수를 줄이려면, 큰 함수에서 한 번에 많은 자원을 잡지 말고 “획득 단계”를 작게 쪼개 expected로 실패를 반환하게 만드는 게 좋습니다.
예시로 “설정 파일 읽기 → 소켓 연결 → 핸드셰이크”를 생각해보면:
- 각 단계는
expected<Resource, Error>를 반환 - 상위 단계는
and_then또는 명시적 분기로 연결 - 자원은 모두 RAII 타입으로 감쌈
#include <expected>
#include <string>
#include <system_error>
struct Config {
std::string host;
int port;
};
struct Connection {
// RAII로 소켓 닫기 등을 처리한다고 가정
int fd;
~Connection();
};
using Error = std::error_code;
auto load_config(const std::string& path) -> std::expected<Config, Error>;
auto connect_tcp(const Config& cfg) -> std::expected<Connection, Error>;
auto handshake(Connection& c) -> std::expected<void, Error>;
std::expected<Connection, Error> create_ready_connection(const std::string& cfgPath) {
return load_config(cfgPath)
.and_then([](const Config& cfg) {
return connect_tcp(cfg);
})
.and_then([](Connection c) {
// handshake는 Connection&을 요구하므로 약간의 형태 조정이 필요할 수 있음
auto hs = handshake(c);
if (!hs) return std::expected<Connection, Error>(std::unexpected(hs.error()));
return std::expected<Connection, Error>(std::move(c));
});
}
여기서 중요한 점은 “실패 시점이 어디든, 이미 만들어진 RAII 객체는 스코프 종료로 정리”된다는 것입니다. 즉, expected는 누수를 직접 막는다기보다, 누수 방지에 유리한 구조(작은 단계 + 명시적 실패 + RAII)를 표준화해줍니다.
expected<void, E>로 절차형 API도 깔끔하게
성공 시 반환할 값이 없을 때는 std::expected<void, E>가 유용합니다.
#include <expected>
#include <system_error>
std::expected<void, std::error_code> write_all(int fd, const void* data, size_t n);
std::expected<void, std::error_code> save(int fd) {
auto r = write_all(fd, "abc", 3);
if (!r) return std::unexpected(r.error());
return {};
}
return {};는 “성공(값 없음)”을 의미합니다.
실전 팁: expected 도입 시 흔한 실수
1) E에 문자열만 넣기
문자열 에러는 빠르게 시작하기 좋지만, 분류/재시도/메트릭 태깅이 어려워집니다. 최소한 enum class 코드 + 메시지(선택) 조합을 추천합니다.
2) expected를 반환하면서 내부에서 여전히 수동 정리
expected는 “리턴 타입”일 뿐입니다. 자원은 반드시 RAII로 감싸야 합니다. expected만으로는 malloc/fopen 누수를 자동으로 막아주지 않습니다.
3) 계층 경계에서 에러를 그대로 노출
라이브러리 에러를 API 에러로 그대로 던지면 호출자가 해석하기 어렵습니다. transform_error로 의미를 재구성하세요.
4) 성공/실패 분기를 대충 처리
if (!r) return ...; 패턴을 줄이려면 and_then/or_else를 적극적으로 쓰되, 디버깅 시점에 “어디서 실패했는지”가 보이도록 에러에 컨텍스트를 넣으세요.
결론: expected는 “에러를 값으로” 만들어 누수를 구조적으로 줄인다
C++23 std::expected는 예외를 쓰지 않는 코드베이스에서도 실패를 풍부하게 표현하고, 호출자가 실패를 무시하기 어렵게 만듭니다. 여기에 RAII를 결합하면 조기 반환이 많아져도 자원 정리가 자동으로 따라오므로, 에러 경로에서의 자원 누수를 구조적으로 줄일 수 있습니다.
정리하면 권장 스타일은 다음과 같습니다.
- 자원은 모두 RAII 타입으로 감싸기(
unique_ptr+ 커스텀 deleter, 전용 래퍼) - 단계별 함수는
std::expected<T, E>로 실패를 반환하기 - 계층 경계에서
transform_error로 에러 의미를 재구성하기 - 파이프라인은
and_then/or_else로 합성해 보일러플레이트 줄이기
이 패턴을 한 번 팀 규칙으로 정착시키면, “에러 처리 코드가 늘수록 누수 가능성도 늘어난다”는 오래된 공식을 상당 부분 깨뜨릴 수 있습니다.