C# Native AOT DLL을 C/C++에서 호출하는 방법
· 업데이트: · Go Komura · C#, .NET, Native AOT, C++, Windows 개발, 네이티브 연동
수정 이력(10건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635123)
- 추가한 그림의 Mermaid 소스 들여쓰기를 글 안의 규칙에 맞추고, 리뷰에서 지적된 그림 표현을 본문 서술에 맞게 조정했습니다. 본문 문장은 바꾸지 않았습니다.
- 리뷰 지적에 따라, 오늘 추가한 그림 중 폭이 너무 컸던 것을 세로 구성으로 고치고, 일부 그림과 캡션 표현을 본문 서술에 맞게 정확하게 했습니다. 본문 문장은 바꾸지 않았습니다.
- 본문의 흐름·구조를 그림으로도 따라갈 수 있도록 Mermaid 그림을 16점 추가했습니다(본문 500〜750자당 1그림 규칙에 맞춘 것입니다). 기존 구성도에는 캡션을 추가했습니다. 본문 문장은 바꾸지 않았습니다.
- 글 앞에 '이 글의 지식 맵' 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 업데이트했습니다. 개별 변경 내용은 아래 이력을 참고하면 됩니다.
- 핸들 번호가 한 바퀴 돌았을 때의 설명을 고쳤습니다. Dictionary 중복 키 예외가 도움이 되는 것은 같은 값이 지금 살아 있을 때뿐이고, 이미 폐기했다면 Add는 성공합니다. 그 결과 오래된 핸들이 무관한 새 인스턴스를 가리키게 되며, 예외도 에러 코드도 나오지 않습니다. 2^32 바로 그 값에서는 예약해 두었던 0이 발급되는 점도 추가하고, 세대 번호를 넣는 방법과 다 쓰면 영구히 실패시키는 방법을 제시했습니다.
- 전제 환경 표를 추가하고, C# 컴파일이 아니라 마지막 네이티브 링크에서 실패한다는 막히는 지점을 명시했습니다. export되어 있는지를 dumpbin /exports로 확인하는 절, import library로 정적 링크하는 절차, ABI 설명과 C#과 C의 타입 대응표, 핸들 번호 부여 주의점을 추가했습니다.
- 본문의 관련 글 링크 문구가 링크 대상의 현재 제목과 어긋나 있던 것을 실제 제목에 맞췄습니다. 본문 내용은 바꾸지 않았습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635122)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「C# Native AOT DLL을 C/C++에서 호출하는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635122 https://comcomponent.com/ko/blog/csharp-native-aot-native-dll-from-c-cpp/
- DOI(최신 버전)
- 10.5281/zenodo.21635122
- DOI(이 버전)
- 10.5281/zenodo.22217428
앞선 글 C#에서 네이티브 DLL을 쓸 때 C++/CLI 래퍼가 유력한 이유에서는 C#에서 C++을 호출할 때의 경계면을 정리했습니다. 이번에는 방향을 반대로 해서, C/C++에서 C#을 호출하는 이야기입니다.
C#으로 작성한 처리를 기존 C/C++ 앱에서 호출하고 싶은데, P/Invoke는 방향이 반대이고, C++/CLI나 COM까지 끌어오기에는 과한 상황이 있습니다. 특히 네이티브 앱 본체는 그대로 두고, 판정 로직, 문자열 처리, 설정 해석, 계산 규칙 같은 부분만 C#으로 옮기고 싶을 때입니다.
COM으로도 다리를 놓을 수는 있지만, 이번에는 더 in-process에 가깝고, 더 DLL다운 방식입니다. .NET의 Native AOT라면 클래스 라이브러리를 네이티브 공유 라이브러리로 발행할 수 있고, UnmanagedCallersOnly를 붙인 메서드를 C의 엔트리 포인트로 공개할 수 있습니다. 즉 C#을 “호출되는 쪽의 네이티브 DLL”로 쓸 수 있습니다.
다만 무엇이든 그대로 경계를 넘길 수 있는 것은 아닙니다. string, List<T>, 예외, 소유권을 경계에 흘리면 분위기가 금방 나빠집니다. 이 글에서는 Windows + C++의 최소 예제로, 어떤 상황에서 이 구성이 잘 맞는지, 어떤 API 모양으로 만들면 잘 안 깨지는지를 정리합니다. Linux / macOS에서도 접근 방식은 거의 같지만, 코드 예는 Windows DLL을 전제로 합니다.
flowchart TB
accTitle: 이전 글과 이번 글의 방향 차이
accDescr: 이전 글은 C#에서 네이티브 DLL을 호출하는 경계면 이야기였고, 이번에는 방향을 반대로 해서 C/C++ 앱에서 Native AOT로 발행한 C# 네이티브 DLL을 in-process로 호출하는 이야기임을 보여 줍니다.
prev["이전: C#이 C++을 호출"] --> wrap["C++/CLI 래퍼 이야기"]
now["이번: C/C++이 C#을 호출"] --> aot["Native AOT로 C#을 DLL화"]
aot --> entry["UnmanagedCallersOnly가 입구"]
그림1: 이번에는 P/Invoke나 C++/CLI와 방향이 반대이며, C#이 “호출되는 쪽의 네이티브 DLL”이 됩니다.
참고로 이 글에 나오는 코드는, 빌드·실행할 수 있는 샘플 세트(Native AOT로 발행하는 C# 라이브러리, C++ 호출 예, 유닛 테스트)로 GitHub에 공개되어 있습니다.
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
목차
- 먼저 결론 (한 줄로)
- 먼저 보는 구분
- 구성도
- 최소 구성
- 4.1. C# 프로젝트
- 4.2. export하는 C# 코드
- 4.3. 발행 커맨드
- 4.4. C++ 쪽 호출 예
- 4.5. export되어 있는지 확인하기
- 4.6. import lib으로 정적 링크하고 싶을 때
- 잘 안 깨지는 API 모양
- 5.1. C ABI에 맞추기
- 5.2. 문자열은 포인터 + 길이 + 버퍼 용량으로 다루기
- 5.3. 예외를 경계 밖으로 보내지 않기
- 5.4. 호출 규약을 고정하기
- 5.5. Export 메서드는 얇게 두고 본체는 따로 두기
- 잘 맞는 경우
- 그래도 맞지 않는 경우
- 빠지기 쉬운 함정
- 정리
- 참고 자료
이 글의 지식 맵
C#의 Native AOT는 UnmanagedCallersOnly 속성을 붙인 메서드를 C의 엔트리 포인트로 공개할 수 있어, 기존 C/C++ 앱에서 in-process로 C# 로직만을 호출할 수 있는 구성을 실현합니다. 다만 경계에 내놓을 수 있는 것은 blittable한 타입으로 한정되므로, 실무에서는 string이나 List
flowchart LR
accTitle: Native AOT로 C#을 네이티브 DLL로 만들어 C/C++에서 호출
accDescr: Native AOT와 UnmanagedCallersOnly로 C#을 C/C++에서 호출되는 네이티브 DLL로 발행할 수 있는 것, 경계면을 C ABI로 떨어뜨리기 위한 호출 규약·blittable 타입·핸들 설계·에러 표현의 요점, P/Invoke·C++/CLI·COM과의 사용 구분을 보여주는 그림
native_aot["Native AOT"]
unmanagedcallersonly["UnmanagedCallersOnly 속성"]
blittable_type["blittable 형식"]
calling_convention["호출 규약(calling convention)"]
c_abi["C ABI(Application Binary Interface)"]
native_aot_publish_toolchain["Native AOT publish용 네이티브 툴체인"]
dotnet[".NET(Core 이후)"]
native_to_managed_inprocess_call["C/C++에서 C# 로직을 in-process로 호출하는 요구사항"]
p_invoke["P/Invoke"]
csharp_calls_c_functions["C#에서 C 함수를 호출하는 요구 사항"]
cpp_cli["C++/CLI"]
csharp_calls_cpp_library["C#에서 C++ 라이브러리를 자연스럽게 다루는 요구 사항"]
com_interop["COM(Component Object Model)"]
cross_process_or_bitness_boundary["32비트/64비트 또는 프로세스 경계를 넘는 요구 사항"]
plugin_unload_requirement["플러그인을 나중에 언로드하려는 요구사항"]
status_code_error_convention["오류를 상태 코드로 반환하는 설계"]
handle_based_api_design["핸들 기반 C API 설계(create/destroy/operate)"]
handle_counter_overflow["핸들 번호 카운터의 오버플로"]
handle_wraparound_collision["핸들 랩어라운드로 인한 충돌"]
generation_embedded_handle["세대 번호를 내장한 핸들 설계"]
dumpbin["dumpbin"]
import_library["가져오기 라이브러리(.lib)"]
native_aot -->|"이용한다"| unmanagedcallersonly
unmanagedcallersonly -->|"전제로 한다"| blittable_type
calling_convention -->|"에서 구성할 수 있다"| unmanagedcallersonly
c_abi -->|"이용한다"| calling_convention
unmanagedcallersonly -->|"전제로 한다"| c_abi
native_aot -->|"전제로 한다"| native_aot_publish_toolchain
native_aot -->|"전제로 한다"| dotnet
native_aot -->|"권장되는 대응"| native_to_managed_inprocess_call
p_invoke -->|"권장되는 대응"| csharp_calls_c_functions
cpp_cli -->|"권장되는 대응"| csharp_calls_cpp_library
com_interop -->|"권장되는 대응"| cross_process_or_bitness_boundary
native_aot -->|"사용은 비권장"| cross_process_or_bitness_boundary
native_aot -->|"사용은 비권장"| plugin_unload_requirement
status_code_error_convention -->|"권장되는 대응"| unmanagedcallersonly
handle_based_api_design -->|"권장되는 대응"| unmanagedcallersonly
handle_counter_overflow -.->|"원인이 될 수 있다"| handle_wraparound_collision
generation_embedded_handle -->|"방지한다"| handle_wraparound_collision
native_aot -->|"에서 확인할 수 있다"| dumpbin
import_library -.->|"전제로 한다"| native_aot
native_aot -->|"사용은 비권장"| csharp_calls_c_functions
native_aot -->|"사용은 비권장"| csharp_calls_cpp_library
handle_based_api_design -.->|"원인이 될 수 있다"| handle_wraparound_collision
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 22건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론 (한 줄로)
- C/C++에서 C# 처리를 in-process로 호출하고 싶다면, Native AOT +
UnmanagedCallersOnly는 상당히 유력합니다. - 다만 export되는 것은 어디까지나 C 함수 입구입니다.
string이나List<T>를 그대로 보여 주는 세계가 아닙니다. - 실무에서는
create/destroy/operate처럼 플랫한 C API로 맞추고, 수명 관리와 에러 코드를 명시하는 편이 안정적입니다. - C++ 클래스나 STL을 자연스럽게 다루고 싶다면 C++/CLI가, 등록·자동화·프로세스 너머가 필요하다면 COM이 더 맞습니다.
요컨대 C#을 네이티브 DLL의 내용물로 쓸 수는 있지만, 경계면은 .NET이 아니라 C ABI로 설계한다는 이야기입니다. 이 부분을 딱 정리하고 가면 꽤 재미있는 무기가 됩니다.
flowchart TB
accTitle: 경계면은 C ABI로 설계한다
accDescr: C# 내부는 클래스나 컬렉션 그대로여도 되지만, 바깥에 보여 주는 경계면은 string이나 List<T>가 아니라 create / destroy / operate처럼 플랫한 C API로 맞추어 수명 관리와 에러 코드를 명시합니다.
inner["내부는 평범한 C#"] --> face["바깥에 보여 주는 면은 플랫한 C API"]
face --> h["수명 관리는 handle로 명시"]
face --> e["에러는 코드로 반환"]
face -.-> ng["string이나 List〔T〕는 보여 주지 않음"]
그림2: 정리할 지점은 하나입니다. “.NET을 그대로 보여 주는” 것이 아니라 경계면을 C ABI로 맞춥니다.
2. 먼저 보는 구분
| 하고 싶은 일 | 유력 후보 | 이유 |
|---|---|---|
| C#에서 C 함수들을 호출 | P/Invoke | 방향이 직관적이고, 가장 자연스럽습니다 |
| C#에서 C++ 라이브러리를 자연스럽게 다룸 | C++/CLI | C++ 타입, 소유권, 예외, std::wstring 등을 C++ 쪽에서 흡수하기 쉽습니다 |
| 32bit / 64bit나 프로세스 경계를 넘음 | COM / IPC | in-process DLL만으로는 넘을 수 없습니다 |
| C/C++에서 C# 로직을 네이티브 DLL로 호출 | Native AOT + UnmanagedCallersOnly |
C의 entry point를 직접 export할 수 있습니다 |
이 구성이 잘 맞는 것은 “네이티브 쪽이 주인공이고, C#은 부품으로 호출되는” 장면입니다. 여기는 P/Invoke나 C++/CLI와 정확히 방향이 다릅니다.
flowchart TB
accTitle: 주인공 방향의 차이
accDescr: P/Invoke나 C++/CLI는 C#이 주인공이고 네이티브 쪽을 끌어들이는 방향인 반면, 이 글의 Native AOT 구성은 네이티브 쪽이 주인공이고 C# 로직을 부품으로 호출하는, 정확히 반대 방향임을 보여 줍니다.
cs["C#이 주인공"] -->|"네이티브를 호출"| n1["P/Invoke나 C++/CLI"]
nat["네이티브가 주인공"] -->|"C#을 부품으로 호출"| n2["Native AOT로 export"]
그림3: 다리는 방향으로 고릅니다. 이 글의 구성은 “네이티브가 주인공이고 C#이 부품”인 방향입니다.
3. 구성도
flowchart LR
Cpp["C / C++ 앱"] -->|cdecl 함수 호출| Dll["Native AOT로 발행한 C# DLL"]
Dll --> Exports["UnmanagedCallersOnly가 붙은 export"]
Exports --> Core["C#의 업무 로직"]
Exports --> Store["핸들 테이블 / 상태 관리"]
그림4: C/C++ 앱에서는 UnmanagedCallersOnly의 export만 C 함수로 보입니다.
모양은 단순합니다. 중요한 것은 경계면을 C 함수에 맞추는 것입니다. C# 쪽 내부 구현이 클래스든 컬렉션이든 LINQ든 상관없지만, 바깥에 보여 주는 면은 flat하게 갑니다.
4. 최소 구성
여기서는 C++ 쪽에서 “가산기”를 만들어 값을 더하고, 마지막에 합계를 얻는 최소 예제로 갑니다. 실무에서는 판정 엔진이든, 설정 해석이든, 간단한 파서든 상관없습니다. 네이티브 쪽이 handle을 들고, 조작 함수를 순서대로 호출하는 형태라고 생각하면 됩니다.
4.1. C# 프로젝트
먼저 클래스 라이브러리를 준비합니다.
<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
포인트는 두 가지입니다.
- Native AOT publish를 켜 둘 것
- 포인터 인자를 쓰므로
unsafe를 허용할 것
이 글의 샘플은 net8.0을 전제로 하지만, 생각 자체는 .NET 9 / 10에서도 같습니다.
4.2. export하는 C# 코드
UnmanagedCallersOnly를 붙인 메서드가 네이티브 쪽에서 보이는 입구가 됩니다. 여기서는 handle을 정수로 발급하고, 내부 상태는 C# 쪽 dictionary로 관리합니다.
// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace KomuraSoft.NativeAotSample;
internal static class NativeStatus
{
public const int Ok = 0;
public const int InvalidArgument = -1;
public const int InvalidHandle = -2;
public const int UnexpectedError = -3;
}
internal sealed class Accumulator
{
public long Total { get; private set; }
public void Add(int value)
{
Total += value;
}
}
internal static class AccumulatorStore
{
private static readonly object s_gate = new();
private static readonly Dictionary<nint, Accumulator> s_instances = new();
private static long s_nextHandle = 0;
public static int Create(out nint handle)
{
try
{
var instance = new Accumulator();
handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);
lock (s_gate)
{
s_instances.Add(handle, instance);
}
return NativeStatus.Ok;
}
catch
{
handle = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Add(nint handle, int value)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
return NativeStatus.InvalidHandle;
}
instance.Add(value);
return NativeStatus.Ok;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
public static int GetTotal(nint handle, out long total)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
total = 0;
return NativeStatus.InvalidHandle;
}
total = instance.Total;
return NativeStatus.Ok;
}
}
catch
{
total = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Destroy(nint handle)
{
try
{
lock (s_gate)
{
return s_instances.Remove(handle)
? NativeStatus.Ok
: NativeStatus.InvalidHandle;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
}
public static unsafe class NativeExports
{
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_create",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorCreate(nint* outHandle)
{
if (outHandle == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.Create(out var handle);
*outHandle = handle;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_add",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorAdd(nint handle, int value)
{
return AccumulatorStore.Add(handle, value);
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_get_total",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorGetTotal(nint handle, long* outTotal)
{
if (outTotal == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.GetTotal(handle, out var total);
*outTotal = total;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_destroy",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorDestroy(nint handle)
{
return AccumulatorStore.Destroy(handle);
}
}
하는 일은 꽤 소박합니다.
- 네이티브 쪽에 보여 주는 것은
intptr_thandle뿐 - 상태 본체는 C# 쪽이 가짐
- create / add / get / destroy를 flat한 함수로 나눔
- 반환값은 에러 코드, 출력값은 포인터 인자로 돌려줌
이 형태로 두면 C# 쪽 내부 구현을 나중에 바꿔도, C 쪽 ABI는 꽤 안정적입니다.
flowchart TB
accTitle: handle 기반의 플랫한 API
accDescr: create로 handle을 발급하고, add나 get 같은 조작 함수를 handle과 함께 호출한 뒤 destroy로 정리합니다. 상태 본체는 C# 쪽이 가지며, 반환값은 에러 코드, 출력값은 포인터 인자로 돌려줍니다.
create["create: handle을 발급"] --> op["add나 get: handle을 붙여 조작"]
op --> destroy["destroy: 정리"]
op -.-> state["상태 본체는 C# 쪽이 가짐"]
op -.-> err["반환값은 에러 코드"]
그림5: 네이티브 쪽에 보여 주는 것은 handle과 조작 함수뿐입니다. 내부 구현을 바꿔도 ABI는 안정적입니다.
handle 번호 부여에 대해 하나만 보충합니다. 샘플에서는 번호 부여용 카운터를 long으로 두고, Interlocked.Increment 결과를 nint로 캐스트합니다. 여기에는 알아 두는 편이 좋은 성질이 두 가지 있습니다.
- 0은 발급되지 않습니다. 카운터는 0에서 시작하고,
Increment는 더한 뒤의 값을 반환하므로 첫 handle은 1입니다. C++ 쪽이intptr_t handle = 0;을 “아직 들고 있지 않음”의 표시로 쓸 수 있는 것은 이 때문입니다. - 32bit에서는 잘림이 일어납니다.
nint는 포인터 폭이므로 64bit라면 64bit이지만, 32bit 빌드에서는 32bit입니다.long에서nint로의 캐스트는 상위 비트를 조용히 버리므로, 번호가 2^32를 넘으면 값이 한 바퀴 돕니다. 24시간 연속으로 create / destroy를 반복하는 쓰임새라면, 이론상으로는 닿습니다.
한 바퀴 돌면 무엇이 일어나는지, 여기는 정확히 알아 두어야 합니다. s_instances.Add(handle, instance)의 중복 키 예외는, 같은 값의 handle이 “지금 살아 있을” 때만 도움이 됩니다. 이 API의 평범한 쓰임새는 create와 destroy의 반복이고, destroy가 끝난 handle은 Dictionary에서 지워져 있습니다. 즉 한 바퀴 돌아 같은 값으로 돌아왔을 때 Dictionary에 키는 없으므로 Add는 성공합니다. 그 결과 C 쪽이 그대로 들고 있는 오래된 handle이, 무관한 새 인스턴스를 가리키게 됩니다. 예외도 나오지 않고 에러 코드도 반환되지 않으므로, 값만 조용히 깨집니다.
하나 더, 2^32 바로 그 값은 하위 32비트가 전부 0이므로, “아직 들고 있지 않음”의 표시로 쓰려던 0이 발급됩니다.
따라서 중복 키 검사를 안전장치로 믿지 마십시오. 32bit를 상대할 가능성이 있다면 다음 중 하나로 갑니다.
- 세대 번호를 handle에 넣습니다. 하위를 일련번호, 상위를 세대로 두고, destroy할 때마다 세대를 올립니다. 같은 일련번호가 돌아와도 값이 일치하지 않습니다
- 다 쓰면 영구히 실패시킵니다. 번호 부여가 상한에 닿으면 이후 create를 에러로 합니다. 계속 돌아가는 장치에서는 재시작이 필요해지지만, 조용히 깨지는 쪽보다 다루기 쉽습니다
어느 쪽이든, nint 폭을 넘지 않도록 번호 부여 카운터 자체를 nint로 두고, 0을 발급하지 않는 것을 함께 지킵니다.
flowchart TB
accTitle: handle 번호가 한 바퀴 돌았을 때의 깨지는 방식과 대책
accDescr: 32bit에서 번호가 한 바퀴 돌면, destroy가 끝나 Dictionary에서 지워진 값의 Add는 성공하고, C 쪽이 그대로 들고 있는 오래된 handle이 무관한 새 인스턴스를 가리켜 조용히 깨집니다. 대책은 세대 번호를 넣는 것, 또는 상한에 닿은 뒤에는 영구히 실패시키는 것입니다.
wrapd["32bit에서 번호가 한 바퀴"] --> add["같은 값의 Add가 성공해 버림"]
add --> alias["오래된 handle이 새 인스턴스를 가리킴"]
alias --> silent["예외도 에러 코드도 없이 깨짐"]
silent --> g1["대책: 세대 번호를 넣음"]
silent --> g2["대책: 다 쓰면 실패시킴"]
그림6: 중복 키 예외는 안전장치가 되지 않습니다. 한 바퀴의 깨지는 방식은 조용하고, 대책은 설계 쪽에서 넣습니다.
4.3. 발행 커맨드
먼저 전제 하나. Native AOT publish에는 네이티브 툴체인이 따로 필요합니다.
PublishAot만 붙이고 dotnet publish를 치면, C# 컴파일이 아니라 마지막 네이티브 링크 단계에서 실패합니다. 여기가 첫 관문입니다.
| 환경 | 필요한 것 |
|---|---|
| Windows | Visual Studio 2022 이후. “C++를 사용한 데스크톱 개발” 워크로드를, 기본 컴포넌트를 전부 넣은 상태로 설치 |
| Ubuntu 18.04 이후 | sudo apt-get install clang zlib1g-dev |
| Alpine 3.15 이후 | sudo apk add clang build-base zlib-dev |
| Fedora 39 이후 / RHEL 8 이후 | sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel |
| macOS | Xcode의 Command Line Tools(.NET 8 이후 대응) |
이 글은 Windows + C++를 전제로 하므로, 실질적으로 “Visual Studio의 C++ 워크로드가 들어가 있는지”를 먼저 확인하라는 이야기입니다.
링커를 찾지 못한다, link.exe 쪽에서 실패한다, 같은 에러는 대개 여기입니다.
그다음, 공유 라이브러리로 publish합니다.
dotnet publish -r win-x64 -c Release /p:NativeLib=Shared
이렇게 하면 bin/Release/net8.0/win-x64/publish/ 아래에 네이티브 DLL이 나옵니다. Windows 예라면 .dll, Linux라면 .so, macOS라면 .dylib입니다.
중요한 것은 RID마다 publish하는 것입니다. win-x64로 만든 것을 win-arm64 전제로 쓸 수는 없고, 호출 쪽과 DLL의 bitness도 맞춰야 합니다.
flowchart TB
accTitle: publish의 첫 관문
accDescr: Native AOT publish에는 네이티브 툴체인이 따로 필요하고, 없이 dotnet publish를 치면 C# 컴파일이 아니라 마지막 네이티브 링크 단계에서 실패합니다. RID마다 publish하고, bitness도 맞춥니다.
pub["dotnet publish를 실행"] --> q{"네이티브 툴체인이 있는가"}
q -->|"없다"| fail["마지막 네이티브 링크에서 실패함"]
q -->|"있다"| out["RID마다 네이티브 DLL이 나옴"]
out -.-> match["호출 쪽과 bitness를 맞춤"]
그림7: 실패하는 곳은 C# 컴파일이 아니라 링크 단계입니다. 첫 관문은 툴체인 유무입니다.
4.4. C++ 쪽 호출 예
이번에는 import lib 이야기를 일단 빼고, LoadLibrary / GetProcAddress로 그대로 호출합니다. 이 형태면 무엇이 export되어 있고, 어떤 시그니처로 받아야 하는지가 보이기 쉽습니다.
/* native_api.h */
#pragma once
#include <stdint.h>
enum km_status
{
KM_STATUS_OK = 0,
KM_STATUS_INVALID_ARGUMENT = -1,
KM_STATUS_INVALID_HANDLE = -2,
KM_STATUS_UNEXPECTED_ERROR = -3
};
typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>
#include "native_api.h"
template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
FARPROC proc = ::GetProcAddress(module, name);
if (proc == nullptr)
{
std::cerr << "GetProcAddress failed: " << name << '\n';
std::exit(EXIT_FAILURE);
}
return reinterpret_cast<T>(proc);
}
int main()
{
HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
if (module == nullptr)
{
std::cerr << "LoadLibraryW failed" << '\n';
return EXIT_FAILURE;
}
auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");
intptr_t handle = 0;
if (create(&handle) != KM_STATUS_OK)
{
std::cerr << "create failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 10) != KM_STATUS_OK)
{
std::cerr << "add(10) failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 20) != KM_STATUS_OK)
{
std::cerr << "add(20) failed" << '\n';
return EXIT_FAILURE;
}
std::int64_t total = 0;
if (getTotal(handle, &total) != KM_STATUS_OK)
{
std::cerr << "get_total failed" << '\n';
return EXIT_FAILURE;
}
std::cout << "total = " << total << '\n';
if (destroy(handle) != KM_STATUS_OK)
{
std::cerr << "destroy failed" << '\n';
return EXIT_FAILURE;
}
handle = 0;
// Native AOT 공유 라이브러리는 언로드 전제로 쓰지 않는다.
// FreeLibrary(module);
return EXIT_SUCCESS;
}
이 예에서는 C++ 쪽에서 보이는 것이 “함수 포인터로 호출할 수 있는 C API”뿐입니다. 안이 C#으로 쓰여 있다는 것은 거의 의식하지 않아도 됩니다.
publish한 DLL을 main.exe와 같은 폴더에 두고 실행하면, 10과 20을 더하고 있으므로 표준 출력은 이것뿐입니다.
total = 30
중간에 실패한 경우에는 std::cerr 쪽에 어느 단계에서 실패했는지가 나옵니다. LoadLibraryW failed라면 DLL을 아예 찾지 못한 것이고, GetProcAddress failed: km_accumulator_add라면 DLL은 읽혔지만 export를 찾지 못한 것이라는 구분입니다.
flowchart TB
accTitle: 호출되지 않을 때의 원인 분리
accDescr: LoadLibraryW가 실패하면 DLL이 로드되지 않은 것이므로 경로·bitness·의존 DLL 부족을 의심하고, GetProcAddress가 실패하면 DLL은 읽혔지만 export를 찾지 못한 것이며, 둘 다 통과하면 함수 포인터로 호출할 수 있다는 구분을 보여 줍니다.
s1{"LoadLibraryW는 성공?"}
s1 -->|"실패"| f1["DLL이 로드되지 않음"]
f1 -.-> f1a["경로·bitness·의존 DLL을 의심"]
s1 -->|"성공"| s2{"GetProcAddress는 성공?"}
s2 -->|"실패"| f2["export를 찾지 못함"]
s2 -->|"성공"| ok["C API로 호출 가능"]
그림8: 어느 단계에서 실패했는지로, DLL 로드 문제인지 export 문제인지를 가를 수 있습니다.
4.5. export되어 있는지 확인하기
“호출이 안 된다”면 먼저 DLL 쪽에 정말 이름이 나와 있는지를 봅니다. Visual Studio Developer Command Prompt에서 dumpbin을 쓰는 것이 빠릅니다.
dumpbin /exports NativeAotSample.dll
km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy 네 개가 name 목록에 늘어서 있으면 C# 쪽 발행은 성공한 것입니다. 이름으로 좁히려면 이렇게 합니다.
dumpbin /exports NativeAotSample.dll | findstr km_
여기에 이름이 없으면 C# 쪽 문제, 있는데 GetProcAddress가 실패하면 호출 쪽 문제, 로 가를 수 있습니다.
GetProcAddress가 NULL을 반환할 때 볼 곳은 대체로 다음 순서입니다.
dumpbin /exports에 이름이 나와 있는지(없으면 C# 쪽 이야기)EntryPoint에 쓴 문자열과GetProcAddress에 넘긴 문자열이 완전히 일치하는지(대소문자도 구분됩니다)- 호출 쪽 EXE와 DLL의 bitness가 맞는지
UnmanagedCallersOnly를 붙인 메서드가static이고, generic 안에 들어가 있지 않은지- 그 속성을 publish 대상 어셈블리 쪽에 썼는지(참조하는 라이브러리에 써도 겉으로 나오지 않습니다)
참고로 LoadLibraryW 자체가 실패하는 경우는 export 이야기가 아닙니다. DLL 경로, bitness, 의존 DLL 부족을 먼저 의심하십시오.
flowchart TB
accTitle: GetProcAddress가 NULL일 때의 확인 순서
accDescr: dumpbin의 exports 목록에 이름이 나와 있는지, EntryPoint 문자열과 넘긴 문자열이 완전 일치인지, bitness가 맞는지, 메서드가 static이고 generic 밖에 있는지, 속성을 publish 대상 어셈블리 쪽에 썼는지 순으로 확인합니다.
c1["dumpbin에 이름이 나와 있는지"] --> c2["문자열이 완전 일치인지"]
c2 --> c3["bitness가 맞는지"]
c3 --> c4["static이고 generic 밖인지"]
c4 --> c5["publish 대상 쪽에 속성을 썼는지"]
c1 -.->|"나와 있지 않음"| cs["C# 쪽 문제로 조사"]
그림9: export에 이름이 나와 있는지를 먼저 보면, C# 쪽 문제인지 호출 쪽 문제인지가 정해집니다.
4.6. import lib으로 정적 링크하고 싶을 때
여기까지는 LoadLibrary / GetProcAddress 방식으로 썼습니다. 무엇이 export되어 있고, 어떤 시그니처로 받아야 하는지가 보이기 쉽기 때문입니다.
한편 실무에서는 “헤더를 include하고 그대로 함수를 호출하고 싶다”는 경우도 많을 것입니다. 그때는 import library를 쓰는 정적 로드가 됩니다. 절차는 다음과 같습니다.
- publish 출력에 import library(
.lib)가 나와 있으면 그것을 링크합니다 - 없으면 export 이름을 나열한
.def파일을 준비하고,lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64로 import library를 만듭니다 - 헤더 쪽은 함수 포인터 타입이 아니라 보통의 함수 선언으로 합니다
/* native_api_static.h */
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);
#ifdef __cplusplus
}
#endif
이 형태로 두면 호출 쪽 코드는 꽤 단순해집니다. 그 대신 DLL을 찾지 못하면 프로세스 시작 시점에 실패하므로, “본체는 돌아가지만 이 기능만 못 쓴다”는 운용에는 잘 맞지 않습니다. 플러그인처럼 끼워 넣고 싶다면 LoadLibrary 방식 그대로가 다루기 쉽습니다.
flowchart TB
accTitle: 동적 로드와 정적 링크의 구분
accDescr: LoadLibrary 방식은 실행 시점에 읽으므로 플러그인처럼 끼워 넣기에 맞고, import library로 정적 링크하면 헤더를 include하고 그대로 호출할 수 있는 대신, DLL이 없으면 프로세스 시작 시점에 실패합니다.
q{"어떻게 넣고 싶은가"}
q -->|"플러그인처럼 끼워 넣음"| dyn["LoadLibrary 방식"]
q -->|"헤더로 그대로 호출"| stat["import lib으로 정적 링크"]
stat -.-> risk["DLL이 없으면 시작 시점에 실패함"]
그림10: 그대로 쓸 수 있는 것은 정적 링크이지만, “이 기능만 없이” 돌릴 수 있는 것은 동적 로드입니다.
참고로 정적 라이브러리(NativeLib=Static)로 발행하는 것은 공식으로는 지원되지 않으므로, 그쪽은 믿지 않는 편이 안전합니다.
5. 잘 안 깨지는 API 모양
Native AOT로 export할 수 있는 것은 재미있지만, 실무에서는 무엇을 export하지 않을지가 더 중요합니다.
5.1. C ABI에 맞추기
먼저 말을 정리해 둡니다. 이 글의 핵심은 “경계면을 .NET이 아니라 C ABI로 설계한다”인데, 그 ABI는 Application Binary Interface의 약자로, 컴파일된 바이너리끼리 실행 시점에 어떻게 맞물릴지의 약속입니다. 소스 코드 수준이 아니라 기계어 수준의 약속이라고 생각하면 됩니다. 내용은 주로 세 가지입니다.
| 약속 | 무엇을 정하는가 | 여기서 지키지 않으면 어떻게 되는가 |
|---|---|---|
| 호출 규약(calling convention) | 인자를 레지스터와 스택 중 어디에 어떻게 넘기는지, 반환값을 어디에 두는지, 호출 뒤에 스택을 되돌리는 것은 호출하는 쪽인지 호출되는 쪽인지 | 인자가 어긋남, 돌아온 직후 스택이 깨짐 |
| 타입 레이아웃 | 각 타입이 몇 바이트인지, struct 멤버가 어느 위치에 늘어서는지(패딩과 정렬) | 구조체 중간부터 값을 읽지 못하게 됨 |
| 이름과 링크 | export되는 함수 이름의 철자, 장식(decoration) 유무 | GetProcAddress가 이름을 찾지 못함 |
cdecl이나 stdcall은 이 가운데 첫 번째 “호출 규약”의 이름입니다. C++ 클래스나 예외는 이 세 가지 약속이 컴파일러마다 다르므로, 그대로 경계에 내면 맞물리지 않습니다. 반대로 말하면 C 함수와 기본형만으로 좁히면 약속이 단순해서 맞물리기 쉽다는 이야기입니다. “C ABI에 맞춘다”는 이 단순한 약속 범위까지 경계면을 내린다는 뜻입니다.
flowchart TB
accTitle: ABI라는 약속의 내용
accDescr: ABI는 컴파일된 바이너리끼리 실행 시점에 어떻게 맞물릴지의 약속이며, 인자와 반환값의 넘기는 방식을 정하는 호출 규약, 타입 레이아웃, export 이름과 장식이라는 세 가지로 이루어집니다.
abi["ABI〔기계어 수준의 약속〕"] --> a1["호출 규약"]
abi --> a2["타입 레이아웃"]
abi --> a3["이름과 링크"]
a1 -.-> ex["cdecl이나 stdcall은 여기의 이름"]
그림11: “C ABI에 맞춘다”는 이 세 가지 약속이 단순한 범위까지 경계면을 내리는 것입니다.
그다음, 경계에 내보내는 타입은 처음부터 다음 부근에 맞추는 편이 무난합니다.
int32_t/int64_t/double같은 기본형- 고정 레이아웃 struct
intptr_t/void*상당의 handleuint8_t*와 길이
반대로, 처음부터 바깥에 흘리고 싶지 않은 것은 이 부근입니다.
stringobjectList<T>TaskSpan<T>- C++ 클래스나
std::vector나std::wstring
이 근처를 그대로 경계를 넘기려 하면 경계면이 금방 탁해집니다. C#의 사정을 C++에 흘리지 않고, C++의 사정도 C#에 너무 흘리지 않는 것이 중요합니다.
flowchart TB
accTitle: 경계에 내보내는 타입과 흘리지 않는 타입
accDescr: 경계에 내보내는 것은 기본형, 고정 레이아웃 struct, handle, 포인터와 길이에 맞추고, string이나 object, List<T>, Task, C++ 클래스나 STL은 바깥에 흘리지 않습니다.
edge["경계에 내보내는 타입"] --> ok1["기본형과 고정 struct와 handle"]
edge --> ok2["포인터와 길이"]
keepx["바깥에 흘리지 않는 타입"] --> ng1["string이나 List〔T〕나 Task"]
keepx --> ng2["C++ 클래스나 STL"]
그림12: 실무에서 중요한 것은 “무엇을 export하지 않을지”입니다. 양쪽 사정을 경계에 흘리지 않습니다.
옮겨 적다 틀리는 일을 줄이려고 대응표도 둡니다. UnmanagedCallersOnly를 붙인 메서드 시그니처에는 blittable한 타입만 쓸 수 있으므로, 실질 이 범위에 들어갑니다.
| C# 쪽 | C / C++ 쪽 | 보충 |
|---|---|---|
byte / sbyte |
uint8_t / int8_t |
|
short / ushort |
int16_t / uint16_t |
|
int / uint |
int32_t / uint32_t |
|
long / ulong |
int64_t / uint64_t |
C++의 long은 Windows에서 32bit, Linux의 LP64에서 64bit이므로, long이라고 쓰지 않고 int64_t를 쓰는 것이 안전합니다 |
nint / nuint |
intptr_t / uintptr_t |
포인터 폭. 32bit 빌드에서는 32bit가 됩니다 |
float / double |
float / double |
|
bool |
쓰지 않음 | blittable이 아닙니다. int32_t로 0 / 1을 넘깁니다 |
char / string |
쓰지 않음 | 문자열은 5.2대로 포인터 + 길이로 다룹니다 |
T*(unsafe 포인터) |
T* |
출력값은 이것으로 돌려줍니다 |
| 고정 레이아웃 struct | 같은 레이아웃의 struct | 멤버 순서, 타입, 패딩을 양쪽에서 반드시 일치시킵니다 |
5.2. 문자열은 포인터 + 길이 + 버퍼 용량으로 다루기
문자열을 주고받고 싶어지면 바로 string을 내보내고 싶어지지만, 여기는 꾹 참는 편이 낫습니다. 라이브러리 경계에서는 예를 들어 다음 형태로 맞추는 것이 알기 쉽습니다.
int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);
문자 코드, 길이, 누가 버퍼를 확보할지를 먼저 정해 둔다는 이야기입니다. Windows이니까 UTF-16에 맞추는 선택지도 있지만, 다른 언어까지 내다보면 UTF-8이 다루기 쉬운 경우가 많습니다.
5.3. 예외를 경계 밖으로 보내지 않기
네이티브 함수 경계는 예외 표현으로서는 그다지 친절하지 않습니다. 적어도 managed 예외를 그대로 호출 쪽으로 흘리는 설계는 하지 않는 편이 안전합니다.
실무에서는
- 반환값은 status code
- 실제 데이터는 out 버퍼나 포인터 인자
- 필요하면
get_last_error형식으로 추가 정보를 얻기
처럼 두면 다루기 쉽습니다.
화려하지는 않지만, 이런 소박한 설계가 나중에 먹힙니다. 경계면에서 갑자기 격투기를 시작하지 않는다는 이야기입니다.
flowchart TB
accTitle: 예외를 경계 밖으로 보내지 않는 에러 설계
accDescr: managed 예외를 그대로 호출 쪽으로 흘리지 않고, 반환값은 status code, 실제 데이터는 out 버퍼나 포인터 인자, 필요하면 get_last_error 형식으로 추가 정보를 받는 형태로 합니다.
exc["C# 내부의 예외"] --> stop["경계 안쪽에서 받음"]
stop --> code["반환값은 status code"]
stop --> outp["실제 데이터는 포인터 인자로"]
stop -.-> last["추가 정보는 get_last_error 형식"]
그림13: 예외는 경계를 넘기지 않고, status code와 포인터 인자의 세계로 번역해서 돌려줍니다.
5.4. 호출 규약을 고정하기
샘플에서는 CallConvCdecl을 명시했습니다. 생략하면 플랫폼 기본 호출 규약이 되지만, 헤더나 함수 포인터 타입을 고정하고 싶다면 이쪽에서 명시해 버리는 편이 실수하기 어렵습니다.
특히 x86을 상대할 가능성이 있으면, 여기를 애매하게 두면 나중에 힘듭니다. x64에서는 겉으로 잘 안 나와도, 규칙을 처음부터 정해 두는 편이 낫습니다.
5.5. Export 메서드는 얇게 두고, 본체는 따로 두기
UnmanagedCallersOnly를 붙인 메서드는 보통의 managed 코드에서 그대로 호출하는 전제가 아닙니다. 그래서 거기에 업무 로직을 전부 쓰기 시작하면 테스트도 어려워집니다.
샘플에서도 실체 관리는 AccumulatorStore에 두고, export되는 NativeExports는 얇은 입구만 두었습니다. 이것은 꽤 중요합니다.
- export 메서드: ABI의 창구
- 내부 클래스: 평범한 C# 로직
이 분업으로 두면 C++와의 경계와 C# 본체 코드를 나눠 생각할 수 있습니다.
flowchart TB
accTitle: export는 얇게, 본체는 따로 둠
accDescr: UnmanagedCallersOnly가 붙은 export 메서드는 ABI의 얇은 창구에 머물고, 실체 관리와 업무 로직은 내부 클래스에 두어 경계와 본체를 나눠 생각할 수 있고 테스트도 쉬워집니다.
exp["export 메서드〔얇은 창구〕"] --> core["내부 클래스〔평범한 C#〕"]
exp -.-> abi["ABI 검사와 변환만 담당"]
core -.-> test["보통의 C#으로 테스트 가능"]
그림14: export에 업무 로직을 쓰기 시작하지 마십시오. 창구와 본체의 분업이 보수와 테스트를 편하게 합니다.
6. 잘 맞는 경우
이 구성이 꽤 기분 좋게 들어맞는 것은 이런 장면입니다.
- 기존 C/C++ 앱은 그대로 두고, 일부 업무 로직만 C#으로 옮기고 싶다
- .NET 런타임의 사전 설치를 배포 전제로 하고 싶지 않다
- export하는 함수 표면을 작게 유지할 수 있다
- 앞으로 Rust나 Go 등, 다른 언어에서도 같은 C API로 호출하고 싶어질지 모른다
특히 네이티브 앱은 그대로 두고, 교체하기 쉬운 로직 계층만 C#으로 쓰는 구성과 궁합이 좋습니다. UI나 장치 제어는 C++ 그대로, 판정이나 계산이나 설정 규칙은 C#, 이라는 구분입니다.
flowchart TB
accTitle: 궁합이 좋은 구분
accDescr: UI나 장치 제어는 C++ 그대로 두고, 판정·계산·설정 규칙처럼 교체하기 쉬운 로직 계층만 C#으로 써서, 작은 C API 표면으로 잇는 구성에 맞습니다.
app["기존 C/C++ 앱"] --> keepn["UI나 장치 제어는 C++ 그대로"]
app --> logic["판정·계산·설정 규칙은 C#"]
logic --> api["작은 C API 표면으로 이음"]
api -.-> multi["다른 언어에서도 같은 표면으로 호출 가능"]
그림15: 네이티브가 주인공인 채로, 교체하기 쉬운 로직 계층만 C#의 생산성을 가져옵니다.
7. 그래도 맞지 않는 경우
물론 만능은 아닙니다. 맞지 않는 장면도 분명합니다.
- C++ 클래스나
std::vector나 예외를 그대로 다루고 싶다- 이럴 때는 C++/CLI나 네이티브 쪽 래퍼가 더 자연스럽습니다.
- COM 등록, VBA / Office 자동화, Explorer 확장 같은 세계에 들어가고 싶다
- 여기는 COM 문맥으로 생각하는 편이 낫습니다.
- 32bit / 64bit를 다리 놓고 싶거나, 프로세스 경계를 넘고 싶다
- in-process DLL이 아니라 COM / IPC / 별도 프로세스 구성 쪽이 맞습니다.
- 플러그인을 나중에 언로드하고 싶다
- Native AOT 공유 라이브러리는 언로드 전제로 쓰지 않는 편이 좋습니다.
- 의존 라이브러리가 reflection이나 동적 코드 생성에 크게 의존한다
- AOT publish의 warning이 난다면 그 warning을 대충 무시하지 않는 편이 안전합니다.
결국 C ABI로 정리할 수 있는지가 갈림길입니다. 정리할 수 없다면 다른 다리가 더 깔끔합니다.
flowchart TB
accTitle: 맞지 않는 경우와 대신할 다리
accDescr: C++ 클래스나 예외를 그대로 다룬다면 C++/CLI, COM 등록이나 자동화 세계라면 COM, bitness나 프로세스 경계를 넘으면 COM이나 IPC, 언로드 전제 플러그인에는 이 구성 자체가 맞지 않습니다.
q{"무엇을 원하는가"}
q -->|"C++ 타입이나 예외를 그대로"| cli["C++/CLI나 래퍼로"]
q -->|"등록이나 자동화의 세계"| com["COM 문맥으로"]
q -->|"bitness나 프로세스 너머"| ipc["COM이나 IPC나 별도 프로세스로"]
q -->|"나중에 언로드하고 싶다"| ng["이 구성은 전제에 맞지 않음"]
그림16: 갈림길은 “C ABI로 정리할 수 있는지”입니다. 정리할 수 없다면 다른 다리가 더 깔끔합니다.
8. 빠지기 쉬운 함정
마지막으로 Native AOT export에서 소박하게 빠지기 쉬운 점을 모읍니다.
UnmanagedCallersOnly를 붙이는 메서드는static이어야 합니다.- generic 메서드나 generic class 안에는 둘 수 없습니다.
- named export로 하고 싶다면
EntryPoint를 붙입니다. ref/in/out은 쓰지 말고, 포인터 인자로 돌려주는 형태가 좋습니다.- export되는 것은 publish 대상 어셈블리 쪽 메서드입니다. 참조하는 라이브러리의 메서드에 속성을 붙여도 그대로는 겉으로 나오지 않습니다.
- 호출 쪽과 DLL의 bitness는 맞춰야 합니다.
- publish warning은 꽤 중요합니다. AOT / trimming의 warning이 나 있다면 먼저 그쪽을 정리하는 편이 안전합니다.
이 근처는 전부 “알고 나면 그렇지요”라는 이야기입니다. 하지만 모르는 상태에서 한 번 밟으면 꽤 씁쓸한 시간이 흐릅니다.
9. 정리
C/C++에서 C#을 호출하고 싶을 때 먼저 떠오르는 것은 COM이거나, C++/CLI이거나, 별도 프로세스입니다. 전부 올바른 선택지입니다.
다만 in-process 네이티브 DLL로 C# 처리를 끼워 넣고 싶다면, Native AOT + UnmanagedCallersOnly는 꽤 재미있는 선택지입니다.
포인트를 한 번만 더 적어 둡니다.
- C#을 그대로 보여 주지 말고, C ABI로 flatten한다
- handle 기반으로 수명 관리를 명시한다
- 예외가 아니라 error code로 경계를 넘는다
- 호출 규약을 고정한다
- export 메서드는 얇게 두고, 내부 로직과 나눈다
하는 일은 화려하지 않습니다. 하지만 이런 “경계를 어떻게 자를까”가 나중 유지보수에 꽤 영향을 줍니다. 네이티브 자산을 살리면서 로직 계층만 C#의 생산성을 가져오고 싶을 때, 이 구성은 기억해 둬서 손해 볼 일이 없습니다.
flowchart TB
accTitle: 잘 안 깨지는 경계의 다섯 가지
accDescr: C ABI로의 flatten, handle 기반 수명 관리, error code로 경계 넘기, 호출 규약 고정, export를 얇게 두고 내부 로직과 나누기의 다섯 가지가 잘 안 깨지는 경계를 만듭니다.
goal["잘 안 깨지는 C#으로 만든 네이티브 DLL"] --> p1["C ABI로 flatten한다"]
goal --> p2["handle로 수명을 명시"]
goal --> p3["error code로 경계를 넘김"]
goal --> p4["호출 규약을 고정"]
p1 -.-> p5["export는 얇게 본체와 나눔"]
그림17: 정리의 다섯 가지. 소박한 경계 설계가 나중 유지보수에 가장 영향을 줍니다.
10. 참고 자료
- 이 글의 샘플 코드 세트(C# 라이브러리, C++ 호출 예, 유닛 테스트) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- DUMPBIN /EXPORTS - Microsoft Learn
- LIB Reference - Microsoft Learn
- C#에서 네이티브 DLL 호출하기: C++/CLI 래퍼 vs P/Invoke - KomuraSoft Blog
- 32bit 앱에서 64bit DLL을 호출하는 COM 브리지 실례 - KomuraSoft Blog
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Windows on Arm에서 업무 앱은 동작하는가 ── x64 에뮬레이션(Prism)과 네이티브 DLL·COM의 현실
「Windows on Arm에서 업무 앱은 동작하는가」에 개발자와 사내 IT를 위해 답합니다. x64 에뮬레이션(Prism)의 구조, 드라이버처럼 동작하지 않는 계층, .NET의 AnyCPU와 P/Invoke 문제, Arm 대응 체크리스트까지 정...
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
WMI/CIM을 C#·PowerShell에서 쓰기 ── 하드웨어 정보 가져오기·프로세스 모니터링·원격 조회의 실무 가이드
PC 시리얼 번호 조회, 디스크 여유 공간 모니터링, 프로세스 시작 감지의 흔한 답이 WMI/CIM입니다. Get-CimInstance 등 CIM cmdlet 사용법과 구 Get-WmiObject에서의 이전, C#의 System.Managemen...
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 깨뜨리는지의 판단표
DLL이나 COM 컴포넌트의 어떤 변경이 호출 측을 깨뜨리는지. 바이너리 호환・소스 호환・동작 호환의 3계층을 정리하고, 변경 내용별 판단표, COM 인터페이스 불변의 철칙, semver 운용까지를 실무 가이드로 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
32비트 / 64비트 상호 운용
32비트 / 64비트 상호 운용, 네이티브 경계, 관련된 Windows 설계 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
C#과 C/C++ 경계를 구현하는 주제라서, Windows 앱 개발로서의 설계·구현 상담으로 바로 이어집니다.
기존 자산 활용 & 이관 지원
기존 네이티브 자산과 .NET을 어떻게 이을지라는 점에서, 기존 자산 활용·마이그레이션 지원과도 잘 맞습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- C++에서 C# 코드를 호출할 수 있나요?
- 호출할 수 있습니다. .NET Native AOT에서는 C# 클래스 라이브러리를 네이티브 공유 라이브러리로 발행할 수 있고, UnmanagedCallersOnly를 붙인 메서드를 C의 엔트리 포인트로 공개할 수 있습니다. 즉 C#을 '호출되는 쪽의 네이티브 DLL'로 C/C++에서 in-process로 쓸 수 있습니다.
- 이 구성은 어떤 상황에 맞나요?
- 네이티브 앱 본체는 그대로 두고, 판정 로직, 문자열 처리, 설정 해석, 계산 규칙 같은 부분만 C#으로 옮기고 싶은 상황입니다. '네이티브 쪽이 주인공이고, C#은 부품으로 호출되는' 방향이 특징입니다. 반대로 C#에서 C 함수들을 호출한다면 P/Invoke, C++ 타입이나 소유권을 자연스럽게 다루고 싶다면 C++/CLI, 32bit/64bit나 프로세스 경계를 넘어야 한다면 COM/IPC가 맞습니다.
- API를 설계할 때 주의할 점은 무엇인가요?
- export되는 것은 어디까지나 C 함수 입구이므로, string이나 List<T>, 예외를 경계에 그대로 보여 주면 안 됩니다. create / destroy / operate처럼 플랫한 C API로 맞추어 수명 관리와 에러 코드를 명시하고, 문자열은 포인터+길이+버퍼 용량으로 다루며, 예외를 경계 밖으로 보내지 않고, 호출 규약을 고정합니다. 경계면은 .NET이 아니라 C ABI로 설계하는 것이 요점입니다.
- 동작하는 샘플 코드가 있나요?
- 있습니다. GitHub의 komurasoft-blog-samples 리포지토리에 Native AOT로 발행하는 C# 라이브러리, C++ 호출 예, 유닛 테스트를 포함한 빌드·실행 가능한 샘플 세트가 공개되어 있습니다. 코드 예는 Windows DLL을 전제로 하지만, 접근 방식은 Linux / macOS에서도 거의 같습니다.