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장) |
목차
- 먼저 결론(한마디로)
- P/Invoke로 충분한 케이스
- P/Invoke가 갑자기 괴로워지는 경계
- C++/CLI 래퍼를 끼우는 구성
- C++/CLI로 무엇이 편해지는가
- 코드 발췌
- 그래도 C++/CLI를 고르지 않는 편이 좋은 케이스
- 정리
- 참고 자료
이 글의 지식 맵
이 기사는 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에 대한 의존을 동반하므로, 크로스플랫폼이나 배포 제약이 엄격한 경우에는 선택할 수 없습니다.
flowchart LR
accTitle: C++/CLI 래퍼와 P/Invoke 구분 사용의 지식 맵
accDescr: 네이티브 DLL의 복잡함에 따라 P/Invoke와 C++/CLI 래퍼를 어떻게 고르는지, 마샬링이나 소유권·예외·콜백의 취급, 배포 제약이 각각 어떻게 달라지는지 보여주는 그림입니다.
cpp_cli["C++/CLI"]
p_invoke["P/Invoke"]
c_api_bridge["C API 브리지 계층"]
c_native_api["플랫 C API"]
cpp_class_native_library["C++ 클래스 중심 네이티브 라이브러리"]
com_marshaling["마샬링"]
marshal_as["marshal_as"]
safehandle["SafeHandle"]
structlayout["StructLayout"]
dispose_finalize_pattern["Dispose/Finalize 패턴(C++/CLI)"]
ownership_lifetime_management["소유권·수명 관리"]
exception_translation[".NET 예외 변환"]
callback_delegate_lifetime["콜백 델리게이트 수명 관리"]
native_aot["Native AOT"]
ijwhost["ijwhost.dll"]
clr_compilation_flag["/clr 컴파일 옵션"]
mixed_assembly["혼합 어셈블리"]
cross_platform_requirement["크로스 플랫폼 요구 사항"]
p_invoke -.->|"전제로 한다"| c_api_bridge
p_invoke -->|"권장되는 대응"| c_native_api
cpp_cli -->|"권장되는 대응"| cpp_class_native_library
p_invoke -->|"사용은 비권장"| cpp_class_native_library
cpp_cli -->|"사용은 비권장"| c_native_api
cpp_cli -->|"이용한다"| com_marshaling
marshal_as -->|"구현을 담당한다"| com_marshaling
p_invoke -.->|"이용한다"| safehandle
p_invoke -.->|"이용한다"| structlayout
cpp_cli -->|"구현을 담당한다"| dispose_finalize_pattern
cpp_cli -->|"전제로 한다"| ownership_lifetime_management
cpp_cli -->|"구현을 담당한다"| exception_translation
cpp_cli -.->|"전제로 한다"| callback_delegate_lifetime
p_invoke -.->|"전제로 한다"| callback_delegate_lifetime
cpp_cli -->|"양립하지 않는다"| native_aot
cpp_cli -.->|"전제로 한다"| ijwhost
cpp_cli -->|"에서 구성할 수 있다"| clr_compilation_flag
cpp_cli -->|"구현을 담당한다"| mixed_assembly
c_api_bridge -->|"전제로 한다"| c_native_api
cpp_cli -.->|"의 후속"| c_api_bridge
cpp_cli -->|"양립하지 않는다"| cross_platform_requirement
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론(한마디로)
- 상대가 C 함수군이라면 P/Invoke가 단순
- 상대가 C++ 라이브러리라면 C++/CLI 래퍼를 한 장 끼우면 유지보수하기 쉽다
- 특히 클래스·소유권·문자열·배열·예외·콜백이 얽혀 있다면 C# 측에 무리시키지 않는 편이 좋다
요컨대 C#에 네이티브 DLL의 사정을 직접 가져오지 않는다는 것입니다. 네이티브의 사정은 C++ 측에서 받고, .NET에 보일 면만 다듬습니다. 이 분업이 잘되면 코드도 디버그도 꽤 온화해집니다.
flowchart TB
accTitle: 상대 DLL의 형태로 정해지는 구분
accDescr: 상대가 C 함수군이라면 P/Invoke가 단순하고, C++ 라이브러리라면 C++/CLI 래퍼를 한 장 끼우면 유지보수하기 쉽다는 이 글의 결론을 분기로 보여준다.
q{"상대 DLL은 어느 쪽인가"}
q -->|"C 함수군"| pi["P/Invoke가 단순"]
q -->|"C++ 라이브러리"| cli["C++/CLI 래퍼를 끼운다"]
cli -.-> note["네이티브의 사정은 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를 호출하는 감각에 가까우므로 구현도 읽기 쉬워집니다.
flowchart TB
accTitle: P/Invoke로 충분하다고 판단할 수 있는 조건
accDescr: 플랫한 C API, 단순한 인수와 반환값, 명확한 문자열과 리소스 규약이라는 조건이 갖춰져 있으면 C# 측에서 선언해서 쓰기만 하면 됨을 보여준다.
c1["extern C의 플랫한 함수 API"] --> ok["P/Invoke로 충분"]
c2["인수·반환값이 단순"] --> ok
c3["문자열·리소스 규약이 명확"] --> ok
ok --> use["C# 측에서 선언해서 쓰기만"]
그림 2: 상대 API가 이 정도로 갖춰져 있다면, 억지로 C++/CLI를 가져올 필요는 없다.
3. P/Invoke가 갑자기 괴로워지는 경계
문제는 상대가 「단순한 C API」가 아닐 때입니다. 여기서부터 갑자기 분위기가 바뀝니다.
3.1. C++의 클래스를 상대하기 시작했을 때
네이티브 DLL이 C++의 클래스 중심으로 설계되어 있는 경우, 본래는 클래스의 메서드를 그대로 호출하고 싶지만, P/Invoke로 직접 상대할 수 있는 것은 DLL의 익스포트 함수입니다. 즉 결국 어딘가에서 C 형식의 함수로 떨어뜨리는 층이 필요해집니다.
이 시점에서 하고 있는 일은 거의 「래퍼를 쓴다」입니다.
그렇다면 C# 측에 IntPtr과 해제 함수를 대량으로 만드는 것보다 C++ 측에 래퍼를 두는 편이 자연스럽습니다.
flowchart TB
accTitle: C++ 클래스를 상대로 P/Invoke를 골랐을 때의 귀결
accDescr: C++ 클래스의 메서드를 호출하고 싶어도 P/Invoke로 상대할 수 있는 것은 DLL의 익스포트 함수뿐이므로 C 형식으로 떨어뜨리는 층이 필요해지고, 실질적으로 래퍼를 쓰게 된다는 흐름을 보여준다.
want["C++ 클래스의 메서드를 호출하고 싶다"] --> limit["호출할 수 있는 건 익스포트 함수뿐"]
limit --> bridge["C 형식의 함수로 떨어뜨리는 층이 필요"]
bridge --> fact["하는 일은 거의 래퍼"]
fact -.-> better["그렇다면 C++ 측에 두는 편이 자연스럽다"]
그림 3: P/Invoke로 밀어붙이려 해도, C++ 클래스가 상대라면 결국 어딘가에서 래퍼를 쓰게 된다.
3.2. 소유권과 수명 관리가 보이기 어려울 때
C++에서는
- 호출 측이 해제하는가
- 반환된 포인터는 빌려온 것인가
const&인가 소유권 이동인가- 내부에서 캐시하고 있어 수명에 전제가 있는가
같은 이야기가 흔히 있습니다.
이것을 C#의 IntPtr 기반으로 표현하면 처음에는 동작해도, 나중에 다시 읽을 때 꽤 괴롭습니다.
「이 포인터, 누가 언제 지우더라」 문제가 시작되면 경계면은 금세 흐려집니다.
flowchart TB
accTitle: 소유권의 전제를 IntPtr로 표현했을 때 흐려지는 방식
accDescr: 누가 해제하는지, 빌려온 것인지 소유권 이동인지, 수명에 전제가 있는지라는 C++ 측 사정을 C#의 IntPtr 기반으로 표현하면 나중에 다시 읽을 때 괴로워지고 경계면이 흐려짐을 보여준다.
q1["누가 해제하는가"] --> ptr["C#의 IntPtr로 표현한다"]
q2["빌려온 것인가 소유권 이동인가"] --> ptr
q3["수명에 전제가 있는가"] --> ptr
ptr --> bad["처음엔 동작해도 나중엔 못 읽는다"]
bad --> muddy["경계면이 금세 흐려진다"]
그림 4: 소유권과 수명의 전제를 IntPtr 기반으로 들고 다니면, 「누가 언제 지우더라」 문제로 경계면이 흐려진다.
3.3. std::wstring, std::vector, 콜백, 예외가 나왔을 때
이쯤부터 P/Invoke는 「쓸 수는 있지만 기분 좋지는 않은」 영역에 들어갑니다.
std::wstring을 그대로 C#에서 표현하고 싶다std::vector<T>를 반환하고 싶다- 네이티브 처리의 진척을 콜백으로 받고 싶다
- 실패 시에 C++ 예외가 날아간다
이런 요소가 늘어나면 C# 측에 MarshalAs, 수동 버퍼, 고정 길이 배열, 델리게이트 수명 관리, 에러 코드 해석 등이 늘어납니다.
물론 애쓰면 쓸 수 있습니다. 다만 애써야 할 부분이 본질이 아닌 것이 괴롭습니다. 본래 하고 싶은 것은 업무 로직이나 UI이지, 경계면과의 격투기가 아닙니다.
flowchart TB
accTitle: C++ 요소의 증가와 C# 측 부담의 누적
accDescr: wstring이나 vector를 반환하고 싶다, 콜백으로 진척을 받고 싶다, C++ 예외가 날아간다는 요소가 늘어날수록 C# 측에 MarshalAs나 수동 버퍼, 델리게이트 수명 관리가 쌓임을 보여준다.
e1["wstring이나 vector를 반환하고 싶다"] --> pile["C# 측 코드가 쌓인다"]
e2["콜백으로 진척을 받고 싶다"] --> pile
e3["실패 시 C++ 예외가 날아간다"] --> pile
pile --> load["MarshalAs·수동 버퍼"]
pile --> load2["델리게이트 수명 관리·에러 해석"]
그림 5: C++다운 요소가 늘어날 때마다 C# 측 경계 코드의 부담이 쌓여 간다.
3.4. C++의 사정을 C#에 새어나가게 하고 싶지 않을 때
네이티브 DLL 측의 API가 그대로 C#에 적합하다고는 할 수 없습니다.
예를 들어 네이티브 측에서는
- 여러 메서드 호출을 조합해 한 번의 처리로 만든다
- 에러는 반환값과 out 인수로 돌려준다
- 초기화 순서에 전제가 있다
- 스레드 안전성에 제약이 있다
같은 설계여도, C# 측에는 더 직관적인 API를 보여주고 싶은 경우가 많습니다. 이 부분을 변환하는 층으로서 C++/CLI는 꽤 적합합니다.
flowchart TB
accTitle: 네이티브의 사정을 변환해 C#에 보여주는 층
accDescr: 초기화 순서나 스레드 안전성 제약 같은 네이티브 API 설계의 사정을 C++/CLI의 변환 층에서 받아, C# 측에는 더 직관적인 API를 보여준다는 역할 분담을 보여준다.
nat["네이티브 API 설계의 사정"] -.-> ex["초기화 순서·스레드 제약 등"]
nat --> conv["C++/CLI의 변환 층"]
conv --> api["C#에는 직관적인 API를 보여준다"]
그림 6: 네이티브 측 설계의 사정을 그대로 C#에 향하지 않고, C++/CLI를 변환하는 층으로 끼운다.
4. C++/CLI 래퍼를 끼우는 구성
구성으로서는 심플합니다.
flowchart LR
Cs[C# 앱] -->|.NET용 API| Wrapper[C++/CLI 래퍼 DLL]
Wrapper -->|네이티브 헤더나 형을 직접 다룬다| Native[네이티브 C++ DLL]
그림 7: C# 앱과 네이티브 C++ DLL 사이에 C++/CLI 래퍼 DLL을 한 장 끼우는 구성.
C#에서 보이는 것은 .NET다운 API만으로 하고,
- 문자열 변환
- 배열이나 벡터의 변환
- 예외의 변환
- 소유권의 정리
- 에러 코드의 해석
- 필요하다면 스레드 경계나 콜백의 흡수
를 C++/CLI 측에 가둡니다.
중요한 것은 C++/CLI 프로젝트 자체를 너무 크게 하지 않는 것입니다. 역할은 어디까지나 「번역」과 「정형」입니다. 업무 로직까지 넣기 시작하면 이번에는 그 층이 주역이 되어 버립니다.
flowchart TB
accTitle: C++/CLI 래퍼에 가둘 일
accDescr: 문자열과 배열의 변환, 예외와 에러 코드의 변환, 소유권 정리 같은 번역과 정형의 일을 C++/CLI 측에 가두고, 업무 로직은 넣지 않는다는 역할의 선긋기를 보여준다.
w["C++/CLI 래퍼의 역할"] --> t1["문자열·배열의 변환"]
w --> t2["예외·에러 코드의 변환"]
w --> t3["소유권의 정리"]
w -.-> warn["업무 로직은 넣지 않는다"]
그림 8: 래퍼의 역할은 「번역」과 「정형」으로 한정하고, 너무 크게 만들지 않는 것이 중요하다.
5. C++/CLI로 무엇이 편해지는가
5.1. C++의 형을 C++ 그대로 다룰 수 있다
이것은 꽤 큽니다. C++/CLI 측에서는 네이티브 헤더를 인클루드해서 그대로 C++의 형을 쓸 수 있습니다.
즉 C# 측에서 억지로 「C++의 세계를 재현」하지 않아도 됩니다.
std::wstring도 std::vector도, 우선은 C++의 형으로 받은 다음 필요한 형태로 .NET 측에 넘기면 됩니다.
flowchart TB
accTitle: C++의 형을 받아들인 뒤 .NET으로 넘기는 흐름
accDescr: 네이티브 헤더를 인클루드해 wstring이나 vector를 C++의 형 그대로 받고, 필요한 형태로 변환한 뒤 .NET 측으로 넘김으로써 C# 측에서 C++의 세계를 재현하지 않아도 됨을 보여준다.
nt["네이티브의 wstring이나 vector"] --> recv["C++/CLI 측에서 C++의 형 그대로 받는다"]
recv --> conv["필요한 형태로 변환한다"]
conv --> net[".NET 측으로 넘긴다"]
net -.-> nofake["C#에서 C++의 세계를 재현하지 않는다"]
그림 9: C++의 형은 일단 C++의 형으로 받고, 변환한 뒤 .NET에 넘기면 된다.
5.2. API를 .NET용으로 정형할 수 있다
C# 측에는
stringbyte[]List<T>IDisposable- 예외
같은, 익숙한 형태로 API를 보여줄 수 있습니다.
이 차이는 수수하게 보여도 사용하는 쪽의 부담을 크게 바꿉니다. 특히 팀 개발이라면, 네이티브 사정을 모르는 멤버도 다루기 쉬워지는 것이 효과가 있습니다.
5.3. 예외와 에러의 책임을 정리하기 쉽다
네이티브 측에서 예외와 에러 코드가 뒤섞여 있으면 C# 측에서 그대로 받는 것은 다루기 어렵습니다. C++/CLI 측에서 한 번 모아서
- 예외는 .NET의 예외로 변환한다
- 에러 코드는 의미 있는 예외나 결과 형으로 변환한다
- 로그에 필요한 문맥을 보충한다
같은 것을 할 수 있습니다.
경계에서 한 번 「의미 있는 실패」로 번역해 두면 호출 측은 꽤 깔끔해집니다.
flowchart TB
accTitle: 예외와 에러 코드의 경계에서의 번역
accDescr: 네이티브 측에서 뒤섞여 있는 예외와 에러 코드를 C++/CLI 측에서 한 번 모아, 예외는 .NET 예외로, 에러 코드는 의미 있는 형태로 변환하고, 로그에 필요한 문맥을 보충함을 보여준다.
mixed["네이티브의 예외와 에러 코드"] --> tr["C++/CLI 측에서 한 번 모은다"]
tr --> e1["예외는 .NET 예외로 변환"]
tr --> e2["에러 코드는 의미 있는 형태로 변환"]
tr -.-> log["로그에 필요한 문맥을 보충"]
그림 10: 경계에서 한 번 「의미 있는 실패」로 번역해 두면, C# 호출 측이 깔끔해진다.
5.4. ABI의 흔들림을 C# 측에서 감출 수 있다
C++의 클래스나 메서드는 C의 함수처럼 단순한 ABI가 아닙니다. C#이 직접 그 사정을 알기 시작하면 익스포트 함수나 마샬링의 사정이 겉으로 드러납니다.
C++/CLI 래퍼를 끼우면 C++의 사정은 C++ 측에 가두고, C#에는 안정된 면만 보여줄 수 있습니다. 이 분리는 라이브러리 갱신 시에도 효과를 냅니다.
flowchart TB
accTitle: ABI의 흔들림을 래퍼로 가로막는 구도
accDescr: C++ 클래스나 메서드는 C 함수처럼 단순한 ABI가 아니므로, 그 사정을 C++ 측에 가두고 C#에는 안정된 면만 보여주면 라이브러리 갱신 시에도 분리가 효과를 냄을 보여준다.
abi["C++ 클래스의 ABI는 단순하지 않다"] --> hide["C++의 사정은 C++ 측에 가둔다"]
hide --> stable["C#에는 안정된 면만 보여준다"]
stable -.-> update["라이브러리 갱신 시에도 효과를 내는 분리"]
그림 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의 설계를 시작하고 있는 경우가 많습니다.
flowchart TB
accTitle: P/Invoke안이 실질적으로 C 호환 API 설계가 되는 흐름
accDescr: P/Invoke로 직접 호출할 생각이어도 C 브리지 함수를 준비하고 C# 측에 SafeHandle이나 StructLayout을 작성하며 가변 길이·해제·콜백의 논점이 늘어나, 실질적으로 C 호환 API의 설계를 시작하게 되는 흐름을 보여준다.
start["P/Invoke로 직접 호출할 생각"] --> bridge["C 브리지 함수를 별도로 준비"]
bridge --> decl["SafeHandle이나 StructLayout을 작성"]
decl --> more["가변 길이·해제·콜백의 논점"]
more --> real["실질적으로 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」에 정리되어 있습니다.
flowchart TB
accTitle: 디스트럭터와 파이널라이저의 역할 분담
accDescr: C#의 using이나 Dispose에서는 디스트럭터가 호출되어 파이널라이저를 거쳐 네이티브 자원을 해제하고, Dispose를 잊은 경우는 GC가 파이널라이저를 호출해 마지막에는 주워지며, SuppressFinalize로 이중 해제가 방지됨을 보여준다.
us["C#의 using이나 Dispose"] --> dtor["디스트럭터(Dispose에 해당)"]
forget["Dispose 호출을 잊음"] -.-> gc["GC 회수 시 파이널라이저"]
dtor --> fin["파이널라이저를 호출"]
fin --> del["네이티브 자원을 delete"]
gc -.-> del
dtor -.-> sup["SuppressFinalize로 이중 해제를 방지"]
그림 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#에서 보이는 것은 string과 List<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. 이 구분으로 대체로 잘 됩니다.
flowchart TB
accTitle: C++/CLI를 고르지 않는 편이 좋은 장면
accDescr: 깔끔한 C API가 이미 있다, 크로스 플랫폼이 필요하다, 경계면이 작고 형도 단순하다, AOT나 배포 제약을 엄밀하게 보고 있다는 네 가지 장면에서는 C++/CLI 래퍼를 고르지 않는 편이 좋음을 보여준다.
n1["C API가 이미 있다"] --> no["C++/CLI를 고르지 않는다"]
n2["크로스 대응이 필요하다"] --> no
n3["경계가 작고 형도 단순하다"] --> no
n4["AOT·배포 제약이 엄격하다"] --> no
no -.-> judge["어디서 번역하는 것이 자연스러운가"]
그림 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. 참고 자료
- 이 글의 샘플 코드 일습(네이티브 C++ 라이브러리, C++/CLI 래퍼, C# 소비 측) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Interop(C++) 성능에 관한 고려 사항 - Microsoft Learn
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
네임드 파이프의 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows 프로세스 간 통신의 정석인 네임드 파이프를 실무 관점에서 정리합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 받는 서버 설계, ACL과 위장 보안, .NET NamedPipeStream까지 1차 정보를 바탕으로 설명합니다.
WMI/CIM을 C#・PowerShell에서 사용하기 ── 하드웨어 정보 취득・프로세스 감시・원격 조회 실무 가이드
PC 시리얼 번호 취득, 디스크 여유 공간 감시, 프로세스 시작 감지의 정석이 WMI/CIM입니다. Get-CimInstance 등 CIM 계열 명령어의 사용법과 구형 Get-WmiObject로부터의 이전, C#의 System.Management...
Windows 앱에서 USB 기기를 다루는 방법 ── 가상 COM・HID・WinUSB・전용 SDK를 고르는 법
Windows 앱에서 장치나 USB 기기를 제어하는 방법을, 가상 COM 포트・HID・WinUSB・벤더 제공 SDK의 4가지 방식으로 비교합니다. 드라이버 도입 필요 여부, 여러 대 연결 시의 식별, 착탈 추적, 성능의 상한까지 실무 관점으로 정...
업무 시스템의 코드 설계 ── 상품 코드・고객 코드 정하는 방법과 체크 디지트
상품 코드・고객 코드 등 업무 시스템의 코드 체계를 정하는 실전 가이드. 유의미 코드와 무의미 일련번호의 판단표, JAN・Luhn 등 체크 디지트 산식과 C# 구현, Excel의 앞자리 0 소실 대책, 자릿수 초과와 이관까지 정리합니다.
업무 앱의 DB 스키마를 버전 관리하는 법 ── 「거래처마다 DB가 다르다」를 막는 마이그레이션 실천
거래처마다 흩어져 있는 업무 앱의 DB 스키마를 버전 관리하는 실천 가이드. PRAGMA user_version과 전진 마이그레이션의 C# 구현, EF Core Migrations・DbUp・직접 구현의 판단표, 2단계 릴리스까지 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
32비트 / 64비트 상호 운용
32비트 / 64비트 상호 운용, 네이티브 경계, 관련된 Windows 설계 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 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나 배포 제약을 엄밀하게 보는 경우에는 구성 전체의 요건을 먼저 확인하는 편이 좋습니다.