본문으로 건너뛰기
뒤로가기

[C++] 결과 반환과 상세 진단을 위한 공통 파이프라인

TL;DR — 새로운 계층마다 전용 결과 타입을 정의하던 경험을 돌아보며, 함수 호출의 공통 결과는 TkResult, 상세 원인은 TkDiagnostic으로 분리했습니다. Vulkan의 VkResult를 참고해 공통 계층에 결과 반환과 세부 진단에 대한 작은 계약을 두고, 다양한 곳에서 같은 양식으로 성공/실패를 표현할 수 있게 구성하였습니다.

Table of contents

Open Table of contents

들어가며

PrivateServer에는 이미 공통 결과 타입인 NrStatusNrResult<T>가 있었습니다. 하지만 새로운 계층을 추가할 때마다 그 계층의 상태와 실패를 표현할 enum, 성공 데이터를 전달할 struct, 하위 실패를 다시 변환할 규칙을 자주 새로 정의했습니다.

결과 타입은 필요에 의해 만들어졌기 때문에 불필요하다고만 볼 수는 없습니다. 다만 비슷한 패턴을 가졌지만, 레이어가 추가될 때마다 매번 비슷한 용도의 비슷한 코드를 작성하고 확인하는 것은 줄일 수 있다고 생각했습니다.

이 글에서는 Vulkan의 결과 반환 양식을 참고해 TkResultTkDiagnostic으로 책임을 나눈 과정, 공통 계층이 소유하는 계약, 그리고 Packet Tool에서 실제로 적용되는 방식에 대해 순차적으로 정리해 나갑니다.

이 글에서 다루는 내용:

사전 지식: C/C++ 함수 반환값, callback function pointer


1. 공통 결과 타입만으론 해결할 수 없는 문제

1.1 NrStatusNrResult<T>가 제공한 공통 기반

PrivateServer의 NrStatus는 공통 오류 코드와 native 오류 코드를 함께 보관합니다. NrResult<T>는 이 상태와 성공 값을 묶어 반환합니다.

// 설명에 필요한 public member만 발췌했습니다.
class NrStatus
{
public:
    [[nodiscard]] constexpr static NrStatus Success() noexcept;
    [[nodiscard]] constexpr static NrStatus Failure(
        NrErrorCode errorCode,
        NrNativeErrorCode nativeErrorCode = 0) noexcept;

    [[nodiscard]] constexpr bool Succeeded() const noexcept;
    [[nodiscard]] constexpr bool Failed() const noexcept;

private:
    NrErrorCode errorCode_ = NrErrorCode::Success;
    NrNativeErrorCode nativeErrorCode_ = 0;
};NrStatus.h
// 설명에 필요한 public member만 발췌했습니다.
template <typename T>
class NrResult
{
public:
    explicit NrResult(T value);

    [[nodiscard]] static NrResult Failure(NrStatus status) noexcept;
    [[nodiscard]] const NrStatus& Status() const noexcept;
    [[nodiscard]] bool Succeeded() const noexcept;
    [[nodiscard]] T TakeValue();

private:
    NrStatus status_;
    std::optional<T> value_;
};NrResult.h

이 구조는 네트워크 계층에서 공통 오류와 성공 값을 전달하는 기준을 제공했고, 값과 오류를 하나의 객체로 다룬다는 점에서 어느 정도의 편리함도 존재했습니다.

1.2 새로운 계층에서 반복된 선택

이어서 World 계층에서도 WorldResult<T, TError> 같은 공통 template을 이용해 World 내부 로직 처리의 결과를 처리하게 만들었습니다. 그런데 다음과 같이 World의 내부 레이어가 반환하는 의미를 담기 위한 전용 enum도 계속 늘어나기 시작했습니다.

enum class WorldSpatialIndexBuildResult : std::uint8_t
{
    Built = 0,
    InvalidInput,
    AllocationFailed,
};WorldSpatialIndex.h
enum class WorldFixedStepTakeResult : std::uint8_t
{
    Taken = 0,
    NotDue,
    InvalidArgument,
    InvalidState,
    ServerTickOverflow,
};WorldFixedStepSchedule.h

각 결과를 보면 NotDue는 WorldFixedStep 레이어에서 담당하는 도메인 로직의 결과이고, InvalidArgument, AllocationFailed는 여러 계층에서 반복해 사용할 수 있는 일반적인 결과에 가깝다는 것을 확인할 수 있습니다.

그래서 여러 종류의 enum에 들어간 결과를 매번 코드에서 서로 다른 result를 만들고, 이를 확인하면서 정상적으로 처리가 진행됐는지, 레이어 내부 도메인 로직 처리 결과를 확인하는 등의 불필요한 작업이 반복적으로 진행됐습니다.

성공 시 여러 값을 전달해야 하면 NrResult<T> 또는 WorldResult<T>T로 사용할 struct도 추가했습니다. 새로운 계층마다 결과 enum, 성공 payload와 변환 코드를 설계하는 작업이 계속 반복됐습니다.


2. Vulkan에서 결과를 처리하는 방법

앞에서 확인한 문제를 많은 사람들이 사용하는 프로젝트에서는 어떤 방식으로 해결하는지 확인해보기 위해 Vulkan을 레퍼런스 삼아 조사를 해보았습니다.

2.1 VkResult는 단순한 성공과 실패가 아니다

Vulkan API는 여러 command의 결과를 공통 VkResult로 표현합니다. VK_SUCCESS만 있는 단순한 boolean 결과가 아니라, 호출자가 다음 동작을 결정할 수 있는 성공 상태와 오류 범주를 함께 정의합니다.

분류예시호출자가 판단할 내용
성공VK_SUCCESS작업이 완료됐는가
진행 상태VK_NOT_READY, VK_TIMEOUT기다리거나 다시 시도해야 하는가
부분 성공VK_INCOMPLETE더 큰 출력 공간으로 다시 호출해야 하는가
실패VK_ERROR_OUT_OF_HOST_MEMORY작업을 중단하거나 상위로 실패를 전달해야 하는가

반면 참과 거짓 자체가 결과인 Vulkan Loader 내부의 predicate는 bool을 사용합니다. 예를 들어 문자열 목록에 특정 값이 있는지를 검사하는 함수는 새로운 result enum을 만들지 않고 bool을 반환합니다.

이 구조에서 핵심은 모든 함수의 반환 타입을 하나로 묶는 것이 아니었습니다. 호출자의 제어 흐름을 바꾸는 공통 결과는 공유하고, predicate나 도메인 처리 결과는 그 의미에 맞는 타입을 사용하면 됩니다. 참과 거짓 자체가 결과라면 새로운 구조체 대신 bool을 사용할 수 있습니다.

따라서 public API와 내부 함수의 구분보다 반환값의 의미가 기준이 됩니다. 성공과 실패를 상위 호출자가 해석해야 하면 공통 결과 타입을 사용하고, 정상적인 도메인 상태를 구분해야 하면 전용 타입을 사용합니다.

2.2 결과 범주와 상세 설명은 다른 정보다

그러나 반환 결과만으로는 세부적인 내용을 같이 전달할 수 없습니다. 그래서 Khronos Vulkan Loader의 내부 함수들은 VkResult를 반환하고 하위 결과를 전파합니다. 구체적인 원인은 별도의 logging 경로로 남깁니다.

PrivateServerToolKit에서는 Vulkan에서 상세 설명을 별도 logging으로 처리하는 점을 차용하여 다음과 같이 두 가지 정보를 API 호출 경로로 전달하기로 했습니다.

함수 호출 결과
├─ 공통 결과 범주(TkResult)
│  └─ 호출자의 제어 흐름을 결정

└─ 상세 진단(TkDiagnostic)
   └─ 사람과 도구가 실패 원인을 해석

공통 반환 타입은 TkResult, 상세 진단의 공통 형식은 TkDiagnostic으로 정했습니다.


3. TkResultTkDiagnostic

3.1 TkResult는 제어 흐름을 위한 공통 enum

TkResult에는 여러 Tool과 consumer가 공통으로 해석할 수 있는 결과만 둡니다.

typedef enum TkResult
{
    TK_SUCCESS = 0,

    TK_ERROR_UNKNOWN = -1,
    TK_ERROR_INVALID_ARGUMENT = -2,
    TK_ERROR_BUFFER_TOO_SMALL = -3,
    TK_ERROR_OUT_OF_MEMORY = -4,
    TK_ERROR_INVALID_DATA = -5,
    TK_ERROR_IO = -6,

    TK_RESULT_MAX_ENUM = 0x7FFFFFFF
} TkResult;TkResult.h

이 값은 어떤 작업이 정확히 왜 실패했는지 모두 설명하지 않습니다. 호출자가 실패 범주를 보고 반환, 재시도, buffer 확장 또는 입력 거부 같은 제어 흐름을 선택할 수 있을 정도로만 구분합니다.

상황TkResult
null output pointer와 같은 잘못된 호출TK_ERROR_INVALID_ARGUMENT
caller가 제공한 output buffer의 용량 부족TK_ERROR_BUFFER_TOO_SMALL
입력 데이터 해석 실패TK_ERROR_INVALID_DATA
파일이나 OS resource의 I/O 실패TK_ERROR_IO

NotDue, NeedMoreData, Accepted처럼 호출자가 구분해야 하는 도메인 결과는 TkResult에 추가하지 않고 해당 도메인에 맞는 타입을 사용합니다. 공통 실패와 도메인 결과를 나누는 것이지, 모든 함수의 반환 타입을 하나로 만드는 것은 아닙니다.

3.2 TkDiagnostic은 상세 원인을 전달

TkResult는 다소 범용적인 결과를 담아냅니다. 그래서 상세 원인은 공통 진단 타입인 TkDiagnostic으로 분리했습니다.

typedef struct TkDiagnostic
{
    TkDiagnosticSeverity severity;
    const char *id;
    const char *message;
    TkDiagnosticLocation location;
} TkDiagnostic;

typedef void (*TkDiagnosticCallback)(
    const TkDiagnostic *diagnostic,
    void *userData);

typedef struct TkDiagnosticCallbackInfo
{
    TkDiagnosticCallback callback;
    void *userData;
} TkDiagnosticCallbackInfo;TkDiagnostic.h

TkResultTkDiagnostic은 같은 실패를 서로 다른 관점에서 전달합니다.

정보역할
TkResult호출자가 해석하는 공통 결과 범주
severity정보, 경고와 오류의 심각도
idTool이 정의하는 안정적인 원인 식별자
message사람이 읽을 수 있는 상세 설명
locationsource 이름과 선택적인 위치 정보

예를 들어 여러 입력 오류가 모두 TK_ERROR_INVALID_DATA를 반환하더라도 Tool은 각각 다른 id, messagelocation을 전달할 수 있습니다. 호출자는 TkResult로 실행 흐름을 결정하고, consumer는 diagnostic으로 구체적인 원인을 표시하거나 보관합니다.


4. 공통 계층은 작은 계약만 소유한다

공통 계층은 여러 Tool이 공유하는 기초 타입과 작은 연산을 제공하는 header-only INTERFACE target입니다. 개별 Tool의 도메인 의미나 실행 정책은 소유하지 않습니다.

callback 실행에는 다음 작은 helper를 사용합니다.

static inline void TkEmitDiagnostic(
    TkDiagnosticCallbackInfo callbackInfo,
    const TkDiagnostic *diagnostic)
{
    if (callbackInfo.callback == NULL)
    {
        return;
    }

    callbackInfo.callback(diagnostic, callbackInfo.userData);
}TkDiagnostic.h

공통 계층에 logger나 diagnostic vector를 두지 않은 이유도 여기에 있습니다. 출력, 보관과 동기화 정책은 consumer마다 다르기 때문에 공통 계층이 하나의 정책을 강제하지 않습니다.


5. Tool에서 consumer까지 이어지는 callback 파이프라인

5.1 같은 인터페이스로 서로 다른 정보를 전달한다

consumer는 callbackuserDataTkDiagnosticCallbackInfo에 담아 Tool API에 전달합니다. Tool은 실행 결과를 TkResult로 반환하고, 상세 설명이 필요한 경우 TkDiagnostic을 구성해 공통 계층의 TkEmitDiagnostic으로 전달합니다.

flowchart LR
    Consumer["Consumer
    callback + userData"]
    Tool["Tool API"]
    Result["TkResult
    제어 흐름"]
    SharedLayer["공통 계층
    TkEmitDiagnostic"]
    Callback["Consumer callback
    출력 / 복사 / 변환"]

    Consumer -->|callback info| Tool
    Tool -->|return| Result
    Result --> Consumer
    Tool -->|TkDiagnostic| SharedLayer
    SharedLayer --> Callback

공통 계층은 diagnostic의 내용을 해석하지 않습니다. 각 Tool은 같은 구조 안에 자신이 알고 있는 정보를 채웁니다.

Tool이 알고 있는 정보Diagnostic 구성 예시
source 기반 입력 오류안정적인 id, 입력 파일 이름과 위치
지원하지 않는 값기대한 값과 실제 값을 설명하는 message
I/O 작업 실패공통 I/O 결과와 실패한 작업을 나타내는 id
source가 없는 실행 오류위치 없는 diagnostic과 상세 message

Tool마다 전달하는 정보는 달라도 consumer가 받는 형식은 TkDiagnostic으로 유지됩니다. 새로운 Tool을 추가할 때 별도 callback 형식을 만들지 않고 id, messagelocation의 의미만 Tool의 도메인에 맞게 정할 수 있습니다.

5.2 consumer가 callback 동작을 선택한다

consumer는 Tool 코드를 변경하지 않고 callback만 바꿔 diagnostic 처리 방식을 선택할 수 있습니다.

Consumer 정책Callback 동작
Diagnostic을 사용하지 않음callback == nullptr로 전달
Console 출력severity, idmessage를 원하는 형식으로 출력
Build 결과 수집borrowed data를 consumer 저장소로 복사
Editor 연동location을 파일과 위치 표시로 변환

Diagnostic을 사용하지 않는 consumer는 disabled callback을 전달할 수 있습니다.

const TkDiagnosticCallbackInfo disabled = {nullptr, nullptr};

Diagnostic을 보관하려는 consumer는 callback 안에서 값을 복사합니다.

struct StoredDiagnostic
{
    std::string id;
    std::string message;
    std::string sourceName;
};

void StoreDiagnostic(
    const TkDiagnostic* diagnostic,
    void* userData)
{
    auto& stored = *static_cast<StoredDiagnostic*>(userData);
    stored.id = diagnostic->id;
    stored.message = diagnostic->message;
    stored.sourceName = diagnostic->location.sourceName != nullptr
        ? diagnostic->location.sourceName
        : "";
}

StoredDiagnostic stored;
TkDiagnosticCallbackInfo callbackInfo = {
    StoreDiagnostic,
    &stored,
};consumer.cpp

userData는 callback 함수와 consumer의 상태를 연결합니다. Diagnostic은 callback 동안만 유효하므로 위 예시처럼 문자열을 복사하면 Tool API 반환 이후에도 정보를 사용할 수 있습니다.

이 구조에서는 consumer가 바뀌어도 Tool의 반환 타입과 diagnostic 생성 코드는 그대로 유지됩니다. 반대로 새로운 Tool을 추가해도 공통 callback 형식을 재정의할 필요가 없습니다.

5.3 공통 계층, Tool과 consumer의 책임

주체결과 책임Diagnostic 책임
공통 계층공통 TkResult 값 정의공통 POD, callback 형식과 동기 emit helper 제공
Tool상황에 맞는 TkResult 선택안정적인 ID, message, location과 emit 시점 결정
Consumer결과에 따라 반환·재시도·중단출력, 복사, 필터링, 저장과 동기화 정책 결정

이 분리를 통해 공통 계층은 커지지 않으면서도 Tool과 consumer가 같은 언어로 통신할 수 있습니다.


6. diagnostic의 라이프사이클 관리

callback 기반 diagnostic은 consumer에게 선택권을 주지만 안정적으로 사용하기 위해 몇 가지 규칙이 필요합니다.

그래서 공통 계층은 공통 형식과 최소 helper만 소유합니다. Tool과 consumer의 정책을 공통 계층으로 끌어올리지 않는 것이 현재 구조를 유지하는 조건입니다.


7. 적용 예시: Packet Tool

앞에서 정리한 TkResult, TkDiagnostic, callback과 공통 계층의 책임은 Packet Tool의 build-time compiler와 generated codec에 함께 적용됩니다.

7.1 하나의 결과 어휘를 전파한다

Packet Tool의 C++ 생성 흐름은 다음 단계로 구성됩니다.

schema file 읽기
-> JSON parsing
-> semantic validation
-> PacketDescriptor 구성
-> C++ header 생성
-> generated file commit

각 함수는 별도의 parser result나 generator result를 만들지 않고 TkResult를 반환합니다. 상위 단계는 하위 TkResult를 그대로 확인하고 전파합니다.

PacketDescriptorSet descriptorSet;
PacketSchemaCompiler schemaCompiler(compileInfo.diagnosticCallback);

TkResult result = schemaCompiler.Compile(
    compileInfo.inputPaths,
    compileInfo.inputPathCount,
    &descriptorSet);

if (result != TK_SUCCESS)
{
    return result;
}TkPacketCppCompiler.cpp

Parser, descriptor builder와 file committer처럼 상세 원인을 알고 있는 단계는 같은 callback 형식으로 diagnostic을 전달합니다. C++ generator는 현재 TkResult만 반환하지만, 이를 위해 별도의 generator result 타입을 추가하지 않습니다.

7.2 같은 callback으로 서로 다른 원인을 전달한다

Packet Tool은 하나의 TkResult에 여러 diagnostic을 대응시킵니다.

상황TkResultDiagnostic ID
중복된 field 이름TK_ERROR_INVALID_DATAPSTK-PACKET-DUPLICATE-FIELD-NAME
지원하지 않는 schema versionTK_ERROR_INVALID_DATAPSTK-PACKET-UNSUPPORTED-SCHEMA-VERSION
알 수 없는 field typeTK_ERROR_INVALID_DATAPSTK-PACKET-UNKNOWN-FIELD-TYPE
출력 파일 기록 실패TK_ERROR_IOPSTK-PACKET-OUTPUT-WRITE-FAILED
encode buffer 부족TK_ERROR_BUFFER_TOO_SMALLPSTK-PACKET-INVALID-PAYLOAD-SIZE

각 단계는 실패 범주와 함께 자신이 알고 있는 source 이름, logical path와 설명을 TkDiagnostic으로 구성합니다.

TkResult ReportFailure(
    const TkDiagnosticCallbackInfo& callbackInfo,
    const TkResult result,
    const char* const id,
    const std::string& sourceName,
    const std::string& logicalPath,
    const std::string& detail)
{
    std::string message = sourceName;
    if (!logicalPath.empty())
    {
        message += ": ";
        message += logicalPath;
    }
    message += ": ";
    message += detail;

    EmitDiagnostic(callbackInfo, id, message, sourceName);
    return result;
}TkPacketDiagnostic.cpp

consumer는 5절에서 만든 callback info를 Packet Tool의 public API에 그대로 전달할 수 있습니다.

TkPacketCppCompileInfo compileInfo = {
    inputPaths,
    inputPathCount,
    outputDirectory,
    namespaceName,
    callbackInfo,
};

const TkResult result = TkPacketCompileCpp(&compileInfo);
if (result != TK_SUCCESS)
{
    // result로 제어 흐름을 결정하고,
    // callback이 복사한 diagnostic으로 상세 원인을 표시합니다.
}consumer.cpp

Generated codec도 TkResult와 callback 계약을 사용합니다. 따라서 build-time compiler뿐 아니라 생성된 consumer-facing 코드까지 같은 결과 파이프라인이 이어집니다.

const TkResult encodeResult = packet.Encode(output, callbackInfo);
const TkResult decodeResult = packet.Decode(input, callbackInfo);

7.3 Validation 이후의 상태 반영

공통 결과를 반환하더라도 실패 과정에서 output이 일부 변경된다면 caller는 별도의 정리 코드를 작성해야 합니다. Generated codec은 validation과 임시 객체 구성을 모두 마친 뒤 마지막에 상태를 반영합니다.

다음 코드는 generated Decode에서 field별 읽기와 namespace를 생략하고 상태 반영 순서만 정리한 형태입니다.

TkResult Decode(
    TkByteView input,
    TkDiagnosticCallbackInfo diagnosticCallback = {})
{
    const TkResult validationResult =
        ValidateDecodeView(input, PayloadBytes, diagnosticCallback);
    if (validationResult != TK_SUCCESS)
    {
        return validationResult;
    }

    const std::uint16_t payloadVersion =
        ReadLittleEndian<std::uint16_t>(input.data);
    const TkResult versionResult = ValidatePayloadVersion(
        payloadVersion,
        PayloadVersion,
        diagnosticCallback);
    if (versionResult != TK_SUCCESS)
    {
        return versionResult;
    }

    GeneratedPacket decoded{};
    // input의 field를 decoded에 기록합니다.

    *this = decoded;
    return TK_SUCCESS;
}

Decode는 크기와 payload version을 먼저 검증하고 임시 decoded 객체에 field를 기록합니다. 실제 객체를 바꾸는 *this = decoded는 모든 단계가 성공한 뒤에만 실행됩니다. 따라서 TK_SUCCESS가 아니면 기존 상태가 유지되고 caller는 부분적으로 변경된 상태를 따로 정리하지 않아도 됩니다.

Encode도 caller의 output buffer를 먼저 검증하고 local byte array에 전체 payload를 만든 뒤 마지막에 output으로 복사합니다. 이 규칙을 통해 TkResult 반환과 output 보존이 하나의 호출 계약으로 이어집니다.


정리하며

PrivateServer에는 이미 NrStatus, NrResult<T>WorldResult<T, TError>가 있었습니다. 그러나 계층이 늘어날 때 공통 실패, 도메인 상태와 상세 정보를 어떤 타입으로 나눌지에 대한 판단은 계속 반복됐습니다.

PrivateServerToolKit에서는 Vulkan의 공통 VkResult와 predicate 구분을 참고해 함수 호출 결과를 TkResult로 통일했습니다. 단순한 성공과 실패만 표현하는 것이 아니라 InvalidArgument, InvalidData, BufferTooSmall, OutOfMemory, IO처럼 호출자가 대응할 수 있는 공통 범주를 제공합니다. 호출자는 이 값을 통해 실행 흐름을 제어합니다.

구체적인 실패 원인은 TkDiagnostic으로 분리했습니다. Tool은 같은 callback 인터페이스 위에서 자신의 ID, message와 location을 구성하고, consumer는 출력, 수집, 변환 또는 무시 정책을 선택합니다. Generated codec까지 이 계약을 사용하면서 Packet Tool의 build-time 단계와 consumer-facing 코드가 같은 결과 파이프라인을 공유하게 됐습니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] Packet Tool: JSON 스키마 기반 패킷 코드 자동화
다음 글
[C++] World Server 2: Double Buffer 도입기