Published on

C++23 std - -expected로 예외 없이 누수 막기

Authors

서버나 게임 엔진처럼 예외를 꺼두는(-fno-exceptions) C++ 코드베이스에서는 오류 처리가 곧 리소스 관리 품질을 좌우합니다. 예외가 없으면 throw로 스택을 풀어주지 않기 때문에, 함수가 중간에 실패할 때 “반쯤 생성된 리소스”가 남기 쉽고, 그 결과가 메모리 누수 또는 핸들 누수로 이어집니다.

C++23의 std::expected는 “성공 값” 또는 “에러 값”을 타입으로 표현해, 예외 없이도 오류 전파를 표준화합니다. 하지만 핵심은 단순히 반환 타입이 바뀌는 게 아니라, RAII와 결합했을 때 실패 경로에서의 누수를 구조적으로 막을 수 있다는 점입니다.

이 글에서는 std::expected를 중심으로 다음을 다룹니다.

  • 예외 없이도 누수를 막는 기본 원리: RAII + std::expected
  • new/malloc 기반 코드에서의 안전한 전환 패턴
  • expected 체이닝(조합)으로 “에러 처리 누락”을 줄이는 방법
  • 흔한 함정: expected에 참조/포인터를 담을 때, 에러 타입 설계, 성능

참고로, 장애나 오류를 “반드시 처리하게 만드는” 설계는 분산 시스템에서도 중요합니다. 사가 패턴에서 보상 트랜잭션을 강제하는 설계 철학은 C++의 오류 전파에도 통합니다. 관련 글: MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅

왜 예외가 없으면 누수가 늘어날까

예외를 쓸 때는 생성 도중 실패해도 스택 언와인딩으로 지역 객체의 소멸자가 호출됩니다. 하지만 예외를 꺼두면 보통 다음 패턴으로 흐릅니다.

  • 반환 코드(bool, int errno)로 실패를 알림
  • 호출자가 매번 if로 검사
  • 중간 실패 시 정리 코드 누락(특히 여러 리소스를 순차적으로 획득할 때)

대표적인 누수 패턴은 “획득 후 검사, 실패 시 조기 반환”입니다.

bool load_config(const char* path) {
    FILE* f = std::fopen(path, "rb");
    if (!f) return false;

    char* buf = (char*)std::malloc(4096);
    if (!buf) return false; // f 닫히지 않음: 핸들 누수

    // ... parse ...

    std::free(buf);
    std::fclose(f);
    return true;
}

이 문제의 본질은 “에러 전파 방식”이 아니라 정리 책임이 사람 손에 남아있다는 점입니다.

std::expected의 역할: 실패를 타입으로 만들기

std::expected는 다음 형태를 가집니다.

  • 성공: expected<T, E>T가 들어있음
  • 실패: expected<T, E>E가 들어있음

예외와 달리, 실패는 값이므로 호출자는 반드시 확인해야 하고(혹은 확인하지 않으면 코드 리뷰/정적 분석에서 드러나기 쉽고), 무엇보다 실패 경로에서도 지역 객체의 소멸자는 정상 호출됩니다. 즉, RAII만 제대로 쓰면 실패해도 누수가 나지 않습니다.

최소 예제

#include <expected>
#include <string>

enum class errc {
    not_found,
    invalid_format,
};

std::expected<int, errc> parse_port(const std::string& s) {
    if (s.empty()) return std::unexpected(errc::invalid_format);
    int v = 0;
    for (char c : s) {
        if (c < '0' || c > '9') return std::unexpected(errc::invalid_format);
        v = v * 10 + (c - '0');
    }
    if (v <= 0 || v > 65535) return std::unexpected(errc::invalid_format);
    return v;
}

여기서 중요한 건 “에러가 값이므로” 예외 없이도 충분히 풍부한 정보를 담을 수 있다는 점입니다.

누수를 막는 핵심: expected + RAII

std::expected만으로 누수가 자동 해결되진 않습니다. 누수를 막는 실전 해법은 다음 조합입니다.

  • 리소스는 반드시 RAII 타입으로 감싼다
  • RAII를 만든 뒤에는 실패해도 그냥 return unexpected(...) 한다
  • expected는 그 흐름을 깔끔하게 만든다

FILE* 예제를 RAII로 바꾸기

#include <expected>
#include <cstdio>
#include <memory>

struct file_closer {
    void operator()(FILE* f) const noexcept {
        if (f) std::fclose(f);
    }
};

using file_ptr = std::unique_ptr<FILE, file_closer>;

enum class io_err {
    open_failed,
    read_failed,
    oom,
};

std::expected<file_ptr, io_err> open_file(const char* path) {
    FILE* raw = std::fopen(path, "rb");
    if (!raw) return std::unexpected(io_err::open_failed);
    return file_ptr(raw);
}

이제 file_ptr는 스코프를 벗어나면 자동으로 닫힙니다. 실패해도 누수는 없습니다.

malloc 버퍼도 RAII로

#include <cstdlib>
#include <expected>
#include <memory>

struct free_deleter {
    void operator()(void* p) const noexcept { std::free(p); }
};

using malloc_ptr = std::unique_ptr<void, free_deleter>;

std::expected<malloc_ptr, io_err> alloc_buf(std::size_t n) {
    void* p = std::malloc(n);
    if (!p) return std::unexpected(io_err::oom);
    return malloc_ptr(p);
}

조합: 여러 리소스를 순차 획득해도 안전

#include <expected>
#include <string>

std::expected<std::string, io_err> read_first_line(const char* path) {
    auto fexp = open_file(path);
    if (!fexp) return std::unexpected(fexp.error());
    file_ptr f = std::move(*fexp);

    auto bexp = alloc_buf(4096);
    if (!bexp) return std::unexpected(bexp.error());
    malloc_ptr buf = std::move(*bexp);

    // 여기서부터 어떤 실패로 return 해도 f, buf는 자동 정리됨
    char* cbuf = static_cast<char*>(buf.get());
    if (!std::fgets(cbuf, 4096, f.get())) {
        return std::unexpected(io_err::read_failed);
    }
    return std::string(cbuf);
}

포인트는 “정리 코드가 사라진다”는 것입니다. 누수 방지는 체크리스트가 아니라 구조가 됩니다.

expected 체이닝으로 에러 처리 누락 줄이기

실무에서는 if (!exp) return ...;가 반복되면 다시 실수 여지가 생깁니다. C++23에는 표준 and_then, transform, or_else가 들어왔고, 이를 이용하면 에러 흐름을 더 명시적으로 만들 수 있습니다.

아래 예제는 “파일 열기 → 첫 줄 읽기 → 포트 파싱”을 체이닝합니다.

#include <expected>
#include <string>

std::expected<std::string, io_err> read_first_line2(const char* path);
std::expected<int, errc> parse_port(const std::string& s);

enum class app_err {
    io_open_failed,
    io_read_failed,
    invalid_port,
};

static app_err map_io(io_err e) {
    switch (e) {
        case io_err::open_failed: return app_err::io_open_failed;
        case io_err::read_failed: return app_err::io_read_failed;
        case io_err::oom: return app_err::io_read_failed;
    }
    return app_err::io_read_failed;
}

std::expected<int, app_err> load_port(const char* path) {
    return read_first_line2(path)
        .transform_error(map_io)
        .and_then([](const std::string& line) {
            return parse_port(line)
                .transform_error([](errc) { return app_err::invalid_port; });
        });
}

이 스타일의 장점은 다음과 같습니다.

  • 성공/실패 흐름이 한 눈에 보임
  • 중간 단계에서 자원 정리는 RAII가 담당
  • “에러 매핑”이 분리되어 관찰 가능(로그/메트릭 지점도 넣기 쉬움)

대규모 시스템에서 오류를 추적하고 복구 설계를 하는 방식은 분산 트랜잭션에서도 유사합니다. 보상 설계 관점이 궁금하다면: MSA 사가 패턴 데이터 불일치, 보상 설계 실전

에러 타입(E) 설계: 문자열보다 구조화

expected<T, E>Estd::string으로 두면 편하지만, 다음 문제가 생깁니다.

  • 에러 분류가 어려워 호출자가 분기 처리하기 힘듦
  • 문자열 할당이 추가되어 성능/예측 가능성이 떨어짐
  • 국제화/로그 포맷이 섞여 유지보수 비용 증가

실전에서는 보통 아래 중 하나가 좋습니다.

  • enum class + (필요 시) 부가 정보 구조체
  • std::error_code 또는 커스텀 error category

부가 정보 포함 예시

#include <expected>
#include <string>

enum class parse_errc {
    invalid_character,
    out_of_range,
};

struct parse_error {
    parse_errc code;
    std::size_t position;
};

std::expected<int, parse_error> parse_int10(const std::string& s) {
    if (s.empty()) return std::unexpected(parse_error{parse_errc::invalid_character, 0});

    int v = 0;
    for (std::size_t i = 0; i < s.size(); ++i) {
        char c = s[i];
        if (c < '0' || c > '9') return std::unexpected(parse_error{parse_errc::invalid_character, i});
        int next = v * 10 + (c - '0');
        if (next < v) return std::unexpected(parse_error{parse_errc::out_of_range, i});
        v = next;
    }
    return v;
}

에러 메시지는 로깅 계층에서 codeposition을 기반으로 만들면 됩니다.

흔한 함정 1: expected에 raw 포인터를 성공 값으로 두기

expected<Foo*, E>는 성공 시에도 소유권이 불명확합니다.

  • 누가 delete 하나?
  • 실패 시 중간에 생성된 포인터는 누가 정리하나?

정답은 대부분 “포인터를 반환하지 말고 소유권을 표현하라”입니다.

  • 힙 소유: expected<std::unique_ptr<Foo>, E>
  • 공유: expected<std::shared_ptr<Foo>, E> (정말 필요할 때만)
  • 비소유 뷰: expected<std::reference_wrapper<Foo>, E> 또는 Foo*를 쓰되 수명 규칙을 문서화

소유권을 expected로 표현

#include <expected>
#include <memory>

enum class build_err { invalid, oom };

struct widget {
    int x;
};

std::expected<std::unique_ptr<widget>, build_err> make_widget(int x) {
    if (x < 0) return std::unexpected(build_err::invalid);

    auto p = std::make_unique<widget>();
    p->x = x;
    return p;
}

이렇게 하면 성공/실패와 무관하게 누수 가능성이 크게 줄어듭니다.

흔한 함정 2: “에러 무시”를 방치하기

expected는 예외보다 더 쉽게 무시될 수도 있습니다. 반환값을 받지 않으면 컴파일은 되기 때문입니다.

대응책:

  • 반환값을 반드시 사용하게 [[nodiscard]]를 붙인다
  • 코드 규칙으로 “expected는 즉시 검사하거나 체이닝으로 소비”를 강제
#include <expected>

enum class e { fail };

[[nodiscard]] std::expected<int, e> do_work();

컴파일러 경고를 CI에서 에러로 올리면 실수로 무시하는 경우를 크게 줄일 수 있습니다.

흔한 함정 3: 에러 전파 중 복사 비용

expected는 값 타입이라 큰 T를 담으면 이동/복사가 발생할 수 있습니다. 보통은 다음으로 해결합니다.

  • 큰 객체는 std::unique_ptr로 소유권을 담아 이동 비용 최소화
  • T를 “가벼운 핸들/뷰”로 설계
  • E도 가볍게(대개 enum class)

또한 and_then 체이닝은 람다 캡처가 커지면 비용이 늘 수 있으니, 핫패스에서는 프로파일링이 필요합니다.

예외 없는 코드베이스에서의 권장 규칙(체크리스트)

  1. 리소스는 raw로 들고 다니지 말고 RAII 타입으로 즉시 감싼다
  2. 실패 가능 함수는 std::expected<T, E>로 통일한다
  3. E는 분기 가능한 구조(enum class, error_code)로 설계한다
  4. 모든 expected 반환 함수에 [[nodiscard]]를 붙인다
  5. 다단계 작업은 and_then/transform/transform_error로 조합해 누락을 줄인다

이 원칙을 적용하면 “예외를 못 쓰는 환경이라서”가 아니라, 오히려 예외를 안 쓰는 환경에서도 오류 처리가 더 예측 가능해지고, 실패 경로에서의 누수가 설계적으로 차단됩니다.

마무리

std::expected는 예외의 대체재라기보다, 실패를 타입 시스템으로 끌어올려 호출자에게 처리 책임을 명확히 부여하는 도구입니다. 여기에 RAII를 결합하면, 조기 반환이 난무하는 코드에서도 메모리/핸들 누수를 “사람이 기억해서 막는 문제”에서 “구조적으로 발생하기 어려운 문제”로 바꿀 수 있습니다.

다음 단계로는 다음을 추천합니다.

  • 프로젝트 전반의 에러 타입 표준화(E 통일, 매핑 규칙)
  • 로깅/메트릭에 error를 구조적으로 남기는 규약 수립
  • 예외 금지 환경에서의 API 가이드(소유권, 수명, [[nodiscard]])