C#에서 Win32 API를 안전하게 호출하기 — P/Invoke 실무 가이드(DllImport / LibraryImport / CsWin32)

· · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, 네이티브 상호운용, Windows 개발, 기술 상담

이 블로그에서는 지금까지 C++/CLI 래퍼와 P/Invoke의 구분 사용, C# Native AOT DLL을 C/C++에서 호출하는 방법, 32bit 앱에서 64bit DLL을 호출하는 COM 브리지, Windows DLL 이름 해석의 구조 등 네이티브 상호운용에 관한 글을 여러 편 써 왔습니다. 그런데 그 토대가 되는 P/Invoke 그 자체를 정리한 글은 아직 없었습니다.

P/Invoke는 “DLL의 함수를 extern으로 선언하면 호출할 수 있다”는 손쉬움을 가진 한편, 문자열 마샬링, 핸들의 수명, 에러 코드 획득, 구조체 레이아웃 중 어딘가에서 한 번은 사고를 내는 기술이기도 합니다. 이 글에서는 .NET 7 이후의 기본값인 LibraryImport를 중심으로, 실무에서 챙겨야 할 포인트를 한 번에 정리합니다.

1. 먼저 결론

  • .NET 7 이후라면 DllImport가 아니라 LibraryImport를 기본으로 삼습니다. 컴파일 시점에 마샬링 코드를 생성하기 때문에 Native AOT·트리밍에 대응하고, 실행 시점의 IL 스텁 생성 비용이 없으며, 생성된 코드를 디버거로 한 줄씩 실행할 수 있습니다. 분석기 SYSLIB1054DllImport를 고쳐야 할 부분을 알려줍니다.12
  • Win32 API를 손으로 작성한다면 CsWin32를 검토합니다. NativeMethods.txt에 호출할 함수명을 나열하는 것만으로, 공식 Win32 메타데이터로부터 LibraryImport 시그니처·상수·구조체를 생성해 줍니다.3
  • 문자열은 StringMarshalling을 명시하고, StringBuilder는 피합니다. StringBuilder 마샬링은 항상 네이티브 버퍼로의 복사를 수반하며, 비효율적인 데다 종료 처리를 잘못하기 쉬운 구조입니다.4
  • 핸들은 순수 IntPtr이 아니라 SafeHandle 파생 클래스로 들고 있습니다. GC에 의한 핸들의 조기 해제·이중 해제·”재활용 공격”을 막는, .NET 네이티브 상호운용의 기본 작법입니다.56
  • SetLastError = true를 붙였다면, 호출 직후에 Marshal.GetLastPInvokeError()를 읽습니다. 다른 관리 코드의 실행이 에러 코드를 덮어쓰기 전에 확보해야 합니다.7
  • 구조체는 LayoutKind.Sequential을 기본으로 하고, Pack을 명시할지 여부를 의식합니다. Pack = 0(기본값)의 실제 레이아웃은 C++ 컴파일러의 /Zp 기본값(x86/ARM/ARM64는 8, x64/ARM64EC는 16)과는 다른 것이며, .NET Framework와 .NET 5+ 사이에서도 달라질 수 있습니다. “기본값이니 맞을 것”이라는 생각은 금물입니다.8
  • 콜백(델리게이트)은 네이티브 쪽에서 다 쓰기 전까지 GC에 회수되지 않도록 수명을 관리합니다. static 필드로 보관하거나 GC.KeepAlive를 쓰고, 가능하면 UnmanagedCallersOnly를 우선합니다.9
  • P/Invoke·C++/CLI 래퍼·COM 상호운용은 경쟁 관계가 아니라 역할 분담입니다. 순수한 C 인터페이스라면 P/Invoke, C++의 클래스나 소유권·예외가 얽힌다면 C++/CLI, 프로세스 경계(32/64bit 브리지 등)를 넘어야 한다면 COM이라는 판단 기준을 10장의 표에 정리합니다.

2. DllImport와 LibraryImport — 어느 쪽을 쓸 것인가

DllImport는 예전부터 있던 방식으로, 실행 시점에 런타임이 마샬링용 IL 스텁을 생성하고 JIT로 컴파일한 뒤 호출합니다. 생성이 실행 시점에 일어나는 이상 Native AOT나 트리밍처럼 어셈블리를 사전 컴파일하는 구성과는 상성이 좋지 않고, 생성 비용 자체도 0은 아닙니다.1

LibraryImport는 .NET 7에서 추가된 소스 제너레이터로, partial 메서드에 대해 컴파일 시점에 마샬링 코드를 생성합니다. 생성된 코드는 C# 소스로 존재하기 때문에 디버거로 한 줄씩 실행할 수 있고, 시그니처의 실수는 빌드 에러로 빠르게 감지됩니다.1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

string 반환값에는 놓치기 쉬운 전제가 있습니다. 마샬러는 돌아온 포인터가 가리키는 문자열을 복사한 뒤, 그 메모리를 항상 해제하려고 시도합니다. Windows에서는 CoTaskMemFree가 사용되기 때문에, 네이티브 쪽이 CoTaskMemAlloc이 아닌 방식(정적 버퍼, malloc, new[] 등 C API에서는 흔한 구현)으로 그 포인터를 확보했다면, 마샬러가 잘못된 할당자로 메모리를 해제하게 되어 힙 손상이나 크래시로 이어집니다.10 상대의 헤더나 문서에서 CoTaskMemAlloc 호환 확보를 명시하지 않았다면, 반환값을 string이 아니라 IntPtr로 받아 그에 대응하는 해제 함수(또는 상대가 요구하는 해제 절차)를 직접 호출하는 설계로 만드는 것이 좋습니다. 호출 쪽에서 버퍼를 확보해서 넘기는 방식(앞서 나온 StringBuilder의 대안인 문자 배열이나, 뒤에서 다룰 [Out] 버퍼 패턴)을 쓰면, 애초에 이런 종류의 소유권 모호함을 들여오지 않아도 됩니다.

DllImport와의 주요 차이는 다음과 같습니다.11

  • CharSet이 폐지되고 StringMarshalling(Utf16 / Utf8 / 커스텀)으로 대체되었습니다. ANSI는 폐지되었고, UTF-8이 1급 옵션이 되었습니다.
  • CallingConventionUnmanagedCallConvAttribute로 대체되었습니다.
  • ExactSpellingPreserveSig에 해당하는 것은 없습니다. 엔트리 포인트명은 항상 정확한 철자를 지정하고, 반환값 변환은 항상 그대로 이루어집니다.
  • 클래스와 호출 대상 메서드 둘 다 partial이어야 하며, 프로젝트에는 AllowUnsafeBlocks가 필요합니다.

DllImport가 지금도 필요해지는 경우는 LibraryImport가 지원하지 않는 설정(예를 들면 일부 MarshalAs 지정)을 써야 할 때입니다. 분석기가 지원하지 않는 설정을 쓰려고 하면 에러로 알려주기 때문에, 먼저 LibraryImport를 써보고 막히면 DllImport로 되돌리는 방식이 현실적입니다.11

3. CsWin32 — 시그니처를 손으로 쓰지 않는 선택지

Win32 API를 하나씩 손으로 DllImport/LibraryImport 선언하다 보면, 파라미터 타입·상수 값·구조체 필드 순서를 틀리는 위험이 쌓입니다. CsWin32(Microsoft.Windows.CsWin32)는 공식이 제공하는 Win32 API 메타데이터로부터, 호출하고 싶은 함수의 시그니처·관련 상수·구조체를 자동 생성하는 소스 제너레이터입니다.3

사용법은 단순해서, 프로젝트에 NuGet 패키지를 추가하고 NativeMethods.txt라는 텍스트 파일에 호출할 함수명을 나열하기만 하면 됩니다.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

빌드 시점에 이 함수들의 P/Invoke 시그니처(반환값·파라미터·SetLastError 지정을 포함)가 생성됩니다. 기본값으로는 기존의 DllImport 기반으로 생성된다는 점에 주의해야 합니다. Native AOT·트리밍을 염두에 둔다면, NativeMethods.json에서 allowMarshaling: false를 지정해 LibraryImport 기반 소스 생성 코드로 전환할 수 있습니다.3 HANDLE은 적절한 SafeHandle 파생형으로, 문자열은 올바른 CharSet/StringMarshalling으로 출력되기 때문에, 손으로 작성할 때 흔히 발생하는 CharSet 오인이나 구조체 필드 순서 실수를 애초에 만들어낼 수 없습니다.

C++/CLI 래퍼가 효과적인 상황에서 다뤘듯이, C++의 클래스·소유권·예외가 얽힌 복잡한 DLL에는 얇은 래퍼를 끼우는 것이 효과적이지만, 상대가 순수한 Win32 API(또는 그에 준하는 C 인터페이스의 DLL)라면, CsWin32로 시그니처를 자동 생성하는 것이 가장 빠르고 사고가 적은 경로입니다. 자사 DLL에는 CsWin32를 쓸 수 없지만, 그 경우에도 생성된 코드의 작성 방식을 템플릿으로 참고할 수 있습니다.

4. 문자열 마샬링의 함정

C#·VB·F# 컴파일러는 CharSet을 명시하지 않은 P/Invoke 선언에 기본값으로 CharSet.None을 할당합니다. CharSet.None의 실제 동작은 CharSet.Ansi와 같아서, Windows에서는 비 Unicode(로컬라이즈된 코드 페이지)로 마샬링됩니다. 호출하려는 Win32 API가 Unicode 버전(W 접미사)을 전제로 한다면, 이 기본값 그대로 호출하면 글자가 깨지거나 멀티바이트 문자가 누락되는 문제가 발생합니다.12

LibraryImport에서는 StringMarshalling.Utf16을 명시하는 것이 기본형입니다. ANSI라는 선택지 자체가 폐지되었기 때문에, DllImport 시절 흔했던 “기본값에 맡겼다가 의도치 않게 ANSI가 되는” 사고가 구조적으로 일어나기 어렵게 되었습니다.11

또 하나의 함정이 StringBuilder 파라미터입니다. “네이티브 쪽이 문자열 버퍼를 써서 돌려준다” 타입의 API에서 자주 쓰이지만, StringBuilder의 마샬링은 항상 네이티브 버퍼로의 복사를 발생시키고, ToString()에서 한 번 더 할당이 일어납니다. 버퍼가 [Out](기본값)이라면, 호출할 때마다 여러 번의 할당이 누적되는 비효율적인 구조입니다. 게다가 돌아온 버퍼가 NUL로 종료되지 않았거나, 이중 NUL 종료 문자열일 경우 오작동하기 쉬운 버릇도 있습니다. 호출 빈도가 높다면 ArrayPool<char>에서 가져온 문자 배열을 쓰는 편이 안정적입니다.4

[Out] string 파라미터도 피해야 할 지정입니다. 문자열이 인터닝된 것이었을 경우, 런타임을 불안정하게 만들 수 있습니다.4

5. 핸들의 수명 관리 — SafeHandle을 쓰는 이유

파일 핸들·레지스트리 키·디바이스 핸들 같은 네이티브 리소스를 순수 IntPtr로 들고 있는 것은, .NET의 네이티브 상호운용에서는 피해야 할 설계입니다. 이유는 세 가지입니다.5

  • GC에 의한 핸들의 조기 해제. 파이널라이저를 구현한 클래스가 핸들을 IntPtr 필드로 갖고 있었다면, P/Invoke 호출이 진행되는 동안 GC가 그 객체를 회수해 핸들을 닫아버리는 경쟁이 일어날 수 있습니다.
  • 핸들의 재활용 공격. Windows는 핸들 값을 적극적으로 재사용합니다. 닫혔어야 할 핸들 값이 다른 리소스에 재할당된 상태로 옛 IntPtr를 계속 쓰면, 무관한 리소스를 조작해버리는 심각한 사고로 이어집니다.
  • 비동기 예외로 인한 누수. 스레드 중단 같은 비동기적인 인터럽트가 핸들 획득과 필드 저장 사이에 발생하면, 핸들 누수가 일어날 수 있습니다.

SafeHandle은 이런 문제들을 해결하기 위해 설계된 추상 클래스입니다. CriticalFinalizerObject를 상속하고 있어, AppDomain의 비정상 종료 시에도 해제 처리가 확실히 실행되는 것이 보장됩니다. P/Invoke 호출은 핸들의 참조 카운트를 자동으로 증감시키기 때문에, 호출 중에 핸들이 재활용되는 일도 없습니다.5

직접 구현할 때는 Microsoft.Win32.SafeHandles 네임스페이스의 SafeHandleZeroOrMinusOneIsInvalid 등을 상속하고, ReleaseHandle()을 오버라이드합니다. ReleaseHandle()은 “실패하지 않는 것”이 전제인 제약 실행 영역에서 동작하기 때문에, 복잡한 로직을 쓰지 않고 단순한 해제 API 호출로 그치는 것이 정석입니다. 파이널라이저를 직접 작성할 필요는 없습니다(오히려 피해야 합니다).6

6. 에러 처리 — SetLastError와 GetLastPInvokeError

대부분의 Win32 API는 실패 시 SetLastError로 스레드 로컬 에러 코드를 설정하고, 호출하는 쪽은 GetLastError로 읽어냅니다. P/Invoke에서 이를 다루려면 DllImportAttribute.SetLastError(LibraryImport에도 같은 이름의 속성이 있음)를 true로 설정합니다.13

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

여기서 주의해야 할 점이 두 가지 있습니다.

  • 에러 코드는 호출 직후에 읽습니다. .NET(.NET Framework 제외)에서는 SetLastError = true인 P/Invoke를 호출할 때마다 에러 정보가 일단 초기화되고, 그 호출 1회분의 결과만 유지됩니다. 로그 출력이나 다른 API 호출을 사이에 끼우면 덮어써져서 사라지기 때문에, 실패를 감지한 그 자리에서 값을 가져와야 합니다.13
  • Marshal.GetLastWin32Error()가 아니라 Marshal.GetLastPInvokeError()를 사용합니다. .NET 6 이후에서는 이 둘이 기능적으로 동일하지만, 후자가 크로스 플랫폼 의도를 반영한 새 이름으로 권장됩니다.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. 구조체 마샬링 — 블리터블 타입과 StructLayout

.NET과 네이티브 코드에서 비트 표현이 같은 타입은 “블리터블(blittable)”이라 불리며, 변환 없이 그대로 전달할 수 있어 빠릅니다. byte·int·long 같은 기본 타입, 블리터블한 값 타입만으로 구성된 고정 레이아웃 구조체가 여기에 해당합니다. 블리터블한 구조체는 Marshal.SizeOf<T>()가 아니라 C#의 sizeof()를 쓰는 것이 더 빠릅니다. 반대로 bool은 블리터블이 아니며(네이티브의 BOOL은 4바이트, C/C++의 bool은 1바이트라는 차이가 있습니다), 의식하지 않고 쓰면 반환값의 절반이 버려지는 버그를 만들 수 있습니다.14

구조체의 레이아웃은 StructLayoutAttribute로 제어합니다. 기본값은 LayoutKind.Sequential(선언 순서대로 배치)을 쓰고, 유니온처럼 필드의 위치를 명시하고 싶을 때만 LayoutKind.Explicit을 씁니다.8

놓치기 쉬운 것이 Pack 필드입니다. 공식 문서에 따르면 타입 전체의 정렬은 “가장 큰 필드의 크기”와 “지정한 Pack 값” 중 작은 쪽, 각 필드는 “자기 자신의 크기”와 “타입의 정렬” 중 작은 쪽에 맞춰 배치됩니다.8Pack을 명시적으로 작은 값(2나 4 등)으로 설정하면, C++의 #pragma pack(N)과 마찬가지로 정렬의 상한으로 작용합니다. 한편, 기본값 0은 “타입 전체의 정렬을 가장 큰 필드의 크기로 한다(그 이상의 특별한 상한을 두지 않는다)”는 뜻이며, 이는 C++ 컴파일러의 /Zp 옵션(구조체 멤버 정렬. x86·ARM·ARM64에서는 기본 8바이트 경계, x64·ARM64EC에서는 기본 16바이트 경계)의 기본값과는 다른 규칙으로, 단순히 동일시할 수 없습니다.15 게다가 이 기본 레이아웃은 .NET Framework와 .NET 5+ 사이에서도 달라질 수 있습니다. 예를 들어 decimal을 포함한 구조체는, 내부 필드 구성의 차이로 기본 패킹 시 크기가 .NET Framework에서는 28바이트, .NET 5+에서는 32바이트가 되는 예가 공식 문서에 나와 있습니다.8 즉 “기본값이니 맞을 것”이라는 아키텍처 단위의 생각은 금물입니다. 네이티브 쪽 헤더가 #pragma pack으로 패킹 크기를 명시적으로 바꾸고 있거나, 8바이트를 넘는 정렬을 요구하는 필드를 포함한 DLL을 상대할 때는, C# 쪽의 Pack을 명시하거나 Marshal.OffsetOf 등으로 실제 필드 오프셋을 검증한 뒤 써야 합니다. 이를 게을리하면 필드 오프셋이 어긋나 조용히 데이터가 깨지는 사고가 됩니다. 반대로, Windows SDK 헤더를 그대로 쓰는 순수한 API에서 필드가 모두 8바이트 이하의 기본 타입이라면, 기본 정렬 그대로 Pack을 만지지 않아도 실무상 문제가 되는 경우는 거의 없습니다.

// 네이티브 쪽 헤더가 pack(4)를 명시하고 있는 경우의 예
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

8. 콜백(델리게이트)의 수명 관리

네이티브 API에 “끝나면 이 함수를 불러 달라”는 콜백을 넘기는 상황은 드물지 않습니다. 관리 코드에서는 delegate가 그 역할을 맡지만, 여기에는 GC 특유의 함정이 있습니다. Marshal.GetFunctionPointerForDelegate로 델리게이트에서 함수 포인터를 얻더라도, GC는 그 함수 포인터와 델리게이트의 연관을 추적하지 않습니다. 네이티브 쪽이 아직 그 함수 포인터를 쓰고 있는 동안 델리게이트가 회수되면 크래시로 이어집니다.9

또 하나 놓치기 쉬운 것이 호출 규약입니다. P/Invoke로 델리게이트를 함수 포인터로 네이티브에 넘길 때, 기본값으로는 “플랫폼의 기본 호출 규약”이 쓰이지만, 명시적으로 일치시키고 싶다면 UnmanagedFunctionPointerAttribute를 델리게이트 타입에 붙입니다.16 x64/ARM/ARM64에서는 호출 규약이 사실상 하나뿐이라 의식하지 않아도 실제 피해가 나기 어렵지만, Windows x86(32bit)에서는 Stdcall(Win32 API의 기본값)과 Cdecl(Unix 계열 C 라이브러리에서 흔한 방식)이 다르기 때문에, 상대의 헤더가 Cdecl을 쓰고 있는데 기본값 그대로 두면 스택이 깨지는 문제로 이어집니다.16

// 호출 규약을 명시한다. x86 빌드에서 상대가 Cdecl을 쓰고 있다면 여기가 필수
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // static으로 보관해 수명을 확정시킴

// [UnmanagedFunctionPointer]는 콜백이 "호출될" 때의 규약이며,
// 이 호출 자체(RegisterCallback이라는 P/Invoke)의 규약은 별개.
// LibraryImport의 기본값은 플랫폼 기본값(Windows에서는 stdcall 상당)이므로,
// 상대가 Cdecl인 C DLL이라면 이쪽도 명시가 필요
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// 호출하는 쪽
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // 곧바로 스코프를 벗어날 수 있는 변수를 명시적으로 살려둠

static 필드로 보관해 두면, 애플리케이션이 살아 있는 동안은 GC에 회수되지 않습니다. 네이티브 쪽이 콜백을 한 번의 호출 중에만 쓴다(콜백이 돌아오면 함수 포인터를 버린다)는 것이 확실한 경우에 한해, 로컬 변수 + GC.KeepAlive로 수명을 늘리는 가벼운 작성 방식도 가능합니다.

공식 모범 사례에서는, 가능하다면 Delegate 타입보다 UnmanagedCallersOnlyAttribute를 붙인 정적 메서드와 함수 포인터(delegate*<...>)를 쓰는 것이 권장됩니다. 델리게이트 마샬링에 비해 오버헤드가 적고, Native AOT와도 친화적인 방식입니다.9

9. 32bit/64bit의 차이

P/Invoke 시그니처를 하나 작성하기만 하면, 실행 시점에는 32bit 프로세스에서도 64bit 프로세스에서도 같은 코드 경로가 쓰입니다. 여기서 문제가 되기 쉬운 것이, 네이티브 쪽 타입의 너비가 프로세스의 비트 수를 따라간다는 점입니다.

  • HANDLE·HWND·LPARAM 같은 포인터 계열 타입은 32bit 프로세스에서는 4바이트, 64bit 프로세스에서는 8바이트가 됩니다. .NET 쪽에서는 IntPtr/UIntPtr(또는 nint/nuint)로 받는 것이 올바르며, 고정 크기의 int/long으로 받으면 32bit나 64bit 중 한쪽에서만 동작하는 코드가 됩니다.4
  • 구조체에 위와 같은 포인터 계열 필드가 포함되어 있으면, 구조체 전체의 크기도 비트 수에 따라 달라집니다. 7장에서 다룬 Pack의 기본값이 아키텍처마다 다르다는 점과 더불어, 같은 구조체 정의라도 32bit 빌드와 64bit 빌드에서 바이너리 레이아웃이 달라질 수 있다는 것을 전제로 테스트해야 합니다.
  • 32bit의 기존 앱에서 64bit에서만 동작하는 DLL의 기능을 쓰고 싶다는 요구사항 자체는 P/Invoke로는 해결할 수 없습니다(같은 프로세스에 비트 수가 다른 DLL은 공존할 수 없습니다). 이 경우에는 프로세스를 분리하고, COM 브리지나 네임드 파이프로 다리를 놓는 설계가 됩니다. 실제 예시는 「32bit 앱에서 64bit DLL을 호출하는 COM 브리지 실전 예시」를 참고하세요.
  • DLL이 애초에 발견되지 않거나 의도하지 않은 버전이 로드되는 문제는 P/Invoke 이야기가 아니라 Windows 로더 이야기입니다. 「Windows DLL 이름 해석의 구조」에서 검색 순서와 SxS의 동작을 정리해 두었으니, DllNotFoundException의 원인 조사에서는 함께 확인해보시기 바랍니다.

10. 판단표 — P/Invoke vs C++/CLI 래퍼 vs COM 상호운용

C#에서 네이티브 코드를 호출하는 수단은 P/Invoke뿐만이 아닙니다. 상대가 C++의 클래스·소유권·예외를 가진 복잡한 DLL이라면 C++/CLI 래퍼가 효과적이고, 프로세스 경계(32/64bit 브리지, VBA 등 다른 언어에서의 사용)를 넘어야 한다면 COM이 선택지가 됩니다.

관점 P/Invoke(LibraryImport) C++/CLI 래퍼 COM 상호운용
적합한 대상 순수한 C 인터페이스(구조체·프리미티브 타입 중심) C++의 클래스, 소유권, 예외, std:: 타입이 얽힌 DLL 프로세스를 넘어야 하는 대상, VBA 등 다른 언어
구현 비용 낮음~중간(시그니처 정의만) 중간(래퍼 계층을 한 겹 더 작성) 높음(인터페이스 설계, 레지스트리 등록)
타입 안전성 중간(손으로 쓰면 시그니처 실수가 실행 시점까지 보이지 않을 수도. CsWin32로 개선) 높음(C++의 타입을 그대로 다룰 수 있음) 중간(IDL/타입 라이브러리로 보증)
AOT/트리밍 대응 ◎(LibraryImport라면) △(C++/CLI는 Native AOT 미지원)
예외 처리 ✕(반환값이나 HRESULT로 직접 판정) ◎(C++ 예외를 .NET 예외로 변환 가능) ○(HRESULT가 COM 예외로 변환됨)
프로세스 경계 넘기 ✕(같은 프로세스 내 전용) ✕(같은 프로세스 내 전용) ◎(아웃 프로세스 서버 가능)
디버깅 편의성 ○(LibraryImport는 생성된 코드를 한 줄씩 실행 가능) ○(네이티브·관리 코드 양쪽을 VS로 디버깅) △(참조 카운트나 등록 관련 문제는 추적하기 어려움)
학습 비용 낮음 중간~높음(C++/CLI 문법) 높음(COM의 규약 전반)

“상대가 C 함수 기반의 Win32 API거나, 자사의 순수한 C DLL”이라면 P/Invoke(가능하면 CsWin32), “상대가 C++ 클래스로, 소유권이나 예외까지 포함해서 자연스럽게 주고받고 싶다”면 C++/CLI 래퍼(자세한 내용은 「C#에서 네이티브 DLL을 호출하기: C++/CLI 래퍼 vs P/Invoke」), “애초에 프로세스를 넘어야 하거나 VBA에서 쓰게 하고 싶다”면 COM이라는 순서로 생각하면 헤매지 않습니다. 반대 방향(C/C++에서 C#의 처리를 호출하고 싶은 경우)은 P/Invoke가 아니라, Native AOT의 UnmanagedCallersOnly를 쓰는 구성이 됩니다. 「C# Native AOT DLL을 C/C++에서 호출하는 방법」을 참고하세요.

11. 구현 예제 — LibraryImport를 이용한 핸들 조작과 에러 처리

지금까지의 내용을 조합한 구현 예제입니다. 가상의 센서 기기 SDK device.dll이 공개하는 OpenDevice / CloseDevice / ReadDeviceData를, SafeHandle을 이용한 핸들 관리, LibraryImport를 이용한 컴파일 시점 마샬링, SetLastError + GetLastPInvokeError를 이용한 에러 처리까지 포함해 감싸봅니다.

먼저, 네이티브 핸들을 보관하는 SafeHandle 파생 클래스입니다.

using Microsoft.Win32.SafeHandles;

// device.dll의 핸들을 감싼다. GC의 수명과는 별개로,
// 핸들의 이중 해제·재활용 공격·조기 해제를 막는다
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // OpenDevice의 반환값으로 쓰이므로, 파라미터 없는 생성자가 필요
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // ReleaseHandle 안은 "실패하지 않는다"는 전제인 제약 실행 영역.
        // 단순한 네이티브 해제 호출 하나로 그친다
        => DeviceNativeMethods.CloseDevice(handle);
}

이어서 P/Invoke 선언입니다. 문자열은 StringMarshalling.Utf16을 명시하고, 실패할 수 있는 호출에는 모두 SetLastError = true를 붙입니다.

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // 핸들을 반환값으로 하면, 호출에 성공한 순간부터 SafeHandle이
    // 수명을 추적해 준다. 실패 시에는 IsInvalid가 true인 핸들이 반환된다
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // SafeHandle의 ReleaseHandle에서 직접 부르기 위한 내부 API.
    // handle은 해제 전용이므로 순수 IntPtr로 받는다
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer는 호출하는 쪽이 미리 확보한 배열. byte[]는 블리터블이므로 고정되고,
    // 네이티브 쪽의 쓰기는 같은 메모리에 대해 이루어진다. [Out]을 명시하는 것 자체는
    // 필수는 아니지만, 의도를 자기 문서화하기 위해 붙여둔다
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

마지막으로, 이를 사용하는 쪽의 얇은 래퍼입니다. 에러 코드는 실패를 감지한 직후에 가져와 Win32Exception으로 감싸서 호출한 쪽에 전달합니다.

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // 다른 API 호출로 덮어써지기 전에, 실패 직후에 가져온다
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"디바이스를 열 수 없습니다: {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"디바이스 읽기에 실패했습니다 (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // SafeHandle.Dispose를 부르기만 하면 되고, 파이널라이저는 작성하지 않는다
    public void Dispose() => _handle.Dispose();
}

DeviceConnection을 쓰는 쪽은 using으로 감싸기만 하면 되고, 핸들 해제를 빠뜨리는 것을 걱정할 필요가 없습니다. 이 구성에서 어디서 무엇을 감지해 어떻게 변환할지에 대한 원칙은 「예외 처리에서 catch와 로그는 어디에 두어야 하는가」에서 다룬 계층별 책임 분담이 그대로 적용됩니다. 네이티브 계층의 에러 코드를 P/Invoke 경계에서 예외로 번역하고, 그 위의 계층에서는 평범한 .NET 예외로 다룬다는 선을 여기서 긋는 것이 핵심입니다.

12. 정리

P/Invoke는 “DLL의 함수를 선언하면 호출할 수 있다”는 손쉬움의 뒤편에서, 문자열 마샬링·핸들의 수명·에러 코드 획득 시점·구조체 레이아웃 중 어딘가에서 한 번은 사고를 내는 기술입니다. .NET 7 이후라면 LibraryImport를 기본으로 삼고, 가능하면 CsWin32로 시그니처 자체를 생성하게 한다. 문자열은 StringMarshalling을 명시하고 StringBuilder는 피한다. 핸들은 SafeHandle로 들고 있는다. SetLastError를 쓰면 호출 직후에 에러 코드를 가져온다. 구조체는 Pack의 기본값이 아키텍처마다 다르다는 것을 의식한다. 콜백은 수명을 명시적으로 관리한다 — 이 글에서 다룬 포인트는 모두 “알고 있으면 몇 줄로 끝나지만, 모르면 운영 환경에서만 재현되는 버그가 되는” 종류의 것들입니다.

그리고 P/Invoke로 밀고 나갈지, C++/CLI 래퍼나 COM으로 바꿀지의 판단은, 상대 DLL이 얼마나 “C답게” 만들어져 있는지, 프로세스 경계를 넘어야 하는지에 따라 결정됩니다. 기존 네이티브 자산을 C#에서 호출하고 싶다거나, 반대로 C#의 자산을 네이티브에서 호출하고 싶다는 상담은, 실제 헤더 파일이나 DLL의 구조를 보지 않고서는 최적의 구성이 보이지 않는 경우가 많으니, 판단이 어려우시다면 상담해 주세요.

관련 글

관련 상담 영역

합동회사 코무라소프트는 C#과 네이티브 DLL·Win32 API 사이의 경계 설계, COM 컴포넌트의 개발·조사, 기존 네이티브 자산과 .NET을 연결하는 마이그레이션 프로젝트에 대한 기술 상담을 다루고 있습니다.

참고 링크

  1. Microsoft Learn, Source generation for platform invokes. LibraryImportAttribute를 이용한 컴파일 시점 마샬링 생성, DllImport의 실행 시점 IL 스텁 생성과의 차이, Native AOT/트리밍과의 친화성에 대해.  2 3

  2. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. DllImport에서 LibraryImport로의 전환을 유도하는 분석기 SYSLIB1054를 비롯한 진단 ID 목록에 대해. 

  3. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. C#/Win32 P/Invoke Source Generator(Microsoft.Windows.CsWin32)의 도입 방법과, NativeMethods.txt에 함수명을 나열해 시그니처를 생성하는 절차에 대해.  2 3

  4. Microsoft Learn, Native interoperability best practices. StringBuilder 마샬링이 항상 네이티브 버퍼로의 복사를 수반해 비효율적이라는 점, [Out] string 인자를 피해야 한다는 점, SafeHandle을 쓰고 파이널라이저를 피해야 한다는 점에 대해.  2 3 4

  5. Microsoft Learn, SafeHandle Class. SafeHandle이 핸들의 조기 해제·재활용 공격을 막는 구조와, CriticalFinalizerObject에 의한 확실한 해제 보장에 대해.  2 3

  6. Microsoft Learn, Native interoperability best practices - General guidance. 언매니지드 리소스의 수명 관리에 SafeHandle을 쓰고, 파이널라이저 사용을 피해야 한다는 지침에 대해.  2

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. SetLastError=true가 설정된 P/Invoke 호출 직후의 에러 코드 획득 방법과, .NET 6 이후 GetLastWin32Error보다 권장된다는 점에 대해.  2

  8. Microsoft Learn, StructLayoutAttribute.Pack Field. Pack 기본값 0이 나타내는 “현재 플랫폼의 기본 패킹 크기”의 의미와, 필드의 정렬 계산 규칙에 대해.  2 3 4

  9. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. GetFunctionPointerForDelegate로 얻은 함수 포인터와 델리게이트의 연관을 GC가 추적하지 않는다는 점, GC.KeepAlive를 이용한 수명 연장, UnmanagedCallersOnly 사용 권장에 대해.  2 3

  10. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. 마샬러가 언매니지드 코드가 확보한 메모리를 항상 해제하려 한다는 점, Windows에서는 CoTaskMemFree가 쓰이기 때문에 CoTaskMemAlloc 이외로 확보된 메모리는 IntPtr을 써서 수동으로 해제해야 한다는 점에 대해. 

  11. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. CharSet이 StringMarshalling으로 대체된 점, CallingConvention 대신 UnmanagedCallConvAttribute를 쓰는 점, ExactSpelling/PreserveSig에 해당하는 것이 없는 점에 대해.  2 3

  12. Microsoft Learn, Charsets and marshalling. CharSet을 명시하지 않을 경우 C#·Visual Basic·F# 컴파일러가 기본값으로 CharSet.None을 할당한다는 점, CharSet.None이 CharSet.Ansi와 같은 동작(비 Unicode로의 마샬링)이라는 점에 대해. 

  13. Microsoft Learn, DllImportAttribute.SetLastError Field. SetLastError를 true로 했을 때 .NET에서의 동작(호출마다 에러 정보가 초기화되는 점)에 대해.  2

  14. Microsoft Learn, Native interoperability best practices - Blittable types. 블리터블 타입의 정의, bool이 블리터블이 아니라서 생기는 함정, 블리터블한 구조체에서 sizeof()를 쓰는 이점에 대해. 

  15. Microsoft Learn, /Zp (Struct Member Alignment). C++ 컴파일러의 구조체 멤버 정렬 기본값이 x86/ARM/ARM64에서는 8바이트 경계, x64/ARM64EC에서는 16바이트 경계라는 점에 대해. 

  16. Microsoft Learn, Unmanaged calling conventions. Windows x86에서는 Stdcall과 Cdecl이 서로 다른 기본 호출 규약이 된다는 점, x64/ARM/ARM64에서는 호출 규약이 사실상 하나뿐이라는 점, UnmanagedFunctionPointerAttribute로 호출 규약을 명시할 수 있다는 점에 대해.  2

같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.

이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.

이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.

자주 묻는 질문

이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.

DllImport와 LibraryImport 중 어느 쪽을 써야 하나요?
.NET 7 이후라면 LibraryImport를 기본으로 삼습니다. DllImport가 실행 시점에 마샬링용 IL 스텁을 생성하는 반면, LibraryImport는 소스 제너레이터가 컴파일 시점에 마샬링 코드를 생성하기 때문에 Native AOT·트리밍에 대응하고, 생성된 코드를 디버거로 한 줄씩 실행할 수 있습니다. 분석기 SYSLIB1054가 DllImport를 고쳐야 할 부분을 알려줍니다. LibraryImport가 지원하지 않는 설정(일부 MarshalAs 지정 등)을 써야 할 때만 DllImport로 되돌립니다.
P/Invoke에서 핸들을 IntPtr로 들고 있는 것이 왜 위험한가요?
세 가지 문제가 있기 때문입니다. 첫째, P/Invoke 호출이 진행되는 동안 GC가 객체를 회수해 핸들을 닫아버리는 조기 해제 경쟁이 일어날 수 있습니다. 둘째, Windows는 핸들 값을 적극적으로 재사용하기 때문에, 닫혔어야 할 핸들 값으로 무관한 리소스를 조작해버리는 재활용 공격으로 이어질 수 있습니다. 셋째, 비동기 예외로 인한 핸들 누수가 일어날 수 있습니다. SafeHandle 파생 클래스를 쓰면 참조 카운트의 자동 관리와 확실한 해제 보장으로 이런 문제를 막을 수 있습니다.
Win32 API의 시그니처를 손으로 쓰지 않아도 되는 방법이 있나요?
CsWin32(Microsoft.Windows.CsWin32)라는 소스 제너레이터를 쓸 수 있습니다. NuGet 패키지를 추가하고 NativeMethods.txt라는 텍스트 파일에 호출하고 싶은 함수명을 나열하는 것만으로, 공식 Win32 메타데이터로부터 시그니처·상수·구조체가 자동으로 생성됩니다. HANDLE은 적절한 SafeHandle 파생형으로 출력되기 때문에, 손으로 작성할 때 흔히 발생하는 CharSet 오인이나 필드 순서 실수를 만들어낼 수 없습니다. 기본값은 DllImport 기반 생성이므로, Native AOT를 염두에 둔다면 NativeMethods.json에서 allowMarshaling: false를 지정합니다.
P/Invoke와 C++/CLI 래퍼, COM은 어떻게 구분해서 쓰나요?
상대 DLL의 성격과 프로세스 경계 여부로 결정됩니다. 순수한 C 인터페이스(구조체·프리미티브 타입 중심)라면 P/Invoke가 가장 비용이 낮고, Win32 API라면 CsWin32를 함께 쓰는 것이 효과적입니다. C++의 클래스·소유권·예외·std:: 타입이 얽힌 복잡한 DLL이라면 C++/CLI 래퍼를 한 겹 끼웁니다. 32bit/64bit 브리지처럼 프로세스 경계를 넘어야 하거나 VBA 등 다른 언어에서 사용해야 하는 경우에는 COM이 선택지가 됩니다. 같은 프로세스에 비트 수가 다른 DLL은 공존할 수 없으므로, 이 요구사항은 P/Invoke로는 해결할 수 없습니다.

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기