C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리
Windows의 기존 자산이나 기존 DLL을 C#에서 쓰고 싶다는 요건은 꽤 자주 있습니다. 상대가 Win32 API 같은 솔직한 C 인터페이스라면 P/Invoke로 충분합니다.
다만 실무에서 나오는 것은 좀 더 독특한 DLL입니다.
C++의 클래스가 있고, 소유권의 유파가 있고, 예외도 날아가며, std::wstring이나 std::vector도 평범하게 나옵니다.
여기서 P/Invoke만으로 밀고 나가면, 대체로 경계면이 점점 괴로워집니다.
이 글에서는 그럴 때 C++/CLI로 얇은 래퍼를 한 장 끼우면 무엇이 편해지는지를 정리합니다. P/Invoke가 나쁘다는 이야기가 아닙니다. P/Invoke로 충분한 장면과 C++/CLI가 효과가 있는 장면은 다르다는 이야기입니다.
1. 먼저 결론(한마디로)
- 상대가 C 함수군이라면 P/Invoke가 솔직
- 상대가 C++ 라이브러리라면 C++/CLI 래퍼를 한 장 끼우면 유지보수하기 쉽다
- 특히 클래스·소유권·문자열·배열·예외·콜백이 얽혀 있다면 C# 측에 무리시키지 않는 편이 좋다
요컨대 C#에 네이티브 DLL의 사정을 직접 가져오지 않는다는 것입니다. 네이티브의 사정은 C++ 측에서 받고, .NET에 보일 면만 다듬습니다. 이 분업이 잘되면 코드도 디버그도 꽤 온화해집니다.
2. P/Invoke로 충분한 케이스
먼저 중요한 것을 말하면, P/Invoke로 정리된다면 그것이 가장 간단합니다. 억지로 C++/CLI를 가져올 필요는 없습니다.
P/Invoke가 맞는 것은 예를 들어 다음 같은 경우입니다.
extern "C"로 공개된 플랫 함수 API로 되어 있다- 인수나 반환값이 정수·포인터·단순한 구조체 등으로 끝난다
- 문자열의 규약이 명확하고 버퍼의 책임도 단순
- 자원 관리가
Create/Destroy처럼 알기 쉽다 - C# 측에서
SafeHandle이나StructLayout을 솔직하게 쓸 수 있다
이 정도로 정돈되어 있다면 C# 측에서 선언하고 쓰기만 하면 됩니다. Windows API를 호출하는 감각에 가까우므로 구현도 읽기 쉽습니다.
3. P/Invoke가 갑자기 괴로워지는 경계
문제는 상대가 「단순한 C API」가 아닐 때입니다. 여기서부터 갑자기 공기가 바뀝니다.
3.1. C++의 클래스를 상대하기 시작했을 때
네이티브 DLL이 C++의 클래스 중심으로 설계되어 있는 경우, C#에서 직접 보고 싶은 것은 사실 메서드지만, P/Invoke로 직접 상대할 수 있는 것은 DLL의 익스포트 함수입니다. 즉 결국 어딘가에서 C 형식의 함수로 떨어뜨리는 층이 필요해집니다.
이 시점에서 하고 있는 것은 거의 「래퍼를 쓴다」입니다.
그렇다면 C# 측에 IntPtr과 해제 함수를 대량으로 생기게 하는 것보다 C++ 측에 래퍼를 치우치는 편이 자연스럽습니다.
3.2. 소유권과 수명 관리가 보이기 어려울 때
C++에서는,
- 호출 측이 해제하는가
- 반환된 포인터는 빌려온 것인가
const&인가 소유권 이동인가- 내부에서 캐시하고 있어 수명에 전제가 있는가
같은 이야기가 평범하게 있습니다.
이것을 C#의 IntPtr 기반으로 표현하면 처음에는 동작해도 나중에 다시 읽을 때 꽤 괴롭습니다.
「이 포인터, 누가 언제 지우지 문제」가 시작되면 경계면은 금방 탁해집니다.
3.3. std::wstring, std::vector, 콜백, 예외가 나왔을 때
이쯤부터 P/Invoke는 「쓸 수는 있지만 기분 좋지는 않은」 영역에 들어갑니다.
std::wstring을 그대로 C#에서 표현하고 싶다std::vector<T>를 돌려주고 싶다- 네이티브 처리의 진척을 콜백으로 받고 싶다
- 실패 시에 C++ 예외가 날아간다
이런 요소가 늘어나면 C# 측에 MarshalAs, 수동 버퍼, 고정 길이 배열, 델리게이트 수명 관리, 에러 코드 해석 등이 늘어납니다.
물론 노력하면 쓸 수 있습니다. 다만 노력할 곳이 본질이 아닌 것이 괴롭습니다. 본래 하고 싶은 것은 업무 로직이나 UI이지 경계면의 격투기가 아닙니다.
3.4. C++의 사정을 C#에 새어나오게 하고 싶지 않을 때
네이티브 DLL 측의 API가 그대로 C#에 향하고 있다고는 할 수 없습니다.
예를 들어 네이티브 측에서는,
- 여러 메서드 호출을 조합해 1회의 처리로 한다
- 에러는 반환값과 out 인수로 돌려준다
- 초기화 순서에 전제가 있다
- 스레드 세이프성에 제약이 있다
라는 설계여도 C# 측에는 더 솔직한 API를 보이고 싶은 경우가 많습니다. 여기를 변환하는 층으로서 C++/CLI는 꽤 적합합니다.
4. C++/CLI 래퍼를 끼우는 구성
구성으로서는 심플합니다.
flowchart LR
Cs[C# 앱] -->|.NET 용의 API| Wrapper[C++/CLI 래퍼 DLL]
Wrapper -->|네이티브의 헤더나 형을 직접 다룸| Native[네이티브 C++ DLL]
C#에서 보이는 것은 .NET다운 API만으로 하고,
- 문자열 변환
- 배열이나 벡터의 변환
- 예외의 변환
- 소유권의 정리
- 에러 코드의 해석
- 필요하다면 스레드 경계나 콜백의 흡수
를 C++/CLI 측에 가둡니다.
중요한 것은 C++/CLI 프로젝트 자체를 너무 크게 하지 않는 것입니다. 역할은 어디까지나 「번역」과 「정형」입니다. 업무 로직까지 넣기 시작하면 이번에는 그 층이 주역이 되어 버립니다.
5. C++/CLI로 무엇이 편해지는가
5.1. C++의 형을 C++인 채로 다룰 수 있다
이것은 꽤 큽니다. C++/CLI 측에서는 네이티브의 헤더를 인클루드하고 그대로 C++의 형을 쓸 수 있습니다.
즉 C# 측에서 억지로 「C++의 세계를 재현」하지 않아도 됩니다.
std::wstring도 std::vector도 우선은 C++의 형으로 받은 다음, 필요한 형태로 .NET 측에 건네면 됩니다.
5.2. API를 .NET용으로 정형할 수 있다
C# 측에는,
stringbyte[]List<T>IDisposable- 예외
같은 익숙한 형태로 API를 낼 수 있습니다.
이 차이는 수수하게 보여도 쓰는 쪽의 부담을 크게 바꿉니다. 특히 팀 개발이라면, 네이티브 사정을 모르는 멤버라도 만지기 쉬워지는 것이 효과가 있습니다.
5.3. 예외와 에러의 책임을 정리하기 쉽다
네이티브 측에서 예외나 에러 코드가 혼재하고 있으면 C# 측에서 그대로 받는 것은 다루기 어렵습니다. C++/CLI 측에서 한 번 모아서,
- 예외는 .NET의 예외로 변환한다
- 에러 코드는 의미 있는 예외나 결과 형으로 변환한다
- 로그에 필요한 문맥을 보충한다
같은 것을 할 수 있습니다.
경계에서 한 번 「의미 있는 실패」로 번역해 두면 호출 측은 꽤 깔끔해집니다.
5.4. ABI의 흔들림을 C# 측에서 숨길 수 있다
C++의 클래스나 메서드는 C의 함수처럼 단순한 ABI가 아닙니다. C#이 직접 그 사정을 알기 시작하면 익스포트 함수나 마샬링의 사정이 겉으로 나옵니다.
C++/CLI 래퍼를 끼우면 C++의 사정은 C++ 측에 가두고, 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의 설계를 시작하고 있는 경우가 많습니다.
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;
};
// 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)
{
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나 배포 제약을 꽤 엄밀하게 보고 있다
- 구성 전체의 요건을 먼저 보는 편이 좋습니다.
즉 판단 기준은 「네이티브 DLL의 복잡함에 대해 어디서 번역하는 것이 가장 자연스러운가」입니다. 단순하면 P/Invoke, 복잡하면 C++/CLI. 이 구분으로 대체로 잘 됩니다.
8. 정리
C#에서 네이티브 DLL을 쓰는 방법으로서 P/Invoke는 지금도 왕도입니다. 다만 그것은 상대가 C API로서 솔직할 때의 이야기입니다.
네이티브 측이 C++ 라이브러리로 설계되어 있다면,
C# 측에 IntPtr과 마샬링 속성을 나열하며 분발하기보다 C++/CLI로 얇은 래퍼를 만드는 편이 경계면을 깔끔하게 유지할 수 있는 경우가 많습니다.
특히,
- 클래스 기반의 API
- 소유권의 전제
std::wstring이나std::vector- 예외 변환
- 콜백
- 단계적인 이행
이 얽히면 C++/CLI는 꽤 현실적인 선택지입니다.
하는 일은 화려하지 않습니다. 하지만 이런 「경계를 어디서 다듬을까」는 나중의 유지보수성에 확실히 영향을 줍니다. Windows의 기존 자산과 .NET을 함께 살리고 싶을 때, C++/CLI는 아직도 편리합니다.
9. 참고 자료
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - 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
- Performance considerations for interop (C++) - Microsoft Learn
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
시리얼 통신 앱의 함정 - 1 byte 단위, 타임아웃, 플로우 컨트롤, 재접속, USB 변환, UI 프리즈를 먼저 정리
시리얼 통신 앱이 가끔 멈추거나 응답이 어긋나는 진짜 원인은 byte stream의 메시지 경계, 타임아웃, 재접속, single writer 설계에 있습니다. 실무에서 무너지기 쉬운 함정과 먼저 정리할 체크리스트를 한 번에 정리했습니다.
Windows Forms, WPF, WinUI 중 어느 것으로 할까 - 신규 개발, 기존 자산, 배포, UI 표현의 판단표
Windows 데스크톱 앱을 C#/.NET으로 새로 만들 때 WinForms·WPF·WinUI 중 무엇을 고를지, 신규 개발과 기존 자산, 배포 방식, UI 표현력, 팀 문화의 다섯 축으로 비교한 한 장짜리 판단표를 제시하여 독자가 자기 프로젝트...
공유 메모리를 사용할 때의 함정과 베스트 프랙티스 - 동기, 가시성, 수명, ABI, 보안을 먼저 정리
공유 메모리는 단순히 빠른 IPC가 아니라 동기, 가시성, 수명, ABI, 권한의 책임을 앱 측이 떠맡는 구조입니다. 본 글은 함정과 베스트 프랙티스를 정리하여 SPSC 링 버퍼나 더블 버퍼, 고정 헤더, 오프셋 참조 등 사고율을 내리는 설계 첫...
.NET Framework를 .NET으로 이행하기 전에 확인해야 할 것 - 착수 전에 승부가 결정되는 실천 체크리스트
.NET Framework에서 .NET으로의 이행을 본격 착수하기 전에, NuGet 의존, PackageReference, SDK 스타일, ASP.NET·WPF·WinForms·WCF·EF6, 설정 파일, CI/CD까지 재고 조사할 항목을 한국어...
C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출하기
.NET의 Native AOT와 UnmanagedCallersOnly로 C# 클래스 라이브러리를 네이티브 DLL로 발행해 C/C++에서 in-process로 호출하는 구성을, 핸들 기반 수명 관리와 에러 코드, C ABI 설계 요령으로 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
32비트 / 64비트 상호 운용
32비트 / 64비트 상호 운용, 네이티브 경계, 관련된 Windows 설계 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.