게임 엔진 코드에서 에러 처리는 늘 골칫거리다. 에셋 로딩, 파일 파싱, 네트워크 패킷 해석, 셰이더 컴파일까지 "실패할 수 있는 함수"는 도처에 있는데, 정작 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::expected와 TOptional/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을 지원한다면 다음 에셋 로더부터 한번 적용해볼 만하다.
'C#·C++ 모던' 카테고리의 다른 글
| C++23 deducing this로 CRTP 없이 게임 컴포넌트 체이닝 구현하기 (0) | 2026.06.09 |
|---|---|
| C# PriorityQueue로 A* 경로탐색과 게임 이벤트 스케줄러 구현하기 (0) | 2026.06.07 |
| C# 12 컬렉션 표현식(Collection Expressions)으로 Unity 게임 초기화 코드 반으로 줄이기 (0) | 2026.06.02 |
| Span<T>와 stackalloc로 Unity 핫패스에서 GC를 없애는 법 (0) | 2026.05.31 |
| Unity에서 큰 struct 복사 비용 잡기: in, ref readonly, 그리고 defensive copy 함정 (0) | 2026.05.24 |