C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리

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

수정 이력(1건, 최종 수정 2026년 09월 01일)

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

일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635089)
최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635088)

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

小村 豪 (2026). 「C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635088 https://comcomponent.com/ko/blog/2026/03/07/000-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*, 생포인터 등)을 경계에서 서로 변환하는 것입니다
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 측에서는 네이티브 헤더를 인클루드해서 그대로 C++의 형을 쓸 수 있습니다.

즉 C# 측에서 억지로 「C++의 세계를 재현」하지 않아도 됩니다. std::wstringstd::vector도, 우선은 C++의 형으로 받은 다음 필요한 형태로 .NET 측에 넘기면 됩니다.

C++의 형을 받아들인 뒤 .NET으로 넘기는 흐름네이티브 헤더를 인클루드해 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 측에서는 네이티브 헤더를 인클루드해 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 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기