C#에서 네이티브 DLL 호출하기: C++/CLI 래퍼 vs P/Invoke

· 업데이트: · · C++/CLI, C#, Windows 개발, 네이티브 연계

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

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

관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635089)
리뷰 지적에 맞춰, 오늘 추가한 그림 가운데 너비가 너무 컸던 것을 세로 구성으로 고치고, 일부 그림과 캡션 표현을 본문 서술과 맞게 바로잡았습니다. 본문 문장은 바꾸지 않았습니다.
P/Invoke와 C++/CLI의 용도 구분과 경계 설계를 그림으로도 따라갈 수 있도록 Mermaid 그림을 13개 추가했습니다(본문 500~750자당 1그림 규약에 맞춘 것입니다). 기존 구성도에는 캡션을 붙였습니다. 본문 문장은 바꾸지 않았습니다.
글 맨 앞에 '이 글의 지식 맵' 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참고하세요.
C++/CLI 래퍼의 `Analyze`가 Dispose 이후 검사를 하지 않았습니다. 소멸자(Dispose)가 `_native`를 `nullptr`로 만들기 때문에, Dispose한 뒤에 남아 있는 참조에서 호출하면 `ObjectDisposedException`이 아니라 null 포인터를 통해 네이티브로 들어가 액세스 위반으로 프로세스 전체가 죽습니다. `_native`를 쓰기 전에 검사해 예외를 던지도록 바꿨습니다.
서두에 대상 독자와 전제, 용어 표(ABI, `SafeHandle`, `StructLayout`, `marshal_as`, 마샬링, 혼합 어셈블리)를 처음 등장하는 장 표시와 함께 추가했습니다. `~T()`와 `!T()`가 `Dispose`와 `Finalize`에 해당한다는 주석, AOT와 배포 제약을 실무 영향과 함께 정리한 표, P/Invoke 논점이 C++/CLI 측에서는 어떻게 되는지의 대응 표를 추가했습니다.
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635088)

이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.

Go Komura (2026). 「C#에서 네이티브 DLL 호출하기: C++/CLI 래퍼 vs P/Invoke」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635088 https://comcomponent.com/ko/blog/cpp-cli-wrapper-for-native-dlls/

DOI(최신 버전)
10.5281/zenodo.21635088
DOI(이 버전)
10.5281/zenodo.22217407

Windows의 기존 자산이나 기존 DLL을 C#에서 쓰고 싶다는 요건은 꽤 자주 있습니다. 상대가 Win32 API처럼 단순한 C 인터페이스라면 P/Invoke로 충분합니다.

다만 실무에서 나오는 것은 더 까다로운 DLL입니다. C++ 클래스가 있고, 소유권의 방식이 있고, 예외도 던지며, std::wstring이나 std::vector도 평범하게 나옵니다. 여기서 P/Invoke만으로 밀어붙이면, 대개 경계면이 점점 힘들어집니다.

이 글에서는 그런 경우에 C++/CLI로 얇은 래퍼를 한 장 끼우면 무엇이 편해지는지를 정리합니다. P/Invoke가 나쁘다는 이야기가 아니라, P/Invoke로 충분한 장면과 C++/CLI가 효과를 내는 장면은 다르다는 것이 취지입니다.

참고로 이 글에 나오는 코드 발췌는, 빌드 가능한 샘플 전체(네이티브 C++ 라이브러리, C API 브리지, C++/CLI 래퍼, P/Invoke 판과 C++/CLI 판의 C# 사용 코드)으로 GitHub에 공개하고 있습니다.

cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)

대상 독자와 전제

C#에서 네이티브 DLL을 호출해 본 적이 있고, DllImport 선언까지는 쓸 수 있지만, 상대가 C++ 클래스 라이브러리가 되는 지점에서 손이 멈추는 개발자를 대상으로 씁니다. C++/CLI 자체는 경험이 없어도 됩니다. 반대로 P/Invoke를 아직 써 본 적이 없다면, 먼저 ‘C#에서 Win32 API를 안전하게 호출하기 ── P/Invoke 실무 가이드‘를 읽고 오는 편이 빠릅니다.

대상 환경은 Windows + Visual Studio 2022이며, C++/CLI 래퍼(.vcxproj)와 C# 프로젝트를 같은 솔루션에 둘 수 있는 구성입니다. 타깃은 .NET Framework여도 .NET 8 등이어도 되지만, .NET 쪽에는 고유 제약이 있으므로 7장에 모아 두었습니다.

먼저 알아 둘 용어

용어 의미
P/Invoke(Platform Invoke) C#의 DllImport / LibraryImport 속성으로 네이티브 DLL의 익스포트 함수를 선언해 직접 호출하는 메커니즘입니다
마샬링 .NET 타입(string, 배열 등)과 네이티브 표현(wchar_t*, raw 포인터 등)을 경계에서 서로 변환하는 일입니다
ABI(Application Binary Interface) 호출 규약, 인수 전달 방식, 구조체 메모리 배치, 이름 장식 등, 컴파일된 바이너리끼리 맞물리기 위한 약속입니다. C 함수는 약속이 단순하고 안정적이지만, C++ 클래스는 이름 장식이나 vtable 배치가 컴파일러 의존이라 C#에서 직접 믿을 수 없습니다(5.4)
SafeHandle 네이티브 핸들을 감싸는 .NET 추상 클래스입니다. 핸들 해제 누락과 ‘사용 중에 해제되는’ 사고를 막기 위해, IntPtr을 맨몸으로 들고 다니는 대신 씁니다(6.2)
StructLayout C# 구조체의 메모리 배치를 네이티브 측에 맞추기 위한 속성입니다. LayoutKind.Sequential로 선언 순서대로 늘어놓거나, CharSet으로 문자열 취급을 지정하는 용도로 씁니다(6.2)
marshal_as C++/CLI가 제공하는 변환 헬퍼입니다. marshal_as<std::wstring>(managedString)처럼 .NET 타입과 네이티브 타입을 서로 변환합니다. msclr/marshal_cppstd.h 같은 헤더를 가져와 씁니다(6.3)
혼합 어셈블리 네이티브 기계어 명령과 MSIL을 모두 포함하는 DLL입니다. C++/CLI 래퍼가 이에 해당합니다(7장)

목차

  1. 먼저 결론(한 마디로)
  2. P/Invoke로 충분한 경우
  3. P/Invoke가 갑자기 힘들어지는 경계
  4. C++/CLI 래퍼를 끼우는 구성
  5. C++/CLI에서 무엇이 편해지는가
  6. 코드 발췌
  7. 그래도 C++/CLI를 고르지 않는 편이 좋은 경우
  8. 정리
  9. 참고 자료

이 글의 지식 맵

이 기사는 C#에서 네이티브 DLL을 호출할 때, 상대가 extern “C”의 플랫한 함수군이라면 P/Invoke가 단순하지만, C++의 클래스 중심 라이브러리이고 소유권·문자열·예외·콜백이 얽혀 있다면 C++/CLI로 얇은 래퍼를 끼우는 편이 유지보수하기 쉽다고 정리합니다. P/Invoke는 SafeHandle이나 StructLayout으로 네이티브 형을 표현하면서도 결국 C 형식의 브리지 층을 직접 만들게 되기 쉽지만, C++/CLI는 marshal_as에 의한 형 변환이나 디스트럭터·파이널라이저에 의한 Dispose/Finalize 패턴, 예외의 .NET 변환을 C++ 측에 가두어 C#에는 안정된 API만 보여줄 수 있습니다. 한편 C++/CLI는 Windows 전용이며 Native AOT와 양립하지 않고, /clr 컴파일과 ijwhost.dll에 대한 의존을 동반하므로, 크로스플랫폼이나 배포 제약이 엄격한 경우에는 선택할 수 없습니다.

C++/CLI 래퍼와 P/Invoke 구분 사용의 지식 맵네이티브 DLL의 복잡함에 따라 P/Invoke와 C++/CLI 래퍼를 어떻게 고르는지, 마샬링이나 소유권·예외·콜백의 취급, 배포 제약이 각각 어떻게 달라지는지 보여주는 그림입니다.전제로 한다권장되는 대응권장되는 대응사용은 비권장사용은 비권장이용한다구현을 담당한다이용한다이용한다구현을 담당한다전제로 한다구현을 담당한다전제로 한다전제로 한다양립하지 않는다전제로 한다에서 구성할 수 있다구현을 담당한다전제로 한다의 후속양립하지 않는다C++/CLIP/InvokeC API 브리지 계층플랫 C APIC++ 클래스 중심 네이티브 라이브러리마샬링marshal_asSafeHandleStructLayoutDispose/Finalize 패턴(C++/CLI)소유권·수명 관리.NET 예외 변환콜백 델리게이트 수명 관리Native AOTijwhost.dll/clr 컴파일 옵션혼합 어셈블리크로스 플랫폼 요구 사항

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

1. 먼저 결론(한 마디로)

  • 상대가 C 함수군이라면 P/Invoke가 자연스럽다
  • 상대가 C++ 라이브러리라면 C++/CLI 래퍼를 한 장 끼우는 편이 유지보수하기 쉽다
  • 특히 클래스·소유권·문자열·배열·예외·콜백이 얽혀 있다면 C# 측에 무리를 시키지 않는 편이 좋다

요컨대 C#에 네이티브 DLL의 사정을 직접 가져오지 않는다는 것입니다. 네이티브의 사정은 C++ 측에서 받고, .NET에 보일 면만 다듬습니다. 이 분업이 잘되면 코드도 디버그도 한결 수월해집니다.

상대 DLL의 형태로 정해지는 용도 구분상대가 C 함수군이면 P/Invoke가 자연스럽고, C++ 라이브러리이면 C++/CLI 래퍼를 한 장 끼우는 편이 유지보수하기 쉽다는, 이 글의 결론을 분기로 나타냅니다.C 함수군C++ 라이브러리상대 DLL은 어느 쪽인가P/Invoke가 자연스럽다C++/CLI 래퍼를 끼운다네이티브의 사정은 C++ 측에서 받는다

그림 1: 용도 구분의 결론. 상대가 C 함수군이면 P/Invoke, C++ 라이브러리이면 C++/CLI 래퍼.

2. P/Invoke로 충분한 경우

P/Invoke로 정리된다면 그것이 가장 간단합니다. 억지로 C++/CLI를 끌어올 필요는 없습니다.

P/Invoke가 맞는 것은 예를 들어 다음 같은 경우입니다.

  • extern "C"로 공개된 플랫한 함수 API로 되어 있다
  • 인수나 반환값이 정수·포인터·단순한 구조체 등으로 끝난다
  • 문자열 규약이 명확하고 버퍼의 책임도 단순하다
  • 리소스 관리가 Create / Destroy처럼 알기 쉽다
  • C# 측에서 SafeHandle이나 StructLayout을 무리 없이 쓸 수 있다

이 정도로 정리되어 있다면 C# 측에서 선언하고 쓰기만 하면 됩니다. Windows API를 호출하는 감각에 가까우므로 구현도 읽기 쉽습니다.

P/Invoke로 충분하다고 판단할 수 있는 조건플랫한 C API, 단순한 인수와 반환값, 명확한 문자열·리소스 규약이 갖춰져 있으면 C# 측에서 선언하고 쓰기만 하면 된다는 것을 나타냅니다.extern C의 플랫한 함수 APIP/Invoke로 충분인수·반환값이 단순문자열·리소스 규약이 명확C# 측에서 선언하고 쓰기만 하면 됨

그림 2: 상대 API가 이 정도로 정리되어 있다면 억지로 C++/CLI를 끌어올 필요는 없습니다.

3. P/Invoke가 갑자기 힘들어지는 경계

문제는 상대가 ‘단순한 C API’가 아닐 때입니다. 여기서부터 양상이 갑자기 달라집니다.

3.1. C++ 클래스를 상대하기 시작했을 때

네이티브 DLL이 C++ 클래스 중심으로 설계되어 있는 경우, 실제로는 클래스 메서드를 그대로 호출하고 싶지만, P/Invoke로 직접 상대할 수 있는 것은 DLL의 익스포트 함수입니다. 즉 결국 어딘가에서 C 형식 함수로 변환하는 계층이 필요해집니다.

이 시점에서 하는 일은 거의 ‘래퍼를 쓰는’ 것입니다. 그렇다면 C# 측에 IntPtr과 해제 함수를 대량으로 늘어놓기보다 C++ 측에 래퍼를 두는 편이 자연스럽습니다.

C++ 클래스를 상대로 P/Invoke를 골랐을 때의 귀결C++ 클래스의 메서드를 호출하고 싶어도 P/Invoke로 상대할 수 있는 것은 DLL 익스포트 함수뿐이므로 C 형식으로 변환하는 계층이 필요하고, 실질적으로 래퍼를 쓰게 되는 흐름을 나타냅니다.C++ 클래스의 메서드를 호출하고 싶다호출할 수 있는 것은 익스포트 함수뿐C 형식 함수로 변환하는 계층이 필요하는 일은 거의 래퍼그렇다면 C++ 측에 두는 편이 자연스럽다

그림 3: P/Invoke로 밀어붙이려 해도, C++ 클래스가 상대이면 결국 어딘가에서 래퍼를 쓰게 됩니다.

3.2. 소유권과 수명 관리가 잘 보이지 않을 때

C++에서는

  • 호출 측이 해제하는가
  • 반환된 포인터는 빌려 온 것인가
  • const&인가 소유권 이동인가
  • 내부에서 캐시하고 있어 수명에 전제가 있는가

같은 이야기가 평범하게 있습니다.

이것을 C#의 IntPtr 기반으로 표현하면, 처음에는 돌아가더라도 나중에 다시 읽을 때 꽤 힘듭니다. ‘이 포인터는 누가 언제 지우는가 문제’가 시작되면 경계는 바로 흐려집니다.

소유권 전제를 IntPtr로 표현했을 때의 흐려지는 방식누가 해제하는가, 빌려 온 것인가 소유권 이동인가, 수명에 전제가 있는가라는 C++ 측 사정을 C#의 IntPtr 기반으로 표현하면 나중에 다시 읽을 때 힘들어지고 경계가 흐려진다는 것을 나타냅니다.누가 해제하는가C#의 IntPtr로 표현한다빌려 온 것인가 소유권 이동인가수명에 전제가 있는가처음에는 돌아가도 나중에는 읽히지 않는다경계가 바로 흐려진다

그림 4: 소유권과 수명 전제를 IntPtr 기반으로 들고 다니면 ‘누가 언제 지우는가 문제’로 경계가 흐려집니다.

3.3. std::wstring, std::vector, 콜백, 예외가 나왔을 때

이 근처부터 P/Invoke는 ‘못 쓰는 것은 아니지만 기분 좋지는 않은’ 영역으로 들어갑니다.

  • std::wstring을 그대로 C#에서 나타내고 싶다
  • std::vector<T>를 반환하고 싶다
  • 네이티브 처리의 진행 상황을 콜백으로 받고 싶다
  • 실패 시에 C++ 예외가 던져진다

이런 요소가 늘면 C# 측에 MarshalAs, 수동 버퍼, 고정 길이 배열, 델리게이트 수명 관리, 에러 코드 해석 등이 늘어납니다.

물론 버티면 쓸 수는 있습니다. 다만 힘을 쏟는 지점이 본질이 아니다는 점이 힘듭니다. 원래 하고 싶은 것은 업무 로직이나 UI이지, 경계면 격투기가 아닙니다.

C++ 요소의 증가와 C# 측 부담의 누적wstring이나 vector를 반환하고 싶다, 콜백으로 진행 상황을 받고 싶다, C++ 예외가 던져진다는 요소가 늘수록 C# 측에 MarshalAs나 수동 버퍼, 델리게이트 수명 관리가 쌓인다는 것을 나타냅니다.wstring이나 vector를 반환하고 싶다C# 측 기술이 쌓인다콜백으로 진행 상황을 받고 싶다실패 시에 C++ 예외가 던져진다MarshalAs·수동 버퍼델리게이트 수명 관리·에러 해석

그림 5: C++다운 요소가 늘 때마다 C# 측 경계 코드의 부담이 쌓여 갑니다.

3.4. C++의 사정을 C#에 노출하고 싶지 않을 때

네이티브 DLL 측 API가 그대로 C#에 맞으리라는 보장은 없습니다.

예를 들어 네이티브 측에서는

  • 여러 메서드 호출을 조합해 한 번의 처리로 만든다
  • 에러는 반환값과 out 인수로 돌려준다
  • 초기화 순서에 전제가 있다
  • 스레드 안전성에 제약이 있다

는 설계여도, C# 측에는 더 직관적인 API를 보여주고 싶은 경우가 많습니다. 여기를 변환하는 층으로서 C++/CLI는 꽤 잘 맞습니다.

네이티브의 사정을 변환해 C#에 보여주는 층초기화 순서나 스레드 안전성 제약처럼 네이티브 API 설계의 사정을 C++/CLI 변환 층에서 받아 내고, C# 측에는 더 직관적인 API를 보여 주는 역할 분담을 나타냅니다.네이티브 API 설계의 사정초기화 순서·스레드 제약 등C++/CLI의 변환 층C#에는 직관적인 API를 보여 준다

그림 6: 네이티브 측 설계의 사정을 그대로 C#에 노출하지 않고, C++/CLI를 변환 층으로 끼웁니다.

4. C++/CLI 래퍼를 끼우는 구성

구성 자체는 단순합니다.

.NET 대상 API네이티브 헤더나 타입을 직접 다룬다C# 앱C++/CLI 래퍼 DLL네이티브 C++ DLL

그림 7: C# 앱과 네이티브 C++ DLL 사이에 C++/CLI 래퍼 DLL을 한 장 끼우는 구성.

C#에서 보이는 것은 .NET다운 API만 두고,

  • 문자열 변환
  • 배열이나 벡터 변환
  • 예외 변환
  • 소유권 정리
  • 에러 코드 해석
  • 필요하면 스레드 경계나 콜백의 흡수

를 C++/CLI 측에 가둡니다.

중요한 것은 C++/CLI 프로젝트 자체를 너무 키우지 않는 것입니다. 역할은 어디까지나 ‘번역’과 ‘다듬기’입니다. 업무 로직까지 넣기 시작하면 이번에는 그 층이 주인공이 되어 버립니다.

C++/CLI 래퍼에 가두는 일문자열이나 배열 변환, 예외와 에러 코드 변환, 소유권 정리 같은 번역과 다듬기 일을 C++/CLI 측에 가두고 업무 로직은 넣지 않는다는 역할의 선을 나타냅니다.C++/CLI 래퍼의 역할문자열·배열 변환예외·에러 코드 변환소유권 정리업무 로직은 넣지 않는다

그림 8: 래퍼의 역할은 ‘번역’과 ‘다듬기’로 한정하고, 너무 키우지 않는 것이 중요합니다.

5. C++/CLI에서 무엇이 편해지는가

5.1. C++ 타입을 C++ 그대로 다룰 수 있다

이것은 꽤 큽니다. C++/CLI 측에서는 네이티브 헤더를 include하고 그대로 C++ 타입을 쓸 수 있습니다.

즉 C# 측에서 억지로 ‘C++ 세계를 재현’하지 않아도 됩니다. std::wstringstd::vector도, 일단 C++ 타입으로 받은 뒤 필요한 형태로 .NET 측에 넘기면 됩니다.

C++ 타입을 받은 뒤 .NET으로 넘기는 흐름네이티브 헤더를 include해 wstring이나 vector를 C++ 타입 그대로 받고, 필요한 형태로 변환한 뒤 .NET 측으로 넘겨서 C# 측에서 C++ 세계를 재현하지 않아도 된다는 것을 나타냅니다.네이티브의 wstring이나 vectorC++/CLI 측에서 C++ 타입 그대로 받는다필요한 형태로 변환한다.NET 측으로 넘긴다C#에서 C++ 세계를 재현하지 않는다

그림 9: C++ 타입은 일단 C++ 타입으로 받은 뒤 변환해서 .NET에 넘기면 됩니다.

5.2. API를 .NET 쪽으로 다듬을 수 있다

C# 측에는

  • string
  • byte[]
  • List<T>
  • IDisposable
  • 예외

처럼 익숙한 형태로 API를 낼 수 있습니다.

이 차이는 밋밋해 보여도 쓰는 측의 부담을 크게 바꿉니다. 특히 팀 개발에서는 네이티브 사정을 모르는 멤버도 다루기 쉬워지는 점이 효과가 있습니다.

5.3. 예외와 에러의 책임을 정리하기 쉽다

네이티브 측에서 예외와 에러 코드가 섞여 있으면 C# 측에서 그대로 받기는 다루기 어렵습니다. C++/CLI 측에서 한 번 모아

  • 예외는 .NET 예외로 변환한다
  • 에러 코드는 의미 있는 예외나 결과 타입으로 변환한다
  • 로그에 필요한 맥락을 보탠다

같은 일을 할 수 있습니다.

경계에서 한 번 ‘의미 있는 실패’로 번역해 두면 호출 측은 꽤 깔끔해집니다.

예외와 에러 코드의 경계에서의 번역네이티브 측에서 섞여 있는 예외와 에러 코드를 C++/CLI 측에서 한 번 모아, 예외는 .NET 예외로, 에러 코드는 의미 있는 예외나 결과 타입으로 변환하고 로그에 필요한 맥락을 보탠다는 것을 나타냅니다.네이티브의 예외와 에러 코드C++/CLI 측에서 한 번 모은다예외는 .NET 예외로 변환에러 코드는 의미 있는 형태로 변환로그에 필요한 맥락을 보탠다

그림 10: 경계에서 한 번 ‘의미 있는 실패’로 번역해 두면 C# 호출 측이 깔끔해집니다.

5.4. ABI의 흔들림을 C# 측에서 숨길 수 있다

C++ 클래스나 메서드는 C 함수처럼 단순한 ABI가 아닙니다. C#이 그 사정을 직접 알기 시작하면 익스포트 함수나 마샬링 사정이 겉으로 나옵니다.

C++/CLI 래퍼를 끼우면 C++의 사정은 C++ 측에 가두고, C#에는 안정된 면만 보여 줄 수 있습니다. 이 분리는 라이브러리 갱신 때에도 효과가 있습니다.

ABI의 흔들림을 래퍼로 막는 구도C++ 클래스나 메서드는 C 함수처럼 단순한 ABI가 아니므로 그 사정을 C++ 측에 가두고 C#에는 안정된 면만 보여 주면 라이브러리 갱신 때에도 분리가 효과가 있다는 것을 나타냅니다.C++ 클래스의 ABI는 단순하지 않다C++의 사정은 C++ 측에 가둔다C#에는 안정된 면만 보여 준다라이브러리 갱신 때에도 효과가 있는 분리

그림 11: 마샬링이나 익스포트 함수의 사정을 겉에 내지 않고, C#에는 안정된 면만 보여 줍니다.

5.5. 단계적 이행이 쉽다

기존 네이티브 DLL을 한꺼번에 전부 다시 만드는 일은 무겁습니다. C++/CLI 래퍼라면 우선 필요한 API만 얇게 감싸고, C# 측의 새 화면이나 워크플로부터 쓰기 시작하는 단계적 이행이 쉽습니다.

Windows의 기존 자산을 살리면서 주변을 .NET으로 옮기는 장면과는 꽤 잘 맞습니다.

6. 코드 발췌

여기서는 ‘그대로 돌아가는 완전한 샘플’이 아니라, 경계면 이미지가 잡힐 정도의 발췌만 싣습니다.

6.1. 네이티브 DLL 측 API 이미지

// NativeLib.hpp
#pragma once
#include <string>
#include <vector>

namespace NativeLib
{
    struct AnalyzeOptions
    {
        int threshold;
        std::wstring modelPath;
    };

    struct AnalyzeResult
    {
        bool ok;
        std::wstring message;
        std::vector<int> scores;
    };

    class Analyzer
    {
    public:
        explicit Analyzer(const std::wstring& licensePath);
        AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
    };
}

이 API는 네이티브 C++로서는 평범합니다. 하지만 C#에서 그대로 만지려면 만만치 않습니다.

6.2. P/Invoke로 가려고 하면 이렇게 된다

우선 C#에서 직접 호출하려면 어딘가에서 C 형식 함수로 변환해야 합니다. 예를 들어 이런 브리지 함수를 따로 준비하게 됩니다.

// C API로 변환한 브리지의 이미지
extern "C"
{
    __declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
    __declspec(dllexport) void  Analyzer_Destroy(void* handle);

    __declspec(dllexport) int Analyzer_Analyze(
        void* handle,
        const wchar_t* imagePath,
        const AnalyzeOptionsNative* options,
        AnalyzeResultNative* result);
}

C# 측도 이런 분위기가 됩니다.

internal sealed class SafeAnalyzerHandle : SafeHandle
{
    private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }

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

    protected override bool ReleaseHandle()
    {
        NativeMethods.Analyzer_Destroy(handle);
        return true;
    }
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
    public int Threshold;
    public IntPtr ModelPath;
}

internal static class NativeMethods
{
    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern void Analyzer_Destroy(IntPtr handle);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern int Analyzer_Analyze(
        SafeAnalyzerHandle handle,
        string imagePath,
        ref AnalyzeOptionsNative options,
        out AnalyzeResultNative result);
}

이것으로 끝나면 좋지만, 실제로는 여기에

  • 가변 길이 데이터를 어떻게 반환할 것인가
  • 문자열 버퍼를 누가 해제할 것인가
  • 에러 상세를 어디에 둘 것인가
  • 콜백 수명을 어떻게 지킬 것인가

같은 논점이 늘어납니다.

P/Invoke를 골랐다고 생각했는데, 실질적으로는 C 호환 API 설계를 시작하고 있는 경우가 많습니다.

P/Invoke 안이 실질적으로 C 호환 API 설계가 되는 흐름P/Invoke로 직접 호출할 생각이어도 C 브리지 함수를 준비하고, C# 측에 SafeHandle이나 StructLayout을 쓰고, 가변 길이 데이터나 해제나 콜백 논점이 늘어나 실질적으로 C 호환 API 설계를 시작하게 되는 흐름을 나타냅니다.P/Invoke로 직접 호출할 생각C 브리지 함수를 따로 준비SafeHandle이나 StructLayout을 쓴다가변 길이·해제·콜백 논점실질적으로 C 호환 API 설계가 시작된다

그림 12: ‘P/Invoke를 골랐을 뿐’이라고 생각했는데, 어느새 C 호환 API를 설계하고 있는 경우가 많습니다.

이 논점들은 C++/CLI로 옮기면 다음과 같이 바뀝니다. 마법처럼 사라지는 것이 아니라 C++ 측에서 자연스럽게 쓸 수 있는 형태로 옮긴다는 표현이 정확합니다.

P/Invoke에서 나오는 논점 P/Invoke 측의 전형적인 대응 C++/CLI 측에서는 어떻게 되는가
가변 길이 데이터를 어떻게 반환할 것인가 ‘필요한 크기를 조회하는 함수’와 ‘버퍼를 채우는 함수’를 2단으로 준비하고, C# 측에서 버퍼를 확보한다 네이티브가 반환하는 std::vector를 그대로 받아 List<int>나 배열에 다시 담아 반환한다(6.3)
문자열 버퍼를 누가 해제할 것인가 해제용 함수를 C API에 더하고, C# 측에서 반드시 호출한다는 규약을 지킨다 std::wstring의 수명은 네이티브 측에서 끝나고, C#에는 새 String^를 만들어 반환하기만 한다(6.3)
에러 상세를 어디에 둘 것인가 반환값의 에러 코드에 더해, 상세를 꺼내는 함수나 out 인수의 구조체를 준비한다 try / catch로 네이티브 예외를 받아 의미 있는 .NET 예외로 변환해 다시 던진다(6.3)
콜백 수명을 어떻게 지킬 것인가 델리게이트가 GC에 회수되지 않도록 필드 등으로 참조를 계속 유지한다 콜백의 등록과 해제를 C++ 측에 가두고, C#에는 이벤트나 델리게이트만 보여 준다
핸들의 소유권을 어떻게 나타낼 것인가 SafeHandle을 파생시켜 ReleaseHandle에서 해제 함수를 호출한다 래퍼의 소멸자/파이널라이저에서 네이티브 객체를 delete한다(6.3)

6.3. C++/CLI 래퍼라면 이렇게 쓸 수 있다

C++/CLI 측에서 네이티브의 사정을 받아 내고, C#에 보여 줄 API를 다듬습니다.

// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"

using namespace System;
using namespace System::Collections::Generic;

public ref class AnalysisOptions
{
public:
    property int Threshold;
    property String^ ModelPath;
};

public ref class AnalysisResult
{
public:
    property bool Ok;
    property String^ Message;
    property List<int>^ Scores;
};

public ref class AnalyzerWrapper : IDisposable
{
public:
    AnalyzerWrapper(String^ licensePath);
    ~AnalyzerWrapper();
    !AnalyzerWrapper();

    AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);

private:
    NativeLib::Analyzer* _native;
};

여기서 C++/CLI 특유의 것이 ~AnalyzerWrapper()!AnalyzerWrapper() 둘입니다. 둘 다 C++ 소멸자로 보이지만, 역할은 .NET의 Dispose 패턴에 대응합니다.

C++/CLI의 쓰는 법 컴파일러가 생성하는 것 C#에서 본 동작
~AnalyzerWrapper()(소멸자) IDisposable을 구현하는 Dispose() using을 빠져나올 때, 또는 Dispose()를 호출했을 때 실행됩니다
!AnalyzerWrapper()(파이널라이저) Object::Finalize를 오버라이드하는 Finalize() GC가 회수할 때 실행됩니다. 언제 돌지는 정해져 있지 않습니다

정석은 네이티브 자원 해제는 파이널라이저에 쓰고, 소멸자에서 그것을 호출하는 것입니다. 다음 구현에서 ~AnalyzerWrapper()this->!AnalyzerWrapper()만 호출하는 것이 바로 그것으로, 이렇게 써 두면 C# 측이 Dispose()를 잊어도 마지막에는 GC가 거둬 줍니다. 소멸자가 호출된 경우에는 GC::SuppressFinalize로 파이널라이즈가 억제되므로 이중 해제는 되지 않습니다.

참고로 Dispose(), Finalize(), Dispose(bool)은 컴파일러가 생성하므로 C++/CLI 측에서 직접 쓰지 않습니다. 반대로 C++/CLI 코드에서 Dispose()를 직접 호출할 수도 없고, delete 연산자로 소멸자를 호출합니다. 이 대응 관계는 How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn의 ‘Destructors and finalizers’에 정리되어 있습니다.

소멸자와 파이널라이저의 분담C#의 using이나 Dispose에서는 소멸자가 호출되어 파이널라이저를 거쳐 네이티브 자원을 해제하고, Dispose를 잊은 경우에는 GC가 파이널라이저를 호출해 마지막에 거둬 주며, SuppressFinalize로 이중 해제가 막힌다는 것을 나타냅니다.C#의 using이나 Dispose소멸자(Dispose에 해당)Dispose 호출을 잊음GC 회수 시에 파이널라이저파이널라이저를 호출한다네이티브 자원을 deleteSuppressFinalize로 이중 해제를 막는다

그림 13: 해제는 파이널라이저에 쓰고, 소멸자에서 그것을 호출하는 정석. Dispose를 잊어도 마지막에는 GC가 거둡니다.

// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>

using msclr::interop::marshal_as;

AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
    _native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}

AnalyzerWrapper::~AnalyzerWrapper()
{
    this->!AnalyzerWrapper();
}

AnalyzerWrapper::!AnalyzerWrapper()
{
    delete _native;
    _native = nullptr;
}

AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
    // 폐기 후에 호출되면 네이티브로 들어가기 전에 여기서 멈춘다.
    // 소멸자(= Dispose)는 _native를 nullptr로 만들므로,
    // 이 검사가 없으면 null 포인터를 통해 네이티브로 들어가
    // .NET 예외가 아니라 액세스 위반으로 프로세스 전체가 죽는다.
    // C# 측에서 보면 「Dispose 후에 건드리면 ObjectDisposedException」이
    // 기대되는 동작이며, _native를 쓰는 메서드 모두에 필요하다
    if (_native == nullptr)
    {
        throw gcnew ObjectDisposedException("AnalyzerWrapper");
    }

    NativeLib::AnalyzeOptions nativeOptions{};
    nativeOptions.threshold = options->Threshold;
    nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);

    try
    {
        auto nativeResult = _native->Analyze(
            marshal_as<std::wstring>(imagePath),
            nativeOptions);

        auto managed = gcnew AnalysisResult();
        managed->Ok = nativeResult.ok;
        managed->Message = gcnew String(nativeResult.message.c_str());
        managed->Scores = gcnew List<int>();

        for (int score : nativeResult.scores)
        {
            managed->Scores->Add(score);
        }

        return managed;
    }
    catch (const std::exception& ex)
    {
        throw gcnew InvalidOperationException(gcnew String(ex.what()));
    }
}

C# 측은 꽤 직관적이 됩니다.

using var analyzer = new AnalyzerWrapper(@"C:\license.dat");

var result = analyzer.Analyze(
    @"C:\input.png",
    new AnalysisOptions
    {
        Threshold = 80,
        ModelPath = @"C:\model.bin"
    });

if (!result.Ok)
{
    Console.WriteLine(result.Message);
}

C#에서 보이는 것은 stringList<int>IDisposable입니다. IntPtr이나 해제 함수나 네이티브 문자열 버퍼의 사정은 보이지 않습니다. 여기가 큽니다.

7. 그래도 C++/CLI를 고르지 않는 편이 좋은 경우

물론 C++/CLI는 만능이 아닙니다. 고르지 않는 편이 좋은 장면도 있습니다.

  • 상대가 처음부터 깔끔한 C API를 공개하고 있다
    • 이 경우는 P/Invoke 쪽이 자연스럽습니다.
  • 크로스 플랫폼이 필요
    • C++/CLI는 Windows 전제입니다.
  • 경계면이 작고 타입도 단순
    • 래퍼 DLL을 늘리는 비용이 더 클 수 있습니다.
  • AOT나 배포 제약을 꽤 엄밀하게 보고 있다
    • 구성 전체의 요건을 먼저 보는 편이 좋습니다.

마지막 ‘AOT나 배포 제약’만 추상적이므로, 실제로 걸리는 제약을 늘어놓겠습니다. 여기는 .NET Framework 시절의 감각 그대로이면 빠지기 쉬운 지점입니다.

제약 내용 실무에서의 영향
OS .NET(.NET Core 계열)을 타깃으로 하는 C++/CLI는 Windows 전용입니다 Linux 컨테이너나 macOS에서 돌릴 계획이 있다면 이 시점에서 고를 수 없습니다
Native AOT Native AOT의 미지원 목록에 C++/CLI가 명시되어 있습니다. 더불어 Assembly.LoadFile 같은 동적 로드, System.Reflection.Emit, Windows의 내장 COM도 쓸 수 없습니다 PublishAot로 단일 네이티브 바이너리로 만드는 방침과는 양립하지 않습니다
출력 형식 .NET을 타깃으로 하는 경우 exe는 되지 않고 DLL만입니다. .NET Standard도 대상으로 할 수 없습니다 엔트리 포인트는 C# 측 exe에 두고, C++/CLI는 DLL로 참조하는 구성으로 합니다
프로젝트 형식 SDK 스타일 csproj가 아니라 .vcxproj를 씁니다. 한 프로젝트에서 여러 .NET을 멀티 타깃할 수도 없습니다 .NET Framework 판과 .NET 판이 둘 다 필요하면 프로젝트 파일을 나눕니다
런타임 의존 /clr을 지정하면 /MD도 켜지므로 MSVC 런타임 DLL이 필요합니다. .NET을 타깃으로 하는 경우에는 추가로 ijwhost.dll을 출력에 두어야 합니다 XCOPY 배포나 단일 파일 게시을 전제로 한다면 먼저 확인합니다
CPU 아키텍처 혼합 어셈블리는 네이티브 기계어 명령을 포함하므로, C#의 AnyCPU처럼 하나의 바이너리로 모든 아키텍처를 커버할 수 없습니다 x86 / x64 등 타깃마다 빌드해 배포합니다
로드되는 방식 .NET 7 이후에는 항상 기본 AssemblyLoadContext에 로드됩니다. .NET 6 이전에는 처음 네이티브 측에서 호출된 경우 다른 AssemblyLoadContext로 로드되는 경우가 있습니다 플러그인마다 로드 컨텍스트를 나누는 구성에서는 동작을 확인해 둡니다

참고로 C++/CLI 프로젝트가 .NET(.NET Core 계열)을 타깃으로 할 수 있는 것은 Visual Studio 2019 이후입니다. 그보다 오래된 환경만 있다면 우선 .NET Framework 전제로 생각하게 됩니다.

즉 판단 기준은 ‘네이티브 DLL의 복잡함에 대해 어디서 번역하는 것이 가장 자연스러운가’입니다. 단순하면 P/Invoke, 복잡하면 C++/CLI. 이 구분으로 대체로 잘 됩니다.

C++/CLI를 고르지 않는 편이 좋은 장면깔끔한 C API가 이미 있다, 크로스 플랫폼이 필요, 경계면이 작고 타입도 단순, AOT나 배포 제약을 엄밀하게 보고 있다는 네 장면에서는 C++/CLI 래퍼를 고르지 않는 편이 좋다는 것을 나타냅니다.C API가 이미 있다C++/CLI를 고르지 않는다크로스 플랫폼이 필요경계가 작고 타입도 단순AOT·배포 제약이 엄격어디서 번역하는 것이 자연스러운가

그림 14: C++/CLI는 만능이 아닙니다. 이 네 가지에 해당하면 P/Invoke나 구성 재검토가 먼저입니다.

8. 정리

C#에서 네이티브 DLL을 쓰는 방법으로서 P/Invoke는 지금도 정석입니다. 다만 그것은 상대가 C API로서 단순할 때의 이야기입니다.

네이티브 측이 C++ 라이브러리로 설계되어 있다면, C# 측에 IntPtr과 마샬링 속성을 늘어놓으며 버티기보다 C++/CLI로 얇은 래퍼를 만드는 편이 경계면을 깔끔하게 유지할 수 있는 경우가 많습니다.

특히

  • 클래스 기반 API
  • 소유권의 전제
  • std::wstring이나 std::vector
  • 예외 변환
  • 콜백
  • 단계적 이행

이 얽히면 C++/CLI는 꽤 현실적인 선택지입니다.

하는 일은 화려하지 않습니다. 하지만 이런 ‘경계를 어디서 다듬을까’는 이후의 유지보수성에 분명히 영향을 줍니다. Windows의 기존 자산과 .NET을 함께 살리고 싶을 때, C++/CLI는 아직도 편리합니다.

9. 참고 자료

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

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

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

자주 묻는 질문

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

P/Invoke와 C++/CLI 래퍼는 어떻게 나눠 쓰면 되나요?
상대가 extern "C"로 공개된 플랫한 C 함수군이라면 P/Invoke가 자연스럽고 가장 간단합니다. 상대가 C++ 클래스 중심의 라이브러리이고 소유권·문자열·배열·예외·콜백이 얽혀 있다면, C++/CLI로 얇은 래퍼를 한 장 끼우는 편이 유지보수하기 쉽습니다. 판단 기준은 '네이티브 DLL의 복잡함에 대해 어디서 번역하는 것이 가장 자연스러운가'이며, 단순하면 P/Invoke, 복잡하면 C++/CLI로 나누면 대체로 잘 됩니다.
C++/CLI 래퍼를 끼우면 무엇이 편해지나요?
C++/CLI 측에서는 네이티브 헤더를 include해 std::wstring이나 std::vector를 C++ 타입 그대로 다룰 수 있어, C# 측에서 C++ 세계를 재현할 필요가 없어집니다. C#에는 string, byte[], List<T>, IDisposable, 예외처럼 .NET다운 API만 보여 주고, IntPtr이나 해제 함수, 마샬링 사정은 숨길 수 있습니다. C++ 예외나 에러 코드를 경계에서 .NET 예외로 변환할 수 있고, 기존 자산을 살린 단계적 이행도 쉬워집니다.
P/Invoke만으로 밀어붙이면 힘들어지는 때는 언제인가요?
네이티브 DLL이 C++ 클래스 중심으로 설계된 경우, P/Invoke로 직접 호출할 수 있는 것은 DLL의 익스포트 함수뿐이므로 결국 어딘가에서 C 형식 함수로 변환하는 계층이 필요해지고, 실질적으로 C 호환 API 설계를 시작하게 됩니다. 여기에 std::wstring이나 std::vector를 돌려주고 싶다, 콜백으로 진행 상황을 받고 싶다, C++ 예외가 던져진다 같은 요소가 늘면, C# 측에 MarshalAs나 수동 버퍼, 델리게이트 수명 관리가 쌓입니다. 소유권이나 수명 전제를 IntPtr 기반으로 표현하면, 나중에 다시 읽을 때 꽤 힘들어집니다.
C++/CLI를 고르지 않는 편이 좋은 경우도 있나요?
있습니다. 상대가 처음부터 깔끔한 C API를 공개하고 있다면 P/Invoke 쪽이 자연스럽습니다. 또한 C++/CLI는 Windows 전제라서 크로스 플랫폼이 필요하면 쓸 수 없습니다. 경계면이 작고 타입도 단순하면 래퍼 DLL을 늘리는 비용이 더 클 수 있고, AOT나 배포 제약을 엄밀하게 보는 경우에도 구성 전체의 요건을 먼저 확인하는 편이 좋습니다.

저자 프로필

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

Go Komura

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

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

블로그 목록으로 돌아가기