C#·C++ 모던

C++23 std::expected로 게임 코드의 에러 처리 다시 짜기 (예외도 에러코드도 아닌 제3의 길)

kr-gamedev 2026. 5. 26. 09:01

게임 엔진 코드에서 에러 처리는 늘 골칫거리다. 에셋 로딩, 파일 파싱, 네트워크 패킷 해석, 셰이더 컴파일까지 "실패할 수 있는 함수"는 도처에 있는데, 정작 C++에서 이걸 깔끔하게 표현할 방법이 마땅치 않았다. 예외(exception)는 대부분의 게임 스튜디오가 -fno-exceptions로 꺼버리는 데다 핫 패스에서 비용이 부담스럽고, 그렇다고 전통적인 에러코드 반환은 결과값과 에러를 한 함수에서 같이 돌려줄 수가 없어 출력 파라미터(out-parameter)를 끌고 다녀야 한다.

그래서 많은 코드베이스가 자체 Result<T> / Optional<T, Error> 타입을 굴려왔다. 언리얼의 TOptional, 각 스튜디오의 사내 result 타입, 그리고 절반쯤 동작하는 매크로들. 이제 C++23이 표준 라이브러리로 이 문제를 정리해줬다. 바로 std::expected<T, E>다.

이 글은 std::expected가 게임 코드에서 실제로 어떻게 쓰이는지, 왜 단순한 "에러코드 + 값 묶음"을 넘어서는지, 그리고 모나딕(monadic) 연산으로 에러 전파 체인을 어떻게 평탄하게 만드는지 실전 코드로 정리한다.

std::expected가 해결하는 것

std::expected<T, E>는 "성공하면 T를, 실패하면 E를 담는" 타입이다. std::variant처럼 둘 중 하나만 보관하지만, 의미가 명확하다. 정상값이 우선이고 에러는 부가 상태다.

#include <expected>
#include <string>

enum class LoadError {
    FileNotFound,
    BadMagic,
    VersionMismatch,
};

std::expected<MeshData, LoadError> LoadMesh(const std::string& path) {
    auto file = OpenFile(path);
    if (!file)
        return std::unexpected(LoadError::FileNotFound);

    Header header = file->ReadHeader();
    if (header.magic != kMeshMagic)
        return std::unexpected(LoadError::BadMagic);
    if (header.version > kSupportedVersion)
        return std::unexpected(LoadError::VersionMismatch);

    return ParseMeshBody(*file);   // MeshData 를 그대로 반환
}

호출 측은 출력 파라미터 없이 값과 실패 사유를 한 번에 받는다.

auto result = LoadMesh("char_hero.mesh");
if (result) {
    Renderer::Submit(*result);          // operator* 로 성공값 접근
} else {
    LogError("mesh load failed: {}", ToString(result.error()));
}

*result로 성공값을, result.error()로 실패 사유를 꺼낸다. 핵심은 두 정보가 같은 반환 타입에 들어있어서 함수 시그니처만 봐도 "이 함수는 LoadError로 실패할 수 있다"가 드러난다는 점이다.

모나딕 연산: 에러 전파 체인 평탄화

진짜 위력은 and_then, transform, or_else 같은 모나딕 멤버 함수에서 나온다. 에셋 파이프라인처럼 여러 단계가 순차적으로 실패할 수 있을 때, if 중첩 없이 체인으로 엮을 수 있다.

std::expected<Texture, LoadError>  DecodeTexture(const MeshData&);
std::expected<Material, LoadError> BuildMaterial(const Texture&);

std::expected<Material, LoadError> LoadCharacterMaterial(const std::string& path) {
    return LoadMesh(path)
        .and_then(DecodeTexture)     // 성공이면 다음 단계, 실패면 에러 그대로 단락(short-circuit)
        .and_then(BuildMaterial)
        .transform([](Material m) {  // 성공값만 변형, 에러는 통과
            m.SetQuality(MaterialQuality::High);
            return m;
        });
}

and_then은 앞 단계가 성공일 때만 다음 함수를 호출하고, 한 번이라도 실패하면 그 에러를 끝까지 그대로 흘려보낸다. 중간 단계의 if (!result) return ...;를 전부 없애주는 셈이다. transform은 성공값에만 함수를 적용하고 에러는 손대지 않는다. or_else는 반대로 실패했을 때 복구 로직(예: 기본 머티리얼 대체)을 끼워넣을 때 쓴다.

게임 코드에 적용할 때 주의점

첫째, E 타입은 가볍게 유지하자. enum class나 작은 에러 구조체가 적합하다. 무거운 문자열 메시지를 매 반환마다 생성하면 핫 패스에서 손해다. 로그 메시지는 enum을 받아서 호출 측에서 변환하는 편이 낫다.

둘째, std::expected는 값 의미론(value semantics)을 가진다. T가 큰 타입이면 복사 비용이 생기므로, 무거운 리소스는 핸들이나 인덱스로 감싸 반환하는 패턴이 안전하다. 메시 데이터 자체보다 메시 핸들을 expected에 담는 식이다.

셋째, 언리얼처럼 자체 타입 생태계가 강한 환경에서는 std::expectedTOptional/TValueOrError가 공존한다. 엔진 API 경계에서는 엔진 타입을, 순수 로직 모듈 내부에서는 표준 타입을 쓰는 식으로 레이어를 나누면 마찰이 줄어든다.

// 실패 시 기본값으로 복구하는 or_else 예시
Material LoadOrDefault(const std::string& path) {
    return LoadCharacterMaterial(path)
        .or_else([](LoadError) -> std::expected<Material, LoadError> {
            return GetFallbackMaterial();   // 실패해도 게임은 계속 굴러간다
        })
        .value();   // 여기선 항상 성공이 보장됨
}

정리

std::expected<T, E>는 예외를 끄고도, 출력 파라미터를 끌고 다니지 않고도 "실패할 수 있는 함수"를 정직하게 표현하게 해준다. 함수 시그니처가 곧 계약이 되고, and_then/transform/or_else로 에러 전파를 체인으로 평탄화하면 중첩 if 더미가 사라진다. 게임에서는 에러 타입을 가볍게, 무거운 리소스는 핸들로, 엔진 경계에서는 레이어를 나누는 세 가지만 지키면 사내 result 타입을 표준으로 갈아끼울 충분한 이유가 된다. 컴파일러가 C++23을 지원한다면 다음 에셋 로더부터 한번 적용해볼 만하다.