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

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

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::wstringstd::vector도 우선은 C++의 형으로 받은 다음, 필요한 형태로 .NET 측에 건네면 됩니다.

5.2. API를 .NET용으로 정형할 수 있다

C# 측에는,

  • string
  • byte[]
  • 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#에서 보이는 것은 stringList<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. 참고 자료

관련 기사

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

관련 토픽

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

이 주제와 연결되는 서비스

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

블로그 목록으로 돌아가기