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

[C++] C++ 객체를 C#에 전달하기

TL;DR — C#에서 C++ 객체를 직접 호출하는 대신 C++ 구현을 불투명 핸들(opaque handle) 뒤에 숨기고, 고정 폭 값과 C 함수로 구성한 C ABI 브리지를 만들었습니다. C#에서는 P/Invoke와 SafeHandle로 이 계약을 감싸 처리할 수 있었습니다.

Table of contents

Open Table of contents

들어가며

Private Server 프로젝트의 네트워크 계층은 C++/IOCP로 구현했지만, 실제 게임 화면과 입력 처리는 Godot .NET의 C#으로 작성했습니다. 그래서 C++로 작성한 NetworkRuntime을 C#에서 사용하려면 두 언어 사이를 연결하는 경계가 필요했습니다.

다행히 .NET 실행 환경은 네이티브 함수 호출 기능인 P/Invoke를 지원합니다. DLL에서 내보낸 함수 이름, 매개변수의 바이너리 표현과 호출 규약이 정확히 일치하면 C#에서 네이티브 함수를 호출할 수 있습니다.

flowchart
    Godot["Godot C# Scene"]
    Managed["관리 어댑터"]
    PInvoke["P/Invoke"]
    CAbi["C ABI 브리지 DLL"]
    Runtime["C++ NetworkRuntime DLL"]
    Iocp["IOCP and Winsock"]

    Godot --> Managed
    Managed --> PInvoke
    PInvoke --> CAbi
    CAbi --> Runtime
    Runtime --> Iocp

범위: 이 글에서 말하는 ABI는 Windows x64와 MSVC, .NET 8을 기준으로 한 현재 프로젝트의 프로세스 내부 바이너리 계약입니다. C ABI를 사용한다고 해서 모든 운영체제와 컴파일러에서 동일한 바이너리가 동작하는 것은 아닙니다.

용어: 본문에서 NetworkRuntime은 C++/IOCP 네트워크 모듈을 뜻합니다. .NET 실행 환경이나 C# 실행 환경은 언어 런타임을 뜻합니다. 코드 심볼과 공식 명칭을 제외한 본문 표기는 네이티브, 관리 코드, 핸들, 포인터, 페이로드와 수명으로 통일합니다.


1. C#에서 C++ 객체를 직접 호출하기는 어렵다

앞서 잠깐 언급했지만 C#에서는 C++로 작성된 코드를 직접 호출하기 어렵습니다. 그래서 P/Invoke를 사용하면서 기존 C++ 코드를 P/Invoke에서 사용할 수 있는 규격으로 바꾸기 위해 C ABI를 만들어야 했습니다.

1.1 DLL과 ABI

DLL은 실행 중인 프로세스에 코드와 데이터를 동적으로 적재하기 위한 바이너리 형식입니다.

ABI(Application Binary Interface)는 그 바이너리를 호출할 때 필요한 더 구체적인 계약입니다.

ABI에는 다음 내용이 포함됩니다.

P/Invoke에서 이러한 정보를 알고 있다면 C++로 빌드한 DLL의 함수를 바로 호출할 수 있겠지만, C++의 빌드 결과물은 일반적으로 C의 결과물에 비해 보다 복잡한 인터페이스를 가지고 있습니다. 때문에 C#에서 사용할 수 있는 ABI 코드는 C++을 바로 사용하는 대신 중간에 C 스타일의 ABI를 제공해 C++과 C# 사이에 존재하는 차이를 작고 명시적인 범위의 계약으로 한정짓게 만들었습니다.

C 스타일로 계약을 만들면 다음이 명확해집니다.

그래서 프로젝트에는 두 개의 네이티브 DLL이 있습니다.

PrivateServer.NetworkRuntime.dll
    C++ public API와 IOCP transport 구현

PrivateServer.NetworkRuntime.CAbi.dll
    C++ public API를 호출하고 C ABI를 내보내는 브리지

1.2 C++ 클래스 ABI는 컴파일러 계약에 결합된다

추가로 NetworkRuntime의 DLL 인터페이스도 주의해야 합니다.

다음처럼 클래스 전체를 DLL 인터페이스로 내보낼 수 있습니다.

class NETWORK_API Client
{
public:
    std::vector<std::byte> Receive();

private:
    std::mutex mutex_;
    std::vector<std::byte> receiveBuffer_;
};

하지만 이 인터페이스를 사용하는 호출자는 public 함수뿐 아니라 Client 객체의 크기와 private 멤버 레이아웃도 알아야 합니다. 결과적으로 다음 항목이 ABI의 일부가 됩니다.

private 멤버는 소스 수준의 접근만 막습니다. 호출자가 스택이나 자신의 힙에 Client 객체를 만들려면 컴파일러는 여전히 전체 객체 크기를 알아야 합니다.

1.3 STL과 템플릿을 경계에 노출하면 바이너리가 함께 묶인다

헤더에 STL이나 템플릿이 있다는 사실 자체가 항상 잘못된 것은 아닙니다. 같은 도구 체인으로 전체 프로그램을 함께 빌드하고 모든 사용자를 동시에 다시 배포할 수 있다면 C++ 인터페이스를 그대로 사용하는 방법도 가능합니다.

문제는 이것을 장기 바이너리 계약으로 사용할 때 생깁니다.

템플릿과 inline 함수는 이를 include한 번역 단위에서 인스턴스화 또는 코드 생성이 일어납니다. DLL과 EXE가 같은 헤더를 사용하면 각 바이너리 이미지에 코드와 정적 상태가 따로 생길 수 있습니다. 또한 STL 객체가 DLL 경계를 통과하면 양쪽 모듈이 객체 레이아웃과 할당자에 관해 동일한 전제를 가져야 합니다.

특히 한 모듈에서 할당한 메모리를 다른 모듈의 할당자로 해제하면 문제가 발생합니다.

DLL A의 new 또는 할당자
-> 포인터를 EXE에 반환
-> EXE가 자신의 delete 또는 할당자로 해제
-> 할당자/CRT 계약이 다르면 힙 손상 가능

그래서 ABI 경계에서는 데이터를 값으로 복사하거나, 자원을 생성한 모듈이 제공하는 destroy 함수로 다시 돌려주는 방식을 사용합니다.

1.4 Pimpl은 C++ DLL 경계를 줄여준다

1.2절과 1.3절에서 언급한 문제는 C++ DLL 프로젝트에서 자주 발생하는 문제입니다. 이 문제를 해결하는 방식은 DLL에서 제공하는 인터페이스에서 템플릿, STL과 같이 호출자와 DLL의 구현부가 나누어질 수 있는 가능성이 있는 요소들을 제거하는 것입니다. 세부 구현 내용은 헤더가 아닌 소스 파일에서 제공하면 됩니다.

NetworkRuntime 프로젝트는 클라이언트가 서버와의 연결 및 소켓 통신을 위해 NrClient라는 클래스를 제공하고 있습니다.

class NrClient final
{
public:
    PSNR_API NrClient() noexcept;
    PSNR_API ~NrClient() noexcept;

    [[nodiscard]] static PSNR_API NrStatus Create(
        const NrClientConfig& config,
        NrClient* outClient) noexcept;

    [[nodiscard]] PSNR_API NrStatus Send(
        NrPacketType packetType,
        NrByteView payload) noexcept;

private:
    struct Impl;
    Impl* impl_ = nullptr;
};

public 헤더에는 포인터 Impl*만 남고 소켓 통신에 필요한 Windows IOCP 핸들, 스레드, 큐와 STL 컨테이너는 DLL 내부로 이동합니다. 이 구조는 C++ 사용자가 구현 레이아웃에 결합되는 범위를 줄여 줍니다.

하지만 NrClient는 여전히 생성자, 소멸자, 참조와 namespace를 사용하는 C++ 클래스 ABI입니다. 따라서 DLL을 호출하는 호출자가 C#으로 작성된 프로그램이라면 아직 이 두 언어 사이의 명확한 계약이 만들어진 것은 아닙니다. DLL의 인터페이스를 이런 방식으로 고정하여 잘못 사용할 수 있는 가능성을 줄이고, C 방식으로 평탄화 작업을 진행하는 것입니다.


2. C++ 객체를 C ABI로 평탄화한다

이제 본격적으로 C 스타일의 ABI를 만드는 과정을 살펴봅니다. 이 과정의 개념은 간단합니다. C++과 C# 사이의 간극을 C로 연결해주는 것입니다. C는 C++과 C#에서 제공하는 다양한 기능 대신 기본적인 기능과 간단한 규칙만 존재합니다. 이 기능과 규칙은 C++, C#이 모두 쉽게 이해할 수 있고, 이것이 C 스타일의 ABI를 정의하는 이유가 됩니다.

C는 클래스, 상속과 같은 복잡하고 다양한 기능을 지원하지 않습니다. 하지만 이 덕분에 C++로 작성된 코드를 C로 만드는 규칙은 생각보다 간단해집니다. 이제 그 규칙을 하나씩 살펴보겠습니다.

2.1 불투명 핸들은 객체의 식별자만 노출한다

C ABI 헤더에서는 C++ 클래스 정의 대신 C 스타일의 구조체를 선언합니다.

typedef struct psnr_client psnr_client;
typedef struct psnr_client_event psnr_client_event;psnr_cabi.h

헤더를 include한 호출자는 psnr_client의 크기와 멤버를 알 수 없습니다. 따라서 값을 직접 만들거나 내부 상태에 접근할 수 없고, psnr_client*를 핸들처럼 보관했다가 C ABI 함수에 다시 전달할 수만 있습니다.

실제 정의는 브리지 DLL의 .cpp에 둡니다.

struct psnr_client final
{
    explicit psnr_client(NrClient&& clientValue) noexcept
        : client(std::move(clientValue))
    {
    }

    NrClient client;
};psnr_cabi.cpp

이제 C#은 NrClient의 레이아웃을 몰라도 포인터 크기의 핸들만으로 같은 네이티브 객체를 식별할 수 있습니다.

2.2 생성자와 소멸자 대신 생성/파괴 함수를 제공한다

C++ 생성자와 소멸자도 C 함수로 바꿉니다.

PSNR_CABI psnr_status PSNR_CABI_CALL psnr_client_create(
    const psnr_client_config* config,
    psnr_client** out_client);

PSNR_CABI void PSNR_CABI_CALL psnr_client_destroy(
    psnr_client* client);psnr_cabi.h

구현에서는 NrClient를 생성한 뒤 wrapper에 넣습니다.

psnr_status PSNR_CABI_CALL psnr_client_create(
    const psnr_client_config* config,
    psnr_client** out_client)
{
    if (config == nullptr || out_client == nullptr)
    {
        return InvalidArgumentStatus();
    }

    NrClientConfig nativeConfig;
    nativeConfig.eventQueueCapacity = config->event_queue_capacity;
    nativeConfig.payloadQueueCapacity = config->payload_queue_capacity;

    NrClient nativeClient;
    const NrStatus status = NrClient::Create(nativeConfig, &nativeClient);

    if (status.Failed())
    {
        return ToCAbiStatus(status);
    }

    psnr_client* client =
        new (std::nothrow) psnr_client(std::move(nativeClient));

    if (client == nullptr)
    {
        return psnr_status{PSNR_ERROR_OUT_OF_MEMORY, 0};
    }

    *out_client = client;
    return psnr_status{PSNR_ERROR_SUCCESS, 0};
}

void PSNR_CABI_CALL psnr_client_destroy(psnr_client* client)
{
    delete client;
}psnr_cabi.cpp

핵심은 newdelete가 모두 같은 브리지 DLL에서 실행된다는 점입니다. C#은 가공하지 않은 포인터의 주소만 전달하고 실제 C++ 소멸자나 할당자를 선택하지 않습니다.

C ABI create
-> 브리지 DLL에서 객체 할당
-> 불투명 포인터 반환
-> 호출자가 핸들로 보관
-> C ABI destroy
-> 브리지 DLL에서 객체 파괴

2.3 extern "C"와 호출 규약을 고정한다

C++ 컴파일러는 overload와 namespace 같은 정보를 심볼 이름에 인코딩합니다. 이를 name mangling이라고 합니다. C#의 P/Invoke가 예측 가능한 이름으로 함수를 찾을 수 있도록 C linkage를 지정합니다.

#if defined(__cplusplus)
extern "C"
{
#endif

PSNR_CABI psnr_status PSNR_CABI_CALL psnr_client_disconnect(
    psnr_client* client);

#if defined(__cplusplus)
}
#endifpsnr_cabi.h

Windows export 매크로와 호출 규약도 헤더에 둡니다.

#if defined(PSNR_CABI_EXPORTS)
#define PSNR_CABI __declspec(dllexport)
#else
#define PSNR_CABI __declspec(dllimport)
#endif

#if defined(_MSC_VER)
#define PSNR_CABI_CALL __cdecl
#else
#define PSNR_CABI_CALL
#endifpsnr_cabi.h

브리지 DLL을 빌드할 때는 PSNR_CABI_EXPORTS를 정의해 dllexport를 사용하고, C#에서는 같은 함수를 CallingConvention.Cdecl로 선언합니다.

x64 Windows에서는 일반적인 호출 규약이 하나로 통합되어 있지만, 계약을 코드에 명시하면 x86 빌드나 다른 도구 체인을 검토할 때 의도가 유지됩니다. extern "C"도 모든 아키텍처에서 심볼이 완전히 장식되지 않는다는 뜻은 아니므로 실제 산출물은 dumpbin /exports로 확인하는 편이 안전합니다.


3. C ABI 데이터 계약을 고정한다

3.1 고정 폭 스칼라와 단순 구조체를 사용한다

C ABI는 C++ 타입 대신 크기가 명확한 스칼라와 단순 구조체로 구성합니다.

typedef struct psnr_status
{
    uint32_t error_code;
    uint32_t native_error_code;
} psnr_status;

typedef struct psnr_client_config
{
    uint32_t event_queue_capacity;
    uint32_t payload_queue_capacity;
} psnr_client_config;

typedef struct psnr_byte_view
{
    const uint8_t* data;
    uint32_t size;
} psnr_byte_view;psnr_cabi.h

std::size_t는 아키텍처에 따라 크기가 달라질 수 있으므로 public 개수와 크기에는 uint32_t 또는 uint64_t를 사용합니다. C++ bool도 ABI 표현이 필요한 곳에서는 피하고 명시적인 정수값을 사용합니다.

네이티브 enum을 그대로 cast하지 않고 C ABI 전용 숫자를 정의합니다.

enum
{
    PSNR_ERROR_SUCCESS = 0,
    PSNR_ERROR_INVALID_ARGUMENT = 1,
    PSNR_ERROR_INVALID_STATE = 2,
    PSNR_ERROR_OUT_OF_MEMORY = 3,
    PSNR_ERROR_QUEUE_FULL = 5,
    PSNR_ERROR_QUEUE_EMPTY = 6,
    PSNR_ERROR_UNKNOWN = UINT32_MAX,
};psnr_cabi.h

브리지에서는 C++ enum의 선언 순서에 의존하지 않고 switch로 C ABI 값에 변환합니다. 내부 enum에 값이 추가되더라도 외부에 공개한 숫자의 의미를 조용히 바꾸지 않기 위해서입니다.

3.2 레이아웃을 양쪽에서 동일하게 해석한다

C ABI 구조체는 필드 타입이 같아도 정렬과 패딩 때문에 예상과 다른 크기가 될 수 있습니다. 네이티브 쪽에서는 컴파일 타임 assertion으로 중요한 레이아웃을 확인합니다.

static_assert(sizeof(psnr_ipv4_endpoint) == 8);
static_assert(offsetof(psnr_ipv4_endpoint, port) == 4);
static_assert(offsetof(psnr_byte_view, size) == sizeof(void*));
static_assert(sizeof(psnr_client_snapshot) == 72);
static_assert(
    offsetof(psnr_client_snapshot, pending_connect_io_count) == 8);psnr_cabi.cpp

C#에서는 StructLayout(LayoutKind.Sequential)과 동일한 필드 순서를 사용합니다.

[StructLayout(LayoutKind.Sequential)]
internal readonly struct NativeStatus
{
    internal readonly uint ErrorCode;
    internal readonly uint NativeErrorCode;
}

[StructLayout(LayoutKind.Sequential)]
internal struct NativeByteView
{
    internal IntPtr Data;   // 8바이트 포인터에 대응
    internal uint Size;     // uint32_t에 대응
}NativeMethods.cs

psnr_byte_view의 포인터는 x64에서 8바이트이고 그 뒤에 uint32_t가 배치됩니다. 따라서 전체 구조체의 크기나 후속 필드 오프셋은 포인터 크기와 정렬의 영향을 받습니다.

이 구조체는 같은 프로세스에서 DLL 함수를 호출하기 위한 ABI 계약입니다. TCP로 전송하는 패킷의 wire format과는 별개의 계약이며, byte order나 protocol version도 서로 다른 위치에서 관리합니다.

3.3 예외 대신 상태값을 반환한다

C++ 예외를 C ABI 밖으로 전파하면 C# 실행 환경은 그 예외 타입과 unwind 방식을 이해할 수 없습니다. 프로젝트의 C++ public 연산은 noexceptNrStatus를 사용하고, 브리지의 할당은 new (std::nothrow)를 사용합니다.

네이티브 연산 결과
-> NrStatus
-> C ABI 전용 psnr_status
-> 관리 코드의 NetworkRuntimeStatus

error_code는 호출자가 분기할 수 있는 안정적인 범주이고, native_error_code는 Windows 또는 Winsock 진단값을 보존합니다.

모든 네이티브 실패를 곧바로 C# 예외로 바꾸지는 않습니다. Connect, Send, Shutdown처럼 호출자가 처리할 수 있는 연산 결과는 NetworkRuntimeStatus로 반환합니다. 반면 네이티브 클라이언트 생성 실패나 ABI accessor의 계약 위반처럼 정상 흐름을 이어 갈 수 없는 경우에는 관리 어댑터가 NetworkRuntimeException을 발생시킵니다.


4. C#에서 C ABI DLL을 호출한다

C#에서도 평탄화된 코드를 C#이 이해할 수 있도록 번역해주는 레이어가 필요합니다. 이 레이어는 P/Invoke를 이용해 C ABI의 함수를 호출하고, C의 타입을 C#에서 사용할 수 있도록 변환해주는 역할을 하게 됩니다.

4.1 P/Invoke 시그니처를 C 헤더와 맞춘다

C#에서는 C ABI 헤더와 동일한 함수 이름, 필드 크기와 호출 규약으로 P/Invoke를 선언합니다.

// P/Invoke로 사용할 DLL 라이브러리 이름
private const string CAbiLibrary =
    "PrivateServer.NetworkRuntime.CAbi";

[DllImport(
    CAbiLibrary,
    CallingConvention = CallingConvention.Cdecl)]
internal static extern NativeStatus psnr_client_create(
    in NativeClientConfig config,
    out IntPtr outClient);

[DllImport(
    CAbiLibrary,
    CallingConvention = CallingConvention.Cdecl)]
internal static extern void psnr_client_destroy(IntPtr client);NativeMethods.cs

P/Invoke 시그니처가 실제 C 함수와 다르면 항상 친절하게 예외가 발생하는 것은 아닙니다. 매개변수 크기, 포인터 방향 또는 호출 규약이 다르면 잘못된 주소를 읽거나 스택과 레지스터 값을 잘못 해석할 수 있습니다.

따라서 실제 C 헤더를 source of truth로 두고 다음 항목을 함께 검토해야 합니다.

4.2 Godot 호스트에서 네이티브 DLL 경로를 해석한다

Managed 어셈블리가 일반 콘솔 애플리케이션에서 실행될 때는 .NET의 기본 네이티브 라이브러리 검색 규칙으로 DLL을 찾을 수 있습니다. 하지만 이 프로젝트에서 사용한 Godot .NET editor 호스트는 Managed 어셈블리를 메모리에서 읽어 Assembly.Location이 비어 있는 환경이었습니다.

그래서 프로젝트에서는 module initializer에서 어셈블리별 resolver를 등록했습니다. 이를 통해 Assembly.Location이 Godot 호스트에서도 런타임에 로드되어 정상적인 경로를 인식할 수 있도록 설정해주었습니다.

[ModuleInitializer]
internal static void InitializeNativeLibraryResolver()
{
    NativeLibrary.SetDllImportResolver(
        typeof(NativeMethods).Assembly,
        ResolveNativeLibrary);
}

private static IntPtr ResolveNativeLibrary(
    string libraryName,
    Assembly assembly,
    DllImportSearchPath? searchPath)
{
    if (!string.Equals(
        libraryName,
        CAbiLibrary,
        StringComparison.Ordinal))
    {
        return IntPtr.Zero;
    }

    string libraryPath = Path.Combine(
        AppContext.BaseDirectory,
        $"{CAbiLibrary}.dll");

    return NativeLibrary.Load(libraryPath);
}NativeMethods.cs

Godot 빌드 출력에는 다음 파일을 같은 아키텍처와 구성으로 배치합니다.

PrivateServer.GameClient.dll
PrivateServer.NetworkRuntime.Managed.dll
PrivateServer.NetworkRuntime.CAbi.dll
PrivateServer.NetworkRuntime.dll

resolver는 C ABI DLL의 절대 경로를 정하고, C ABI DLL이 import하는 PrivateServer.NetworkRuntime.dll은 Windows loader가 같은 출력 디렉터리에서 해석합니다.


5. SafeHandle이 네이티브 객체 수명을 소유한다

C#은 C++과 다르게 GC가 존재하여 프로그래머가 직접 메모리를 관리하지 않아도 됩니다. 하지만 NetworkRuntime은 C++로 만들어진 코드이고, 이 코드를 C#에서 사용할 때 객체의 수명이 어떻게 관리되는지 확인할 필요가 있습니다.

5.1 IntPtr만으로는 소유권을 표현하기 어렵다

우선 C ABI에서 반환받은 클라이언트 객체는 단순한 IntPtr 타입입니다. opaque handle을 받기 위해 단순하게 8바이트의 주소값을 받을 수 있게 설계되어있습니다.

그러나 P/Invoke에서 받은 IntPtr를 애플리케이션 코드 전체에 그대로 노출하면 다음 문제가 생깁니다. 왜냐하면 C#에서 IntPtr은 그저 단순한 정수 주소를 가리키는 타입이기 때문입니다. IntPtr로만 Native 객체를 관리하면 다음과 같은 부분에서 문제가 발생하기 쉽습니다.

그래서 Managed 어댑터는 불투명 포인터를 SafeHandle로 한 번 감싸 사용하게 됩니다.

internal sealed class SafeClientHandle : SafeHandle
{
    internal SafeClientHandle(IntPtr client)
        : base(IntPtr.Zero, ownsHandle: true)
    {
        SetHandle(client);
    }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        NativeMethods.psnr_client_destroy(handle);
        return true;
    }
}SafeHandles.cs

SafeHandle은 C#에서 네이티브 리소스의 수명을 관리하기 위한 .NET의 추상 클래스입니다. 여기서 관리하는 핸들은 Windows의 HANDLE만 의미하지 않습니다. 위의 예시처럼 네이티브 객체를 가리키는 불투명 포인터도 핸들로 취급할 수 있습니다.

typedef struct psnr_client psnr_client;

psnr_client* psnr_client_create();
void psnr_client_destroy(psnr_client* client);

// C#에서는 psnr_client*를 이해할 수 없으므로 IntPtr로 받음
// IntPtr client;

IntPtr은 숫자로 표현된 포인터를 C#에서 나타내는 타입입니다. 실제 클라이언트 객체에 대한 다음 정보는 가지고 있지 않습니다.

그래서 SafeHandle을 이용해 IntPtr을 C#에서 이해할 수 있는 클래스 타입으로 표현하고 관리합니다.

또한 SafeHandle은 그 자체로 IDisposable을 구현하고 있습니다. Dispose가 호출되면 내부에서 대략 다음과 같이 처리가 진행됩니다.

따라서 SafeClientHandle에는 별도의 Dispose를 구현하지 않고, 최종적으로 네이티브 리소스를 반환하는 ReleaseHandle을 구현했습니다. 외부의 NetworkRuntimeClient.Dispose는 내부 SafeHandle.Dispose를 호출합니다.

5.2 Dispose와 finalization의 역할을 구분한다

정상 경로에서는 위에서 설명한 것처럼 Dispose를 호출해 C#이 소유한 네이티브 객체의 리소스를 결정적으로 해제합니다. 진행 중인 P/Invoke가 있다면 해당 호출이 끝난 뒤 실제 ReleaseHandle이 실행됩니다.

그러나 어떠한 이유로 Dispose를 호출하지 못하더라도 C#의 GC가 SafeHandle 객체를 수거해 critical finalizer를 실행하고, 이를 통해 네이티브 객체의 리소스를 해제합니다.

정상 경로에서는 Managed 코드의 소유자가 명시적으로 Dispose합니다.

public sealed class NetworkRuntimeClient : IDisposable
{
    private readonly SafeClientHandle handle;

    public void Dispose()
    {
        handle.Dispose();
    }
}NetworkRuntimeClient.cs

5.3 ShutdownDispose는 같은 동작이 아니다

NetworkRuntime 클라이언트는 소켓, worker, pending I/O와 이벤트 큐를 소유합니다. 따라서 객체 메모리를 해제하는 것과 NetworkRuntime 작업을 순서대로 종료하는 것은 구분해야 합니다.

Shutdown
-> 새 작업 admission 중단
-> 소켓 close와 pending I/O drain
-> worker 종료와 join
-> NetworkRuntime 수명주기를 Shutdown 상태로 전환

Dispose
-> SafeHandle 해제
-> C ABI destroy
-> C++ 객체 소멸자 실행

Godot의 씬 정리 작업에서는 Shutdown을 먼저 호출해 명시적인 NetworkRuntime 종료와 drain을 요청하고, 마지막에 Dispose로 네이티브 객체 자체를 해제합니다. C++ 소멸자도 안전한 fallback 정리를 수행하지만, 정상 종료 순서를 명시하면 실패 상태와 drain 결과를 먼저 관측할 수 있습니다.


6. 페이로드 소유권을 ABI 경계에서 전환한다

객체 핸들보다 더 주의해야 하는 부분은 바이트 버퍼의 수명이었습니다. 포인터와 크기만 전달하면 데이터를 복사하지 않고 볼 수 있지만, 그 포인터가 언제까지 유효한지를 별도로 정해야 합니다.

프로젝트에서는 송신과 수신에 서로 반대 방향의 소유권 복사를 사용합니다.

flowchart LR
    ManagedSend["관리 코드 소유 송신 바이트"]
    BorrowedSend["빌린 송신 뷰"]
    NativeFrame["NetworkRuntime 소유 프레임"]

    NativeEvent["네이티브 이벤트 소유 페이로드"]
    BorrowedReceive["빌린 수신 뷰"]
    ManagedReceive["관리 코드 소유 바이트 배열"]

    ManagedSend --> BorrowedSend
    BorrowedSend -->|"Send 반환 전 복사"| NativeFrame

    NativeEvent --> BorrowedReceive
    BorrowedReceive -->|"이벤트 해제 전 복사"| ManagedReceive

6.1 송신은 P/Invoke가 반환되기 전에 NetworkRuntime 소유권으로 바꾼다

Managed API는 ReadOnlySpan<byte>를 받습니다.

public unsafe NetworkRuntimeStatus Send(
    uint packetType,
    ReadOnlySpan<byte> payload)
{
    ThrowIfDisposed();

    // Managed Memory 주소가 GC에 의해 이동하지 않도록 고정
    fixed (byte* payloadPointer = payload)
    {
        NativeMethods.NativeStatus nativeStatus =
            NativeMethods.psnr_client_send(
                handle,
                packetType,
                (IntPtr)payloadPointer,
                checked((uint)payload.Length));

        return new NetworkRuntimeStatus(nativeStatus);
    }
}NetworkRuntimeClient.cs

fixed는 P/Invoke가 실행되는 동안 관리 메모리의 주소가 GC에 의해 이동하지 않도록 합니다. 이 포인터를 IOCP 송신 완료까지 계속 보관하는 것은 안전하지 않습니다. fixed 범위가 끝나면 GC는 다시 해당 객체를 이동할 수 있고, 스택 기반 span은 호출 뒤 존재하지 않을 수도 있습니다.

그래서 NetworkRuntimeSend는 성공을 반환하기 전에 호출자의 바이트를 NetworkRuntime이 소유하는 불변 프레임으로 복사하고 큐에 게시합니다.

관리 코드의 ReadOnlySpan
-> fixed 포인터로 C ABI 호출
-> C ABI가 빌린 NrByteView 생성
-> NetworkRuntime이 프레임 헤더와 페이로드를 소유 블록으로 복사
-> 제한된 송신 큐에 게시
-> Send 반환
-> 원본 관리 버퍼와 수명 분리

이후 WSASend, partial completion과 repost는 NetworkRuntime이 소유하는 같은 프레임을 유지합니다. Managed 버퍼를 장기간 유지하지 않은 상태에서 비동기 I/O 수명을 NetworkRuntime 안에서 해결할 수 있습니다.

6.2 수신은 네이티브 이벤트를 해제하기 전에 관리 코드 소유권으로 바꾼다

수신 이벤트는 반대 방향으로 이동합니다. 네이티브 psnr_client_event가 페이로드를 소유하며, C ABI accessor는 그 페이로드를 psnr_byte_view로 빌려 줍니다.

PSNR_CABI psnr_status PSNR_CABI_CALL
psnr_client_event_get_payload(
    const psnr_client_event* event,
    psnr_byte_view* out_payload);psnr_cabi.h

이 뷰는 이벤트가 살아 있는 동안에만 유효합니다.

psnr_client_event가 payload 소유
-> psnr_byte_view가 같은 메모리를 가리킴
-> psnr_client_event_destroy
-> 뷰의 포인터는 더 이상 유효하지 않음

관리 어댑터는 이벤트 핸들의 수명 안에서 페이로드를 byte[]로 복사합니다.

private static byte[] CopyPayload(
    NativeMethods.NativeByteView nativePayload)
{
    if (nativePayload.Size == 0)
    {
        return Array.Empty<byte>();
    }

    if (nativePayload.Data == IntPtr.Zero)
    {
        throw new InvalidOperationException(
            "Native event returned an invalid payload.");
    }

    int payloadSize = (int)nativePayload.Size;
    byte[] payload = new byte[payloadSize];
    Marshal.Copy(
        nativePayload.Data,
        payload,
        0,
        payloadSize);

    return payload;
}NetworkRuntimeClient.cs

이벤트 핸들은 finally에서 해제합니다.

SafeClientEventHandle eventHandle =
    new SafeClientEventHandle(eventPointer);

try
{
    clientEvent = ReadEvent(eventHandle);
    return true;
}
finally
{
    eventHandle.Dispose();
}NetworkRuntimeClient.cs

ReadEvent가 성공하면 반환된 NetworkRuntimeEvent.Payload는 관리 코드가 소유하는 메모리입니다. 이후 네이티브 이벤트가 파괴되거나 C++ 페이로드 블록이 풀로 돌아가도 Managed 이벤트의 바이트는 독립적으로 생존합니다.

이 복사는 불필요한 중복이라기보다 소유권 경계를 바꾸기 위한 비용입니다. zero-copy를 적용하려면 네이티브 메모리 lease를 관리 영역에 장기간 노출하고, 모든 사용자가 정확한 시점에 반환하도록 더 복잡한 계약을 만들어야 합니다. 현재 게임 클라이언트 workload에서는 명확한 수명을 우선했습니다.


7. 네이티브 콜백 대신 메인 스레드에서 이벤트를 가져온다

네이티브 worker가 패킷을 받을 때마다 C# 콜백을 호출하는 방식도 가능합니다. 하지만 이 구조에는 추가 수명과 스레드 문제가 생깁니다.

프로젝트에서는 네이티브 worker가 C# 객체를 직접 호출하지 않습니다. worker는 전송과 이벤트 게시만 처리하고, Godot 소유자 스레드가 TryPopEvent를 호출합니다.

IOCP worker
-> 네이티브 이벤트 큐에 소유 이벤트 게시

Godot 메인 스레드
-> 프레임마다 정해진 처리량으로 TryPopEvent
-> C ABI 이벤트 accessor 호출
-> 관리 DTO와 byte[]로 변환
-> 네이티브 이벤트 핸들 해제
-> gameplay와 presentation에서 소비

현재 클라이언트는 Godot 메인 스레드에서 이벤트를 소비하고 서버에서 응답하는 일정한 주기의 데이터를 처리하고 있기에, 한 프레임에서 처리할 수 있는 패킷의 양을 제한하는 bounded queue와 일정 수의 drain을 진행하도록 구성하였습니다.

또한 Managed 어댑터는 Godot 타입, gameplay DTO와 씬 수명주기를 알지 않습니다. Godot가 아닌 콘솔 smoke에서도 같은 NetworkRuntimeClient를 사용할 수 있어 엔진 통합과 전송 계약을 분리할 수 있었습니다.


8. 경계에서 발생하는 실패를 분류한다

Interop 오류는 일반 애플리케이션 예외보다 프로세스 손상으로 이어지기 쉽습니다. 다음 항목을 ABI 계약과 검증 대상으로 두었습니다.

잘못된 계약발생 가능한 결과적용한 대응
C++ 클래스와 STL을 직접 노출도구 체인과 레이아웃 결합Pimpl과 C ABI 불투명 핸들
C++ name mangling을 그대로 내보냄entry point 탐색 실패extern "C"와 export 검사
호출 규약 불일치매개변수 또는 스택 해석 오류양쪽에 Cdecl 명시
구조체 필드 크기 불일치잘못된 필드값과 메모리 접근고정 폭 타입과 레이아웃 assertion
C++ 예외가 ABI 밖으로 전파unwind 계약 불일치noexcept, nothrow와 상태값 반환
다른 모듈에서 객체 해제할당자 불일치와 힙 손상create/destroy 함수 pairing
IntPtr 중복 해제double free 또는 use-after-freeSafeHandle과 disposed 검사
fixed 포인터를 비동기로 보관GC 이동 뒤 dangling pointerSend 반환 전 NetworkRuntime 복사
이벤트 해제 뒤 빌린 뷰 사용use-after-free이벤트 수명 안에서 Marshal.Copy
네이티브 콜백이 씬을 직접 변경스레드 affinity와 teardown 경합메인 스레드 bounded pull
x64 프로세스에 x86 DLL 배치loader 실패빌드 아키텍처와 출력 일치

Loader 오류도 원인에 따라 구분할 수 있습니다.


9. 작은 smoke로 ABI와 수명을 검증

ABI는 컴파일 성공만으로 충분히 검증되지 않습니다. 네이티브 빌드와 관리 코드 빌드가 각각 성공해도 실제 loader, marshalling과 수명 경로에서 실패할 수 있습니다.

프로젝트에서는 순수 C# 콘솔 smoke가 같은 프로세스에서 loopback TcpListener를 실행하고 다음 흐름을 확인합니다.

네이티브 클라이언트 create
-> loopback connect
-> TransportConnected event
-> C# 페이로드 send
-> peer echo
-> PacketReceived event
-> 네이티브 이벤트 파괴
-> 관리 페이로드 생존 확인
-> remote disconnect
-> Shutdown 두 번
-> Dispose 두 번

특히 다음 검증은 페이로드 소유권 전환의 증거입니다.

ReadOnlyMemory<byte> survivingPayload = packetEvent.Payload;

NetworkRuntimeEvent disconnectedEvent =
    WaitForEvent(client, timeout, "remote disconnect");

if (!survivingPayload.Span.SequenceEqual(expectedPayload))
{
    throw new InvalidOperationException(
        "Managed payload changed after native event destruction.");
}

packetEvent를 만들 때 사용한 네이티브 이벤트 핸들은 이미 TryPopEvent 내부에서 파괴됐습니다. 그 뒤 다른 이벤트를 기다리고도 페이로드가 유지되므로 Managed 코드의 복사본이 네이티브 수명과 분리됐음을 확인할 수 있습니다.


10. 작은 ABI가 버전 관리와 유지보수를 단순하게 만든다

C ABI도 한 번 공개하면 변경 비용이 사라지는 것은 아닙니다. 함수 시그니처, enum 값과 구조체 레이아웃은 여전히 바이너리 계약입니다.

현재 헤더에서는 다음 규칙을 사용합니다.

독립적인 배포와 하위 호환성이 더 중요해진다고 하면 버전 관리 수단도 필요합니다.

현재 프로젝트는 NetworkRuntime, C ABI 브리지, 관리 어댑터와 Godot 클라이언트를 같은 저장소에서 함께 빌드하고 배포합니다. 따라서 독립적인 third-party SDK 수준의 version negotiation까지 구현하지 않았습니다. 대신 작은 public surface와 명시적인 빌드 조합으로 변경 범위를 제한했습니다.


11. 현재 선택의 trade-off

이 구조로 얻은 가장 큰 장점은 수명과 종속성 방향이 명확해졌다는 점입니다.

Godot
-> 관리 어댑터
-> C ABI 브리지
-> C++ public NetworkRuntime
-> IOCP implementation

위 계층은 아래 계층을 알지만, NetworkRuntime은 C#, P/Invoke와 Godot를 알지 않습니다. 관리 어댑터도 gameplay DTO와 엔진 타입을 모릅니다.

반면 다음 비용이 있습니다.


정리하며

C++ DLL을 C#에서 사용하는 작업은 함수를 내보내고 [DllImport]를 붙이는 것만으로 끝나지 않았습니다. 실제로 설계해야 했던 것은 서로 다른 언어 실행 환경이 공유할 수 있는 바이너리 계약과 소유권이었습니다.

핵심 요약:

이번 작업을 통해 C ABI를 단순히 C++ 코드를 다른 언어에서 호출하기 위한 문법이 아니라, 함수와 데이터 레이아웃뿐 아니라 수명과 실패까지 전달하는 모듈 계약으로 이해할 수 있었습니다.

참고 자료


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


공유하기:

이전 글
[C++] World Server 3: 게임 로직의 계산과 상태 반영을 분리하다
다음 글
[C++] Actor Scheduling: Lost Wakeup을 막는 Admission Protocol