TL;DR — C#에서 C++ 객체를 직접 호출하는 대신 C++ 구현을 불투명 핸들(opaque handle) 뒤에 숨기고, 고정 폭 값과 C 함수로 구성한 C ABI 브리지를 만들었습니다. C#에서는 P/Invoke와
SafeHandle로 이 계약을 감싸 처리할 수 있었습니다.
Table of contents
Open Table of contents
- 들어가며
- 1. C#에서 C++ 객체를 직접 호출하기는 어렵다
- 2. C++ 객체를 C ABI로 평탄화한다
- 3. C ABI 데이터 계약을 고정한다
- 4. C#에서 C ABI DLL을 호출한다
- 5.
SafeHandle이 네이티브 객체 수명을 소유한다 - 6. 페이로드 소유권을 ABI 경계에서 전환한다
- 7. 네이티브 콜백 대신 메인 스레드에서 이벤트를 가져온다
- 8. 경계에서 발생하는 실패를 분류한다
- 9. 작은 smoke로 ABI와 수명을 검증
- 10. 작은 ABI가 버전 관리와 유지보수를 단순하게 만든다
- 11. 현재 선택의 trade-off
- 정리하며
- 참고 자료
들어가며
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 스타일로 계약을 만들면 다음이 명확해집니다.
extern "C"로 내보낸 함수 이름을 예측uint32_t처럼 크기가 명확한 데이터 타입을 사용- 객체 내부의 C++ 구현부를 불투명 핸들로 숨김
- C++의 생성자/소멸자를 C의 create/destroy 함수로 대신 표현
- 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의 일부가 됩니다.
std::vector와std::mutex의 구현 및 레이아웃- 컴파일러와 도구 체인 버전
- Debug와 Release의 CRT 및 iterator 디버깅 설정
- 클래스의 정렬과 패딩
- 멤버 추가 또는 순서 변경
- 객체를 할당하고 파괴할 때 사용하는 CRT와 할당자
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
핵심은 new와 delete가 모두 같은 브리지 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 연산은 noexcept와 NrStatus를 사용하고, 브리지의 할당은 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로 두고 다음 항목을 함께 검토해야 합니다.
const T*가 입력인지 빌린 출력인지T**가 새 핸들을 반환하는 출력 매개변수인지- 스칼라의 부호 유무와 비트 폭
- 구조체 필드 순서와 정렬
- null 포인터가 허용되는 조건
- 버퍼 포인터가 유효해야 하는 시간
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 객체를 관리하면 다음과 같은 부분에서 문제가 발생하기 쉽습니다.
- 어떤 코드가
destroy를 호출해야 하는지 알기 어렵습니다. - 예외나 조기 반환에서 해제를 빠뜨릴 수 있습니다.
- 같은 포인터를 두 번 해제할 수 있습니다.
- 해제된 포인터를 다른 연산에 다시 전달할 수 있습니다.
- GC는
IntPtr가 네이티브 리소스를 소유한다는 사실을 모릅니다.
그래서 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#에서 나타내는 타입입니다. 실제 클라이언트 객체에 대한 다음 정보는 가지고 있지 않습니다.
- 어떤 리소스를 소유하는지
- 어떤 함수로 해제해야 하는지
- 이미 해제됐는지
- GC가 수거할 때 해제해야 하는지
- P/Invoke 실행 중에 해제해도 되는지
그래서 SafeHandle을 이용해 IntPtr을 C#에서 이해할 수 있는 클래스 타입으로 표현하고 관리합니다.
- 기본적으로
IntPtr.Zero, 즉nullptr을 유효하지 않은 핸들로 사용 IsInvalid == true인 경우에는ReleaseHandle을 실행하지 않음ownsHandle: true는 이SafeHandle이 마지막에 네이티브 destroy 함수를 호출할 책임이 있다는 뜻ReleaseHandle은 네이티브 객체를 생성했던 C ABI DLL로 포인터를 되돌려 줌
또한 SafeHandle은 그 자체로 IDisposable을 구현하고 있습니다. Dispose가 호출되면 내부에서 대략 다음과 같이 처리가 진행됩니다.
Dispose호출- 이미 닫혔는지 확인
- 닫힌 상태로 전환
- P/Invoke 레퍼런스가 있는지 확인
ownsHandle이true인지 확인IsInvalid가false인지 확인ReleaseHandle실행
따라서 SafeClientHandle에는 별도의 Dispose를 구현하지 않고, 최종적으로 네이티브 리소스를 반환하는 ReleaseHandle을 구현했습니다. 외부의 NetworkRuntimeClient.Dispose는 내부 SafeHandle.Dispose를 호출합니다.
5.2 Dispose와 finalization의 역할을 구분한다
정상 경로에서는 위에서 설명한 것처럼 Dispose를 호출해 C#이 소유한 네이티브 객체의 리소스를 결정적으로 해제합니다. 진행 중인 P/Invoke가 있다면 해당 호출이 끝난 뒤 실제 ReleaseHandle이 실행됩니다.
그러나 어떠한 이유로 Dispose를 호출하지 못하더라도 C#의 GC가 SafeHandle 객체를 수거해 critical finalizer를 실행하고, 이를 통해 네이티브 객체의 리소스를 해제합니다.
SafeHandle은CriticalFinalizerObject를 상속하며 critical finalizer를 포함합니다.Dispose를 호출하지 않았을 때 마지막 네이티브 리소스를 해제하는 안전망 역할을 합니다.- 같은 GC 회수 과정에서 일반 finalizer가 호출된 뒤 critical finalizer가 실행되어 다른 관리 객체들이 정리되는 동안 네이티브 핸들이 너무 일찍 닫히는 것을 방지합니다.
- 실행 시점을 예측할 수 없으므로 정상적인 해제 방법으로 생각하면 안 됩니다.
정상 경로에서는 Managed 코드의 소유자가 명시적으로 Dispose합니다.
public sealed class NetworkRuntimeClient : IDisposable
{
private readonly SafeClientHandle handle;
public void Dispose()
{
handle.Dispose();
}
}NetworkRuntimeClient.cs
5.3 Shutdown과 Dispose는 같은 동작이 아니다
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은 호출 뒤 존재하지 않을 수도 있습니다.
그래서 NetworkRuntime의 Send는 성공을 반환하기 전에 호출자의 바이트를 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# 콜백을 호출하는 방식도 가능합니다. 하지만 이 구조에는 추가 수명과 스레드 문제가 생깁니다.
- 네이티브 코드가 저장한 delegate를 GC가 회수하지 않도록 root해야 합니다.
- 콜백이 IOCP worker 스레드에서 실행될 수 있습니다.
- Godot scene tree는 메인 스레드에서 다뤄야 합니다.
- 씬 teardown과 콜백 실행이 경합할 수 있습니다.
- 프레임 하나에서 처리할 이벤트 수가 제한되지 않을 수 있습니다.
프로젝트에서는 네이티브 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-free | SafeHandle과 disposed 검사 |
fixed 포인터를 비동기로 보관 | GC 이동 뒤 dangling pointer | Send 반환 전 NetworkRuntime 복사 |
| 이벤트 해제 뒤 빌린 뷰 사용 | use-after-free | 이벤트 수명 안에서 Marshal.Copy |
| 네이티브 콜백이 씬을 직접 변경 | 스레드 affinity와 teardown 경합 | 메인 스레드 bounded pull |
| x64 프로세스에 x86 DLL 배치 | loader 실패 | 빌드 아키텍처와 출력 일치 |
Loader 오류도 원인에 따라 구분할 수 있습니다.
DllNotFoundException: C ABI DLL 또는 종속성을 찾지 못함EntryPointNotFoundException: export 이름과 P/Invoke declaration이 다름BadImageFormatException: 프로세스와 DLL 아키텍처가 다름AccessViolationException: 포인터, 시그니처 또는 수명 계약 위반 가능성
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 값과 구조체 레이아웃은 여전히 바이너리 계약입니다.
현재 헤더에서는 다음 규칙을 사용합니다.
- enum 숫자의 기존 의미를 변경하지 않습니다.
- 새 내부 오류는 기존
public범주 또는UNKNOWN으로 변환합니다. - 입력
reserved필드는0만 허용합니다. - 함수의 null 포인터와 크기 조건을 명시합니다.
- 출력 매개변수는 성공했을 때만 변경하는 규칙을 유지합니다.
- 기존 객체를 확장할 때 C++ 멤버를 헤더에 추가하지 않습니다.
독립적인 배포와 하위 호환성이 더 중요해진다고 하면 버전 관리 수단도 필요합니다.
- ABI version 조회 함수
struct_size를 포함한 확장 가능한 config- capability query
- 새 기능을 기존 함수 변경이 아닌 새 export로 추가
- 지원하는 DLL 조합을 확인하는 startup validation
현재 프로젝트는 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++
publicAPI와 C ABI, 관리 API를 함께 유지해야 합니다. - 각 구조체와 enum mapping 코드가 필요합니다.
- 수신 페이로드를 관리 메모리로 한 번 복사합니다.
- 네이티브 DLL 두 개와 관리 DLL을 올바른 아키텍처로 배포해야 합니다.
- ABI 계약이 늘어날수록 버전 관리 정책도 복잡해집니다.
정리하며
C++ DLL을 C#에서 사용하는 작업은 함수를 내보내고 [DllImport]를 붙이는 것만으로 끝나지 않았습니다. 실제로 설계해야 했던 것은 서로 다른 언어 실행 환경이 공유할 수 있는 바이너리 계약과 소유권이었습니다.
핵심 요약:
- Pimpl은 C++ DLL의 구현 레이아웃을 숨기지만 C#에서 직접 소비할 수 있는 C ABI는 아닙니다.
- C++ 객체는 불투명 핸들과 create/destroy 함수로 평탄화했습니다.
- C ABI에는 고정 폭 스칼라, 단순 구조체와 명시적인 enum 값만 노출했습니다.
- C#은 P/Invoke 시그니처를 C 헤더와 동일하게 선언하고
SafeHandle로 네이티브 객체를 소유합니다. - 송신은 P/Invoke 반환 전에 관리 바이트를
NetworkRuntime소유 프레임으로 복사합니다. - 수신은 네이티브 이벤트를 파괴하기 전에 빌린 뷰를 관리 코드가 소유하는
byte[]로 복사합니다. Shutdown은NetworkRuntime의 종료와 drain이고Dispose는 네이티브 객체 수명의 종료입니다.- 네이티브 콜백 대신 Godot 메인 스레드의 bounded pull model을 사용했습니다.
- ABI는 컴파일뿐 아니라 export, 레이아웃, round trip과 해제 뒤 페이로드 생존으로 검증해야 합니다.
이번 작업을 통해 C ABI를 단순히 C++ 코드를 다른 언어에서 호출하기 위한 문법이 아니라, 함수와 데이터 레이아웃뿐 아니라 수명과 실패까지 전달하는 모듈 계약으로 이해할 수 있었습니다.
참고 자료
- Portability at ABI boundaries — C++ class를 C ABI로 평탄화하는 Microsoft 지침
dllexport,dllimport— MSVC DLL export와 name decoration- C runtime과 C++ standard library 파일 — 여러 CRT와 할당자가 DLL 경계에서 만드는 문제
- Native interoperability best practices — P/Invoke signature,
SafeHandle과 blittable type 지침 SafeHandleclass — P/Invoke 중 핸들 수명과 critical finalizationNativeLibrary.SetDllImportResolver— 어셈블리별 네이티브 DLL resolver- Godot C# basics — Godot의 .NET 및 C# 실행 환경
- Godot Thread-safe APIs — active scene tree의 thread-safety와 메인 스레드 handoff
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.