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

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

수정 이력(8건, 최종 수정 2026년 09월 03일)

이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.

Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
어색한 구어·비유를, 의미를 바꾸지 않고 기술 문서로서 자연스러운 일본어로 고쳤습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21732921)
기사 서두에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
P/Invoke 선언이 경계에서 무엇을 약속하는지 보여주는 그림과, P/Invoke·C++/CLI·COM의 역할을 나누는 선정 플로우 그림을 추가했습니다. 더불어 제10장 말미를 보완합니다. 역방향(C/C++에서 C#을 호출)을 일률적으로 Native AOT 구성으로 적었으나, 이미 동작 중인 .NET 프로세스에 콜백만 거는 경우라면 일반 런타임으로 충분합니다. 독립 네이티브 DLL로 export하는 경우와 나눠 적었습니다.
외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
`Pack = 0` 설명을 고쳤습니다. 0은 「alignment 상한을 두지 않는다」가 아니라 「현재 플랫폼의 기본 packing 크기」를 가리킵니다. 상한은 존재한다는 전제로 표와 본문을 다시 쓰고, offset을 손으로 계산하지 말고 `Marshal.OffsetOf`로 실측하라는 주의를 더했습니다.
구조체 레이아웃에 대해, 아키텍처별 기본값을 C#과 C++로 대비하는 표를 추가했습니다. 레이아웃을 실제로 확인하는 코드 예와 용어 설명도 함께 넣었습니다.
최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635368)

아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.

Go Komura (2026). 「C#에서 Win32 API를 안전하게 호출하기 ── P/Invoke 실무 가이드(DllImport / LibraryImport / CsWin32)」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/pinvoke-safe-guide/

DOI(등록된 아카이브)
10.5281/zenodo.21635368
DOI(마지막 등록 버전)
10.5281/zenodo.21635369

이 블로그에서는 지금까지 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를 축으로, 실무에서 잡아야 할 포인트를 빠짐없이 정리합니다.

이 글에서 쓰는 용어

이후 설명 없이 쓰는 용어를 먼저 정리합니다.

용어 의미
P/Invoke Platform Invoke(플랫폼 호출). 관리 코드에서 unmanaged DLL 함수를 호출하는 .NET 메커니즘입니다
마샬링 관리 코드와 unmanaged 경계에서 타입 표현을 서로 변환하는 일. 문자열·구조체·배열·delegate 전달이 여기에 해당합니다
IL stub DllImport 호출에 대해 실행 시 런타임이 생성하는, 마샬링 처리를 포함한 중간 코드. JIT로 컴파일된 뒤 실제 호출에 쓰입니다1
Native AOT .NET 앱을 게시 시점에 네이티브 코드로 미리 컴파일하는 게시 방식. 실행 시 코드를 생성하는 메커니즘을 쓸 수 없으므로 IL stub 방식과 궁합이 나빠집니다1
트리밍 게시 시 사용하지 않는 코드를 깎아 배포 크기를 줄이는 기능. 실행 시 동적으로 생성되는 코드는 추적할 수 없습니다1
blittable 타입 관리 코드와 unmanaged에서 비트 표현이 같아, 변환 없이 그대로 넘길 수 있는 타입. 자세한 내용은 제7장2

1. 먼저 결론

길어지므로 특히 사고가 많은 4가지를 먼저 듭니다.

  • .NET 7 이후라면 DllImport가 아니라 LibraryImport를 기본으로 합니다. 컴파일 시 마샬링 코드를 생성하므로 Native AOT·트리밍을 지원하고, 실행 시 IL stub 생성 비용이 없으며, 생성 코드를 디버거에서 스텝 실행할 수 있습니다. analyzer SYSLIB1054가 DllImport를 바꿀 지점을 알려 줍니다.13
  • 핸들은 생 IntPtr이 아니라 SafeHandle 파생 클래스로 유지합니다. GC에 의한 핸들 조기 해제·이중 해제·「재활용 공격」을 막는, .NET 네이티브 상호 운용의 기본 관례입니다.45
  • 문자열은 StringMarshalling을 명시하고, StringBuilder는 피합니다. StringBuilder 마샬링은 항상 네이티브 버퍼 복사를 동반하며, 비효율적인 데다 NUL 종료 처리를 틀리기 쉬운 메커니즘입니다.6
  • SetLastError = true를 붙였다면, 호출 직후에 Marshal.GetLastPInvokeError()를 읽습니다. 다른 관리 코드 실행이 오류 코드를 덮어쓰기 전에 확보해야 합니다.7

이 네 가지가 한곳에 모이는 것은 우연이 아닙니다. P/Invoke 선언은 경계를 넘을 때의 타입·문자열·메모리와 핸들의 수명·오류 받는 방법을, 그 몇 줄만으로 동시에 약속하기 때문입니다.

네이티브 쪽(Win32 API / 자사 C DLL)경계(마샬러)관리 쪽(.NET)넘기는 방식은 값의 성질에 따라 달라짐복사한 값을 넘김어느 쪽이 해제하는지는 선언만으로는 알 수 없음API 사양을 보고 정함export된 함수네이티브 메모리와 핸들DllImport / LibraryImport 선언= 경계 계약은 여기에 적은 내용이 전부형 변환·호출 규약 조정변환 대상 임시 버퍼호출 측 C# 코드GC가 다루는 객체· blittable 배열 → 고정(pin)해 그대로 넘김· 문자열 등 non-blittable → 변환해 복사· delegate → 호출 중에는 수명을 유지SafeHandle네이티브 핸들의 수명을 가짐

그림 1: 선언 몇 줄이 「타입·문자열·수명·오류」를 동시에 약속한다. 넘기는 방식은 값의 성질에 따라 달라지고(고정할지, 복사할지, 수명만 유지할지), 어느 하나라도 빠지면 증상은 「가끔 죽는다」가 된다

나머지는 「모르면 밟지만, 알면 몇 줄로 끝나는」 종류의 논점입니다. 결론만 목록으로 두었으니, 자세한 내용은 해당 장에서 확인하세요.

논점 결론 상세
Win32 API 시그니처 손으로 쓰지 말고 CsWin32에 생성시킨다. NativeMethods.txt에 호출할 함수 이름만 나열하면, 공식 Win32 메타데이터에서 시그니처·상수·구조체가 나온다8 제3장
구조체 레이아웃 LayoutKind.Sequential을 기본으로 하고, Pack을 명시할지 의식한다. Pack = 0(기본)은 「상한 없음」이 아니라 「플랫폼 기본 packing 크기」이며, C++ /Zp 기본값과는 값이 정해지는 방식이 다르다. .NET Framework와 .NET 5+ 사이에서도 달라질 수 있다9 제7장
콜백(delegate) 네이티브 쪽이 다 쓸 때까지 GC에 회수되지 않도록 수명을 관리한다. static 필드로 유지하거나 GC.KeepAlive를 쓰고, 가능하면 UnmanagedCallersOnly를 우선한다10 제8장
32bit/64bit 포인터 계열 타입은 IntPtr/nint로 받는다. 같은 프로세스에 비트 수가 다른 DLL은 공존할 수 없으므로, 그 요건은 P/Invoke로는 해결할 수 없다 제9장
P/Invoke 이외의 선택지 단순한 C 인터페이스라면 P/Invoke, C++ 클래스나 소유권·예외가 얽히면 C++/CLI, 프로세스 경계를 넘으면 COM. 경합이 아니라 역할 분담 제10장

그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle

2. DllImport와 LibraryImport ── 어느 쪽을 쓸까

DllImport는 예전부터 있는 메커니즘으로, 실행 시 런타임이 마샬링용 IL stub을 생성하고 JIT로 컴파일한 뒤 호출합니다. 생성이 실행 시인 이상, Native AOT나 트리밍처럼 어셈블리를 사전 컴파일하는 구성과는 궁합이 나쁘고, 생성 비용 자체도 0은 아닙니다.1

LibraryImport는 .NET 7에서 추가된 source generator로, 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에서는 드물지 않은 구현)로 그 포인터를 확보하고 있으면, 마샬러가 잘못된 allocator로 메모리를 해제해 힙 손상이나 크래시로 이어집니다.11 상대 헤더나 문서에 CoTaskMemAlloc 호환 확보가 명시되어 있지 않다면, 반환값은 string이 아니라 IntPtr로 받고, 대응하는 해제 함수(또는 상대가 요구하는 해제 절차)를 직접 호출하는 설계로 하세요. 호출 측에서 버퍼를 확보해 넘기는(앞에서 말한 StringBuilder의 대체인 문자 배열이나, 뒤에서 말할 [Out] 버퍼 패턴) 편이, 애초에 이런 소유권의 모호함을 처음부터 피할 수 있습니다.

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

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

DllImport가 지금도 필요해지는 것은, LibraryImport가 아직 지원하지 않는 설정(예를 들어 일부 MarshalAs 지정)을 쓸 때입니다. analyzer가 지원 외 설정을 쓰려 하면 오류로 알려 주므로, 우선 LibraryImport를 써 보고, 막히면 DllImport로 되돌리는 진행이 현실적입니다.12

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

Win32 API를 하나씩 손으로 DllImport/LibraryImport 선언하면, 매개변수 타입·상수값·구조체 필드 순서를 틀릴 위험이 쌓입니다. CsWin32(Microsoft.Windows.CsWin32)는 공식이 제공하는 Win32 API 메타데이터에서, 호출할 함수의 시그니처·관련 상수·구조체를 자동 생성하는 source generator입니다.8

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

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

NativeMethods.txt의 한 줄에는 메서드 이름 외에 타입 이름·상수 이름·네임스페이스·모듈 이름을 쓸 수 있고, 앞에 -를 붙이면 제외입니다.13

빌드 시 이 함수들의 P/Invoke 시그니처(반환값·매개변수·SetLastError 지정을 포함)가 생성됩니다. 기본은 기존의 DllImport 기반으로 생성된다는 점에 주의하세요. Native AOT·트리밍을 염두에 둔다면, 프로젝트 바로 아래에 NativeMethods.json을 두고 allowMarshaling을 끄면 런타임 마샬러에 의존하지 않는 코드 생성으로 전환할 수 있습니다.13 파일 내용은 다음 두 줄이면 충분합니다.

{
  "$schema": "https://aka.ms/CsWin32.schema.json",
  "allowMarshaling": false
}

$schema 줄은 필수는 아니지만, 적어 두면 많은 JSON 에디터에서 자동 완성·설명·검증이 동작하고, 지정할 수 있는 설정 목록도 거기서 확인할 수 있습니다.13

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에서는 non-Unicode(로컬라이즈된 코드 페이지)로 마샬링됩니다. 호출하는 Win32 API가 Unicode 판(W 접미사)을 전제로 하면, 이 기본값 그대로 호출할 때 문자 깨짐이나 멀티바이트 문자 누락이 일어납니다.14

LibraryImport에서는 StringMarshalling.Utf16을 명시하는 것이 기본형입니다. ANSI라는 선택지 자체가 폐지되어 있으므로, DllImport 시절에 잦았던 「기본값에 맡겨 의도치 않게 ANSI가 된다」는 사고가 구조적으로 일어나기 어려워졌습니다.12

또 하나의 함정이 StringBuilder 매개변수입니다. 「네이티브 쪽이 문자열 버퍼에 써 반환하는」 유형의 API에서 자주 쓰이지만, StringBuilder 마샬링은 항상 네이티브 버퍼로의 복사를 일으키고, ToString()에서 한 번 더 allocation이 돕니다. 버퍼가 [Out](기본)이면, 호출마다 여러 번의 allocation이 쌓이는 비효율적인 메커니즘입니다. 게다가 돌아온 버퍼가 NUL로 끝나지 않거나, 이중 NUL 종료 문자열인 경우 오동작하기 쉬운 버릇도 있습니다. 빈도가 높은 호출에서는 ArrayPool<char>에서 꺼낸 문자 배열을 쓰는 편이 안정적입니다.6

[Out] string 매개변수도 피해야 할 지정입니다. 문자열이 intern된 것이었을 경우, 런타임을 불안정하게 만들 수 있습니다.6

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

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

  • GC에 의한 핸들 조기 해제. finalizer를 구현한 클래스가 핸들을 IntPtr 필드에 들고 있으면, P/Invoke 호출 도중에 GC가 그 객체를 회수해 핸들을 닫아 버리는 경합이 일어날 수 있습니다.
  • 핸들 재활용 공격. Windows는 핸들 값을 적극적으로 재사용합니다. 닫았다고 생각한 핸들 값이 다른 리소스에 재할당된 상태에서 옛 IntPtr을 계속 쓰면, 무관한 리소스를 조작하는 심각한 사고로 이어집니다.
  • 비동기 예외에 의한 누수. 스레드 중단 같은 비동기 끼어들기가 핸들을 얻은 뒤 필드에 저장하기 전에 발생하면, 핸들 누수가 일어날 수 있습니다.

SafeHandle은 이를 해결하려고 설계된 추상 클래스입니다. CriticalFinalizerObject를 상속하며, AppDomain이 비정상 종료할 때도 해제 처리가 확실히 실행되는 것이 보장됩니다. P/Invoke 호출은 핸들 참조 카운트를 자동으로 증감시키므로, 호출 중에 핸들이 재활용되지도 않습니다.4

직접 만들 때는 Microsoft.Win32.SafeHandles 네임스페이스의 SafeHandleZeroOrMinusOneIsInvalid 등을 상속하고 ReleaseHandle()을 override합니다. ReleaseHandle()은 「실패하지 않을 것」이 전제인 제약 실행 영역에서 동작하므로, 복잡한 로직을 쓰지 말고 단순한 해제 API 호출에 그치는 것이 정석입니다. finalizer를 직접 쓸 필요는 없습니다(오히려 피해야 합니다).5

6. 오류 처리 ── SetLastError와 GetLastPInvokeError

Win32 API 상당수는 실패 시 SetLastError로 스레드 로컬 오류 코드를 설정하고, 호출 측은 GetLastError로 읽습니다. P/Invoke에서 이를 다루려면 DllImportAttribute.SetLastError(LibraryImport에서도 같은 이름의 속성)를 true로 합니다.15

[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를 호출할 때마다 오류 정보가 일단 지워지고, 그 호출 한 번의 결과만 유지됩니다. 로그 출력이나 다른 API 호출을 끼우면 덮어써져 사라지므로, 실패를 감지한 그 자리에서 값을 얻으세요.15
  • Marshal.GetLastWin32Error()가 아니라 Marshal.GetLastPInvokeError()를 씁니다. .NET 6 이후 이 둘은 기능적으로 동일하지만, 후자는 크로스 플랫폼 의도를 반영한 새 이름으로 권장됩니다.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. 구조체 마샬링 ── blittable 타입과 StructLayout

.NET과 네이티브 코드에서 비트 표현이 같은 타입을 「blittable」이라 하며, 변환 없이 그대로 넘길 수 있어 빠릅니다. byte·int·long 같은 기본 타입, blittable 값 타입만으로 구성된 고정 레이아웃 구조체가 여기에 해당합니다. blittable 구조체는 Marshal.SizeOf<T>()가 아니라 C#의 sizeof()를 쓰는 편이 빠릅니다. 반대로 bool은 blittable이 아니며(네이티브 BOOL은 4바이트, C/C++ bool은 1바이트라는 차이가 있습니다), 의식하지 않고 쓰면 반환값의 절반이 버려지는 버그를 넣게 됩니다.2

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

놓치기 쉬운 것이 Pack 필드입니다. 공식 문서에 따르면 배치 규칙은 다음 두 단입니다.9

  • 타입 전체 alignment = 「최대 필드 크기」와 「지정한 Pack 값」 중 작은 쪽
  • 각 필드의 배치 경계 = 「자기 자신의 크기」와 「타입의 alignment」 중 작은 쪽

즉 Pack에 작은 값(2나 4 등)을 명시하면, C++의 #pragma pack(N)과 마찬가지로 alignment 상한으로 동작합니다. 문제는 기본값인 Pack = 0 쪽입니다.

7.1 「아키텍처 × 기본값」의 대응

Pack = 0은 「상한을 두지 않는다」는 뜻이 아닙니다. 공식 문서의 표현으로는 0은 「현재 플랫폼의 기본 packing 크기」를 가리킵니다.9 즉 상한은 존재하고, 그 값을 스스로 정하지 않았을 뿐입니다. 「C++ /Zp 기본값과 같다」도 아닙니다. 둘 다 packing 크기 상한을 가리킨다는 점은 같지만, 값이 정해지는 방식이 다르므로 한쪽 숫자를 다른 쪽에 가져오면 계산이 맞지 않습니다.

구현 기본값의 내용 x86 x64 ARM / ARM64 ARM64EC
C#의 Pack = 0(기본) 타입 전체 alignment = 「최대 필드 크기」와 「플랫폼 기본 packing 크기」 중 작은 쪽9 왼쪽과 같음 왼쪽과 같음 왼쪽과 같음 왼쪽과 같음
C++의 /Zp(구조체 멤버 alignment 상한)16 멤버는 「자신의 크기」와 「N바이트 경계」 중 작은 쪽에 배치된다 8바이트 16바이트 8바이트 16바이트

실무상 주의는, 이 기본값을 「상한 없음」으로 바꿔 읽고 offset을 손으로 계산하지 않는 것입니다. 특히 자연 alignment가 큰 필드를 포함한 구조체에서는, 기본 그대로여도 상한이 걸려 계산이 맞지 않게 됩니다. 그래서 맞추려고 Pack을 감으로 명시하면, 이번에는 네이티브 쪽과 어긋난 채로 고정되어, P/Invoke에서 가장 위험한 상태가 됩니다. offset에 확신이 없으면 7.2절의 Marshal.SizeOf와 Marshal.OffsetOf로 실측한 뒤 네이티브 쪽 헤더와 맞춰 보세요.

게다가 C# 쪽 기본 레이아웃은 런타임 버전에 따라서도 달라질 수 있습니다. 공식 문서에는 decimal을 포함한 구조체가 내부 필드 구성 차이로, 기본 packing 크기가 .NET Framework에서는 28바이트, .NET 5+에서는 32바이트가 되는 예가 실려 있습니다.9

비교 축 바뀌는 것
32bit 프로세스 / 64bit 프로세스 포인터 계열 필드 폭(제9장). 그에 끌려 구조체 전체 크기도 바뀜
.NET Framework / .NET 5+ 일부 타입을 포함한 구조체의 기본 크기(위의 decimal 예)9
C# 쪽 / C++ 쪽 기본 alignment 규칙 그 자체(위 표)

「기본값이니까 맞을 것이다」라는 생각은 금물이 여기의 결론입니다. 네이티브 쪽 헤더가 #pragma pack으로 packing 크기를 명시적으로 바꾼 경우나, 8바이트를 넘는 정렬을 요구하는 필드를 포함한 DLL이 상대인 경우에는, C# 쪽 Pack을 명시하거나 다음 절의 방법으로 실제 필드 offset을 검증한 뒤에 쓰세요. 여기를 소홀히 하면 필드 offset이 어긋나 조용히 데이터가 깨지는 사고가 됩니다.

반대로, Windows SDK 헤더를 그대로 쓰는 단순한 API이고 필드가 모두 8바이트 이하 기본 타입이라면, 기본 alignment 그대로 Pack을 건드리지 않아도 실무상 문제가 되는 일은 거의 없습니다.

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

7.2 레이아웃을 실제로 확인하기 ── Marshal.OffsetOf

레이아웃 일치는 책상에서 세는 것보다 실행해서 확인하는 편이 확실합니다. 다음 콘솔 앱은 구조체 크기와 각 필드 offset을 그대로 출력합니다. 확인하고 싶은 구조체를 붙여 넣어 실행하고, 네이티브 쪽 헤더와 맞춰 보는 도구로 쓰세요.

// dotnet new console로 만든 프로젝트의 Program.cs를 이것으로 바꿉니다(.NET 8 / C# 12)
using System.Runtime.InteropServices;

Console.WriteLine($"프로세스 아키텍처: {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"IntPtr.Size            : {IntPtr.Size} 바이트");
Console.WriteLine($"Marshal.SizeOf         : {Marshal.SizeOf<DeviceInfo>()} 바이트");
Console.WriteLine("--- 필드 offset ---");

foreach (var field in typeof(DeviceInfo).GetFields())
{
    IntPtr offset = Marshal.OffsetOf<DeviceInfo>(field.Name);
    Console.WriteLine($"{field.Name,-12} : {offset}");
}

// 검증하고 싶은 구조체를 여기에 붙여 넣습니다.
// top-level statement보다 뒤에 두어야 한다는 점에 주의
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

Marshal.OffsetOf<T>(string fieldName)은 unmanaged로 마샬링되었을 때의 필드 시작부터의 바이트 offset을 반환합니다. 네이티브 쪽에서는 C/C++의 offsetof 매크로와 sizeof로 같은 값을 내어 비교합니다.

// 비교용. 네이티브 쪽 헤더(DeviceInfo 정의를 포함하는 것)를 include합니다
#include <stdio.h>
#include <stddef.h>
#include "device.h"

int main(void)
{
    printf("sizeof(DeviceInfo)  = %zu\n", sizeof(DeviceInfo));
    printf("offsetof(DeviceId)  = %zu\n", offsetof(DeviceInfo, DeviceId));
    printf("offsetof(Flags)     = %zu\n", offsetof(DeviceInfo, Flags));
    printf("offsetof(Timestamp) = %zu\n", offsetof(DeviceInfo, Timestamp));
    return 0;
}

양쪽 값이 모든 필드에서 일치하면, 그 플랫폼에서는 레이아웃이 맞습니다. 하나라도 어긋나면 Pack 지정이나 필드 타입·순서 어딘가에 잘못이 있습니다. 32bit와 64bit 양쪽을 배포한다면 양쪽 비트 수에서 이 확인을 하세요. 한쪽만 맞는 것이, 이런 종류의 결함이 전형적으로 나타나는 방식입니다.

8. 콜백(delegate)의 수명 관리

네이티브 API에 「끝나면 이 함수를 호출해 달라」는 콜백을 넘기는 장면은 드물지 않습니다. 관리 코드에서는 delegate가 그 역할을 맡지만, 여기에는 GC 특유의 함정이 있습니다. Marshal.GetFunctionPointerForDelegate로 delegate에서 함수 포인터를 얻어도, GC는 그 함수 포인터와 delegate의 관련을 추적하지 않습니다. 네이티브 쪽이 아직 그 함수 포인터를 쓰는 동안 delegate가 회수되면 크래시로 이어집니다.10

또 하나 놓치기 쉬운 것이 호출 규약입니다. P/Invoke에서 delegate를 함수 포인터로 네이티브에 넘길 때, 기본은 「플랫폼 기본 호출 규약」이 쓰이지만, 명시적으로 맞추고 싶다면 UnmanagedFunctionPointerAttribute를 delegate 타입에 붙입니다.17 x64/ARM/ARM64에서는 호출 규약이 사실상 하나뿐이므로 의식하지 않아도 실제 피해가 나오기 어렵지만, Windows x86(32bit)에서는 Stdcall(Win32 API 기본)과 Cdecl(Unix 유래 C 라이브러리에 많음)이 다르므로, 상대 헤더가 Cdecl을 쓰는 경우 기본 그대로면 스택 파괴로 이어집니다.17

// 호출 규약을 명시합니다. 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*<...>) 를 쓰라고 권합니다. delegate 마샬링보다 오버헤드가 작고, Native AOT와도 친화성이 높은 작성입니다.10

9. 32bit/64bit 차이

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

  • HANDLE·HWND·LPARAM 같은 포인터 계열 타입은, 32bit 프로세스에서는 4바이트, 64bit 프로세스에서는 8바이트가 됩니다. .NET 쪽에서는 IntPtr/UIntPtr(또는 nint/nuint)로 받는 것이 올바르고, 고정 크기 int/long으로 받으면 32bit 또는 64bit 한쪽에만 동작하는 코드가 됩니다.6
  • 구조체에 위의 포인터 계열 필드가 포함되어 있으면, 구조체 전체 크기도 비트 수에 따라 바뀝니다. 제7장의 Pack 기본값이 아키텍처마다 다른 점과 함께, 같은 구조체 정의라도 32bit 빌드와 64bit 빌드에서 바이너리 레이아웃이 달라질 수 있다는 전제로 테스트하세요.
  • 32bit 기존 앱에서 64bit에서만 동작하는 DLL 기능을 쓰고 싶다는 요건 자체는 P/Invoke로 해결할 수 없습니다(같은 프로세스에 비트 수가 다른 DLL은 공존할 수 없습니다). 이 경우에는 프로세스를 분리하고, COM 브리지나 named pipe로 다리를 놓는 설계가 됩니다. 실제 예는 「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 인터페이스(구조체·primitive 타입 중심) C++ 클래스, 소유권, 예외, std:: 타입이 얽힌 DLL 프로세스를 넘는 상대, VBA 등 다른 언어
구현 비용 낮음〜중간(시그니처 정의만) 중간(래퍼 층을 한 장 더 씀) 높음(인터페이스 설계, 레지스트리 등록)
타입 안전성 중간(손으로 쓰면 시그니처 오류가 실행 시까지 안 보이기도 함. CsWin32로 개선) 높음(C++ 타입을 그대로 다룰 수 있음) 중간(IDL/형식 라이브러리로 보장)
AOT/트리밍 지원 ◎(LibraryImport라면) △(C++/CLI는 Native AOT 미지원) △
예외 처리 ✕(반환값 또는 HRESULT로 직접 판정) ◎(C++ 예외를 .NET 예외로 변환할 수 있음) ○(HRESULT가 COM 예외로 변환됨)
프로세스 경계 넘기 ✕(같은 프로세스 전용) ✕(같은 프로세스 전용) ◎(out-of-process 서버가 가능)
디버그 용이성 ○(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# 처리를 호출하고 싶다동작 중인 .NET에콜백시키고 싶다네이티브 DLL로export하고 싶다C#에서 네이티브를 호출하고 싶다공개하고 있다(In-proc / Out-of-proc)하지 않는다프로세스를 넘는다요구한다요구하지 않는다같은 프로세스면 된다C 함수·구조체가 중심C++ 클래스·소유권·예외호출 방향은 어느 쪽인가호출되는 .NET은어떤 형태인가delegate나 함수 포인터를 넘김(제8장) 일반 런타임으로 충분Native AOT + UnmanagedCallersOnly(P/Invoke가 아님)상대가 이미 COM을공개하고 있는가COM 상호 운용같은 프로세스 안에서 끝나는가상대 쪽이 COM을 요구하는가(VBA에서 쓰게 하는 등)COM out-of-process서버를 직접 준비한다named pipe·소켓·RPC등 기존 IPC로 다리를 놓는다(9장)상대 인터페이스의 형태P/Invoke(LibraryImport,Win32 API라면 CsWin32)C++/CLI 래퍼(Native AOT는 쓸 수 없음)

그림 2: 셋은 경합이 아니라 역할 분담. 상대가 이미 COM으로 공개되어 있으면 같은 프로세스 안에서도 COM이 단순한 입구가 되고, 반대로 프로세스만 나눈다면 COM이 아니라 기존 IPC여도 된다

역방향(C/C++에서 C# 처리를 호출하고 싶다)은 다시 둘로 나뉩니다. 이미 동작 중인 .NET 프로세스에 네이티브 쪽에서 콜백만 걸고 싶다면, 제8장의 delegate나 UnmanagedCallersOnly 함수 포인터를 넘기는 형태로 충분하고, 일반 런타임 그대로 동작합니다. C# 처리를 독립 네이티브 DLL로 export하고, .NET을 모르는 쪽에서 로드하게 하고 싶다면, Native AOT로 게시하는 구성이 됩니다. 후자는 「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[]는 blittable이므로 pin되고,
    // 네이티브 쪽 쓰기는 같은 메모리에 대해 이루어집니다. [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만 호출하면 되고, finalizer는 쓰지 않습니다
    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 stub 생성과의 차이, Native AOT/트리밍과의 친화성에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  2. Microsoft Learn, Native interoperability best practices - Blittable types. blittable 타입의 정의, bool이 blittable이 아닌 데서 오는 함정, blittable 구조체에서 sizeof()를 쓰는 이점에 대해. ↩ ↩2

  3. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. DllImport에서 LibraryImport로의 재작성을 재촉하는 analyzer SYSLIB1054를 비롯한 진단 ID 목록에 대해. ↩

  4. Microsoft Learn, SafeHandle Class. SafeHandle이 핸들 조기 해제·재활용 공격을 막는 메커니즘과, CriticalFinalizerObject에 의한 확실한 해제 보장에 대해. ↩ ↩2 ↩3

  5. Microsoft Learn, Native interoperability best practices - General guidance. unmanaged 리소스 수명 관리에 SafeHandle을 쓰고, finalizer 이용을 피해야 한다는 지침에 대해. ↩ ↩2

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

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

  8. 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

  9. Microsoft Learn, StructLayoutAttribute.Pack Field. Pack 기본값 0이 가리키는 「현재 플랫폼의 기본 packing 크기」의 의미와, 필드 alignment 계산 규칙에 대해. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

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

  11. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. 마샬러가 unmanaged 코드가 확보한 메모리를 항상 해제하려 한다는 점, Windows에서는 CoTaskMemFree가 쓰이므로 CoTaskMemAlloc 이외로 확보된 메모리에서는 IntPtr을 쓰고 수동으로 해제해야 한다는 점에 대해. ↩

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

  13. CsWin32 공식 문서, Getting Started. NativeMethods.txt의 각 줄에 메서드 이름·타입 이름·상수 이름·네임스페이스·모듈 이름을 쓸 수 있다는 점(앞의 -는 제외 지정), 프로젝트 바로 아래에 두는 NativeMethods.json으로 설정을 바꿀 수 있다는 점, allowMarshaling을 끄면 런타임 마샬러에 의존하지 않는 코드 생성이 된다는 점, $schema에 https://aka.ms/CsWin32.schema.json을 지정하면 JSON 에디터에서 자동 완성·설명·검증이 동작하고 설정 항목 목록도 거기에 있다는 점에 대해. ↩ ↩2 ↩3

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

  15. Microsoft Learn, DllImportAttribute.SetLastError Field. SetLastError를 true로 한 경우의 .NET상 동작(호출마다 오류 정보가 지워진다는 점)에 대해. ↩ ↩2

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

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

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

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

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

자주 묻는 질문

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

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

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기