수정 이력(3건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 한국어 전면 재작성에 맞춰 본문 표현을 바로잡았습니다. 기술적인 주장은 일본어판과 같습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635127)
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635126)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「.NET Native AOT란 무엇인가 - JIT·trimming과의 차이」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635126 https://comcomponent.com/ko/blog/dotnet-native-aot-what-is/
- DOI(최신 버전)
- 10.5281/zenodo.21635126
- DOI(이 버전)
- 10.5281/zenodo.22217431
이미 C# Native AOT DLL을 C/C++에서 호출하는 방법에서는 Native AOT로 C/C++에서 C#을 호출하는 이야기를 썼습니다. 다만 그 앞에 애초에 Native AOT란 무엇인가를 두는 편이 친절했습니다. 순서가 조금 뒤바뀌었습니다.
Native AOT 이야기는 처음에 용어가 섞이기 쉽습니다.
- JIT를 없애는 이야기인가
- self-contained나 single-file과 무엇이 다른가
- ReadyToRun과 같은 계통인가
- trimming warning이 대량으로 나오는 것은 무엇이 일어나고 있는가
- WPF / WinForms / ASP.NET Core에서도 같은 무게로 쓸 수 있는가
이 부분이 한데 섞이면 Native AOT가 「그냥 빨라지는 마법」으로 보이거나, 반대로 「제약 투성이라 무서운 것」으로 보이기도 합니다. 어느 쪽도 조금 거칠습니다.
flowchart TB
accTitle: 용어가 섞이면 생기는 오해
accDescr: JIT나 self-contained, ReadyToRun, trimming 같은 용어가 한데 섞이면 Native AOT가 그냥 빨라지는 마법으로 보이거나 제약 투성이라 무섭게 보이지만, 어느 쪽도 거친 시각이다.
mix["용어가 한데 섞인다"] --> m1["빨라지는 마법으로 보인다"]
mix --> m2["제약 투성이라 무섭게 보인다"]
sort["말을 나눠 정리한다"] --> fair["쓸 곳이 보이는 선택지가 된다"]
그림 1: 오해의 원인은 용어가 섞이는 것이다. 먼저 말을 나누는 것부터 시작한다.
이 글에서는 주로 .NET 8 이후의 현재 실무 감각을 전제로, 다음 네 가지를 먼저 정리합니다.
- Native AOT의 정체
- 무엇이 반갑고, 어디가 까다로워지는가
- ReadyToRun이나 trimming과 어떻게 다른가
- 어떤 앱부터 시도하면 무난한가
목차
- 먼저 결론 (한 줄로)
- 먼저 보는 정리 표
- 2.1. Native AOT 주변의 말
- 2.2. JIT / ReadyToRun / Native AOT의 차이
- Native AOT의 전체 모습 (그림)
- Native AOT에서 무엇이 반가운가
- 4.1. 시작이 가벼워지기 쉽다
- 4.2. 런타임 사전 설치를 전제로 하지 않아도 된다
- 4.3. 제약이 있는 실행 환경에 맞다
- Native AOT에서 무엇이 까다로워지는가
- 5.1. 리플렉션과 동적 코드 생성
- 5.2. trimming을 전제로 생각할 필요가 있다
- 5.3. 플랫폼마다 게시한다
- 5.4. Windows 데스크톱 / COM 맥락은 상당히 신중하게
- 최소 절차
- 6.1.
csproj - 6.2. publish
- 6.3. JSON 작성법
- 6.1.
- 잘 맞는 경우
- 맞지 않는 경우
- 빠지기 쉬운 지점
- 정리
- 참고 자료
이 글의 지식 맵
.NET Native AOT는 실행 시 JIT를 쓰지 않고 publish 시점에 네이티브 코드로 사전 컴파일하는 발행 방식으로, 기동 시간과 메모리 점유를 개선하기 쉽고, .NET 런타임이 설치되지 않은 환경에도 배포하기 쉽습니다. ReadyToRun이 IL을 남긴 채 JIT의 일부를 앞당기는 방향인 데 비해, Native AOT는 실행 시 JIT 자체를 전제로 하지 않는 만큼 trimming이 거의 전제가 되어, 자유로운 리플렉션이나 동적 코드 생성, built-in COM과는 궁합이 나빠집니다. Windows의 Native AOT에는 built-in COM이 없으며, WPF는 trimming과의 궁합이 나쁘다는 이유로, WinForms는 built-in COM marshalling에 대한 의존이 무겁다는 이유로, 둘 다 .NET SDK 쪽에서 trimming 지원이 비활성화되어 있어, 첫 Native AOT 후보로는 신중하게 다뤄야 한다고 여겨집니다. COM이 필요한 경우에는 ComWrappers를 전제로 다시 설계하는 것이 유력한 선택지가 됩니다.
flowchart LR
accTitle: .NET Native AOT
accDescr: Native AOT가 JIT나 ReadyToRun과 어떻게 다르고, trimming이나 리플렉션·built-in COM과 어떻게 충돌하며, WPF와 WinForms가 왜 신중하게 다뤄지는지를 보여주는 그림
native_aot["Native AOT"]
jit_compilation["JIT(Just-In-Time) 컴파일"]
readytorun["ReadyToRun"]
trimming["trimming(트리밍)"]
reflection_dotnet["리플렉션"]
dynamic_code_generation["동적 코드 생성(Reflection.Emit 등)"]
builtin_com_interop["built-in COM interop"]
comwrappers["ComWrappers / source-generated COM"]
wpf["WPF"]
windows_forms["Windows Forms"]
source_generator["source generator"]
system_text_json_source_gen["System.Text.Json의 source generation"]
self_contained_deployment["자체 포함 배포"]
single_file_deployment["single-file 게시"]
dotnet[".NET(Core 이후)"]
native_aot -.->|"양립하지 않는다"| jit_compilation
readytorun -.->|"전제로 한다"| jit_compilation
native_aot -->|"전제로 한다"| trimming
native_aot -.->|"양립하지 않는다"| reflection_dotnet
native_aot -->|"양립하지 않는다"| dynamic_code_generation
native_aot -->|"양립하지 않는다"| builtin_com_interop
comwrappers -->|"권장되는 대응"| native_aot
wpf -->|"양립하지 않는다"| trimming
windows_forms -->|"전제로 한다"| builtin_com_interop
wpf -->|"사용은 비권장"| native_aot
windows_forms -->|"사용은 비권장"| native_aot
native_aot -->|"이용한다"| source_generator
system_text_json_source_gen -->|"완화한다"| reflection_dotnet
native_aot -->|"전제로 한다"| self_contained_deployment
native_aot -.->|"이용한다"| single_file_deployment
native_aot -->|"전제로 한다"| dotnet
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 16건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
1. 먼저 결론 (한 줄로)
- Native AOT는 .NET 앱을 publish 시점에 네이티브 코드로 미리 컴파일해 배포하는 방식입니다.
- 실행 시 JIT를 쓰지 않으므로 시작 시간과 메모리 점유가 나아지기 쉽고, .NET 런타임이 설치되지 않은 환경에도 배포하기 쉽습니다.
- 다만 자유로운 리플렉션, 동적 코드 생성, built-in COM, trimming 미대응 라이브러리와는 궁합이 나빠집니다.
- 즉 빨라지는 마법이라기보다, 시작·배포·실행 환경 사정 때문에 동적인 세계를 조금 버리고 정적인 세계로 치우치는 배포 모델입니다.
Native AOT는 「.NET을 네이티브답게 배포하기 위한 장치」이지, 단순한 컴파일 가속 체크박스가 아닙니다.
flowchart TB
accTitle: 무엇을 얻고 무엇을 버리는가
accDescr: Native AOT는 publish 시점의 사전 컴파일로 시작 시간·메모리 점유·런타임 불필요 배포를 얻는 대신, 자유로운 리플렉션·동적 코드 생성·built-in COM과의 궁합을 버리는 배포 모델이다.
aot["Native AOT"] --> gain["얻는 것"]
aot --> lose["버리는 것"]
gain --> g1["시작과 메모리와 배포의 가벼움"]
lose --> l1["자유로운 리플렉션"]
lose --> l2["동적 코드 생성과 built-in COM"]
그림 2: 빨라지는 마법이 아니라, 동적인 세계를 조금 버리고 정적인 세계로 치우치는 거래다.
2. 먼저 보는 정리 표
2.1. Native AOT 주변의 말
처음에 이 부분을 나눠 두면 뒤가 편합니다.
| 용어 | 무엇을 하는가 | Native AOT와의 관계 |
|---|---|---|
| JIT | 실행 시에 IL에서 네이티브 코드를 만든다 | Native AOT는 여기를 미리 끝낸다 |
| self-contained | 실행에 필요한 .NET 구성 전부도 함께 배포한다 | Native AOT는 이 계통으로 생각한다 |
| single-file | 배포물을 1개 파일로 모은다 | Native AOT의 본질과는 다르지만, 결과의 겉모습은 비슷해지기 쉽다 |
| trimming | 쓰지 않는 코드를 깎는다 | Native AOT에서는 거의 전제가 된다 |
| ReadyToRun | IL을 남긴 채로 JIT의 일을 조금 앞당긴다 | Native AOT와는 비슷해 보이지만 다른 것이다 |
| source generator | 실행 시의 동적 처리를 빌드 시 코드 생성으로 치우친다 | Native AOT와 궁합이 좋다 |
섞이기 쉬운 점은, Native AOT가 기능 하나라기보다 self-contained, trimming, source generation, RID 고정 publish 등과 함께 움직이는 배포 모델이라는 것입니다.
flowchart TB
accTitle: Native AOT와 함께 움직이는 장치
accDescr: Native AOT는 기능 하나가 아니라 self-contained, trimming, source generation, RID 고정 publish와 맞물려 움직이는 배포 모델임을 나타낸다.
aot["Native AOT〔배포 모델〕"] --> s1["self-contained 계통"]
aot --> s2["trimming은 거의 전제"]
aot --> s3["source generator와 궁합이 좋음"]
aot --> s4["RID 고정으로 publish"]
그림 3: Native AOT는 단독 기능이 아니라, 주변 장치와 세트로 움직이는 배포 모델이다.
2.2. JIT / ReadyToRun / Native AOT의 차이
여기도 처음에 한 장으로 보는 편이 빠릅니다.
| 관점 | 평범한 JIT 실행 | ReadyToRun | Native AOT |
|---|---|---|---|
| 실행 시 JIT | 사용한다 | 아직 쓰는 장면이 있다 | 쓰지 않는다 |
| 배포물의 내용 | IL 중심 | IL + 사전 생성 코드 | 네이티브 실행 파일 중심 |
| 시작 | 기준 | 나아지기 쉽다 | 상당히 나아지기 쉽다 |
| 호환성 | 가장 넓다 | 넓다 | 제약이 강하다 |
| 동적 기능 | 쓰기 쉽다 | 대체로 쓰기 쉽다 | 제한이 많다 |
| 맞는 목적 | 평범한 .NET 개발 전반 | 우선 시작을 개선하고 싶다 | 시작·배포·제한 환경을 강하게 가져간다 |
ReadyToRun이 「JIT를 조금 덜어 주는」 방향이라면, Native AOT는 「실행 시 JIT 자체를 전제로 하지 않는」 방향입니다. 같은 AOT라는 말이 붙어 있어도 결이는 꽤 다릅니다.
flowchart TB
accTitle: ReadyToRun과 Native AOT의 방향 차이
accDescr: ReadyToRun은 IL을 남긴 채로 JIT의 일을 조금 앞당겨 덜어 주는 방향이고, Native AOT는 실행 시 JIT 자체를 전제로 하지 않는 방향이며, 같은 AOT라는 말이라도 결이가 꽤 다르다.
q{"JIT를 어떻게 하고 싶은가"}
q -->|"조금 덜어 준다"| r2r["ReadyToRun〔IL은 남는다〕"]
q -->|"전제로 하지 않는다"| aot["Native AOT〔네이티브 중심〕"]
r2r --> soft["호환성은 넓은 채로"]
aot --> hard["시작은 빠르지만 제약은 강하다"]
그림 4: 같은 AOT라도 「JIT를 덜어 준다」와 「JIT를 전제로 하지 않는다」는 다른 것이다.
그다음 「결국 어느 배포 형태를 고를 것인가」를 한 장으로 그리면 이렇게 됩니다.
flowchart TD
S["배포 형태를 정하고 싶다"] --> Q1{"배포 대상에 .NET 런타임을 넣을 수 있는가?"}
Q1 -- "넣을 수 있다" --> Q2{"시작 시간을 더 줄이고 싶은가?"}
Q2 -- "그 정도는 아니다" --> P1["framework-dependent<br/>평범한 게시"]
Q2 -- "줄이고 싶다" --> P2["ReadyToRun<br/>호환성을 유지한 채로 시작 개선"]
Q1 -- "넣고 싶지 않다 / 넣을 수 없다" --> Q3{"reflection·동적 코드 생성·built-in COM에 의존하는가?"}
Q3 -- "의존한다" --> P3["self-contained<br/>필요하면 single-file로 모은다"]
Q3 -- "의존하지 않는다" --> Q4{"대상은 console / worker / 작은 API인가?"}
Q4 -- "예" --> P4["Native AOT"]
Q4 -- "아니요" --> P5["우선 self-contained<br/>동적인 의존을 줄인 뒤 다시 검토"]
그림 5: 배포 형태를 고르는 방법. 런타임을 둘 수 있는지, 동적인 구조를 줄일 수 있는지의 순으로 갈린다.
첫 분기는 런타임을 배포 대상에 둘 수 있는지, 다음 분기는 동적인 구조를 어디까지 줄일 수 있는지입니다. self-contained와 single-file은 「배포하는 방법」의 이야기이고, ReadyToRun과 Native AOT는 「언제 네이티브 코드를 만드는가」의 이야기이므로, 실제로는 조합해서 생각하게 됩니다.
3. Native AOT의 전체 모습 (그림)
Native AOT를 대략 그림으로 그리면 이렇습니다.
flowchart LR
Src["C# / .NET 소스 코드"] --> IL["IL 어셈블리"]
IL -->|일반 실행| JIT["실행 시 JIT"]
JIT --> Run1["앱 실행"]
IL -->|dotnet publish + PublishAot| Analyze["AOT / trim 분석"]
Analyze --> Trim["불필요 코드 축소"]
Trim --> AOT["네이티브 코드 생성"]
AOT --> Run2["RID 고유 실행 파일"]
그림 6: 일반 실행은 실행 시에 JIT하지만, Native AOT는 분석·축소·네이티브 생성을 publish 시점으로 앞당긴다.
평소의 .NET은 먼저 IL을 만들고, 실행 시에 필요한 만큼만 JIT합니다. Native AOT는 그 뒷단의 큰 부분을 publish 시점으로 앞당깁니다.
이때 중요한 것은, publish 시점에 「실행 시에 필요해질 코드를 거의 전부 알고 있어야 한다」는 점입니다. 여기서 허용되는 작성법의 전제가 바뀝니다.
- 실행 시에 타입을 찾는다
- 실행 시에 코드를 만들어 낸다
- 실행 시에 Assembly를 읽는다
- 실행 시에 「어떻게든 되겠지」로 지연 해결한다
이런 작성법은 Native AOT와 갑자기 궁합이 나빠집니다.
flowchart TB
accTitle: publish 시점에 전부 알고 있어야 한다
accDescr: Native AOT는 publish 시점에 실행 시에 필요해질 코드를 거의 전부 알고 있어야 하며, 실행 시에 타입을 찾거나 코드를 만들어 내거나 Assembly를 읽거나 지연 해결하는 작성법과 궁합이 나빠진다.
need["publish 시점에 필요 코드를 확정"] --> ng1["실행 시에 타입을 찾는다"]
need --> ng2["실행 시에 코드를 만들어 낸다"]
need --> ng3["실행 시에 Assembly를 읽는다"]
need --> ng4["지연 해결로 버틴다"]
ng1 -.-> bad["어느 쪽이든 AOT와 궁합이 나쁘다"]
그림 7: 전제가 바뀌는 핵심이다. 「실행 시에 정한다」는 작성법일수록 Native AOT와 충돌한다.
4. Native AOT에서 무엇이 반가운가
4.1. 시작이 가벼워지기 쉽다
Native AOT의 가장 알기 쉬운 효과는 역시 시작입니다.
- CLI 도구
- 단명 프로세스
- 서버리스에 가까운 시작
- 컨테이너의 시작·교체
- 감시 도구나 작은 상주 프로세스
이 장면에서는 JIT 비용이 보이기 쉽고, Native AOT로 그 부분을 앞당길 수 있는 만큼 초반이 가벼워집니다.
메모리 점유도 나아지기 쉬우므로, 같은 대수를 더 넣고 싶은 장면에서도 유리합니다. 특히 같은 프로세스가 대량으로 뜨는 클라우드 쪽에서는 이 차이가 서서히 먹힙니다.
4.2. 런타임 사전 설치를 전제로 하지 않아도 된다
Native AOT로 publish한 앱은 .NET 런타임이 설치되지 않은 환경에서도 돌리기 쉬워집니다.
이건 은근히 큽니다.
- 배포 대상에 「.NET 9 Runtime을 먼저 넣어 주세요」라고 말하고 싶지 않다
- 컨테이너 이미지를 가늘게 하고 싶다
- 작은 도구를 한 개만 두고 돌리고 싶다
- 실행 환경에서 JIT를 허용하고 싶지 않다 / 허용되지 않는다
이런 장면에서는 「런타임을 따로 맞추는 전제」가 없다는 것만으로 이야기가 꽤 조용해집니다.
여기서 말하는 「런타임 불필요」는 배포 대상에 따로 .NET을 넣지 않아도 된다는 뜻입니다. 앱 안에서 필요한 runtime 상당 부분까지 완전히 사라진다는 이야기가 아닙니다.
flowchart TB
accTitle: 런타임 불필요의 올바른 의미
accDescr: Native AOT의 런타임 불필요는 배포 대상에 따로 .NET을 넣지 않아도 된다는 뜻이며, 앱 안에서 필요한 runtime 상당 부분까지 완전히 사라진다는 뜻이 아니다.
word["런타임 불필요라는 말"] --> ok["배포 대상에 .NET을 넣지 않아도 된다"]
word -.-> ngx["runtime 상당이 사라진다는 뜻이 아니다"]
ok --> merit["배포와 시작의 전제가 줄어든다"]
그림 8: 「런타임 불필요」는 배포 대상 이야기다. runtime 상당은 앱 안에 들어 있다.
4.3. 제약이 있는 실행 환경에 맞다
Native AOT는 실행 시 JIT를 쓰지 않으므로, JIT가 허용되지 않는 환경에서도 돌리기 쉽습니다.
여기는 desktop보다 클라우드, 컨테이너, 모바일 쪽 이야기에서 먹히기 쉽습니다. 다만 Windows 개발 맥락에서도 「배포 대상에서 불필요한 전제를 줄이고 싶다」는 뜻에서는 충분히 반가운 점입니다.
5. Native AOT에서 무엇이 까다로워지는가
5.1. 리플렉션과 동적 코드 생성
Native AOT의 핵심 제약은 여기입니다.
Assembly.LoadFile같은 동적 로드System.Reflection.Emit같은 실행 시 코드 생성- 실행 시에 제한 없이 타입을 따라가는 리플렉션
- 실행 시에 generic을 마음대로 조립하는 작성법
이 부분은 publish 시점에 필요 코드를 확정하기 어려워서 AOT warning의 온상이 됩니다.
물론 리플렉션이 한 줄이라도 있으면 바로 탈락일 만큼 단순하지는 않습니다. 다만 「실행 시에 보고 정한다」 쪽 설계일수록 까다로워진다는 경향은 빗나가지 않습니다.
warning 이름으로 자주 나오는 것은 RequiresDynamicCode 계열입니다.
「그 호출은 AOT에서 깨질 수 있다」는 뜻이므로, 대충 suppress하지 않는 편이 안전합니다.
Native AOT를 할 때는 「실행 시의 똑똑함」을 줄이고 「빌드 시의 명시」를 늘리는 쪽으로 치우친다고 보면 이해하기 쉽습니다.
flowchart TB
accTitle: 실행 시의 똑똑함에서 빌드 시의 명시로
accDescr: 동적 로드나 실행 시 코드 생성, 제한 없는 리플렉션은 AOT warning의 온상이 되므로, 실행 시의 똑똑함을 줄이고 빌드 시의 명시를 늘리는 쪽으로 설계를 치우친다.
dynamicway["실행 시의 똑똑함에 기대는 설계"] --> warn["RequiresDynamicCode 계열 warning"]
warn --> shift["빌드 시의 명시를 늘린다"]
shift --> calm["publish 시점에 필요 코드가 확정된다"]
warn -.-> nosup["대충 suppress하지 않는다"]
그림 9: 핵심 제약을 다루는 방향은 한 가지다. 「실행 시에 보고 정한다」를 「빌드 시에 명시한다」로.
5.2. trimming을 전제로 생각할 필요가 있다
Native AOT는 trimming과 깊게 묶여 있습니다. 여기서 놓치기 쉬운 것은 자기 코드뿐 아니라 의존 라이브러리 쪽 작성법도 먹힌다는 점입니다.
주의할 것은 이 부근입니다.
- reflection 기반 직렬화기
- 실행 시 스캔으로 타입을 모으는 DI / 플러그인 구성
- 문자열 이름으로 타입을 찾아 실체화하는 장치
- 동적 proxy나 IL 생성에 기대는 라이브러리
여기서 warning이 나오는데 「publish는 통과했으니 됐다」고 하면 나중에 꽤 씁쓸합니다. Native AOT에서는 warning을 대체로 진지하게 읽는 편이 좋습니다.
flowchart TB
accTitle: 자기 코드만으로는 정해지지 않는 trimming
accDescr: trimming에서는 자기 코드뿐 아니라 의존 라이브러리 쪽 작성법도 먹히며, reflection 기반 직렬화기나 실행 시 스캔 DI, 문자열 이름에서 타입을 찾는 방식, 동적 proxy에 기대는 라이브러리가 주의 대상이 된다.
trim["trimming의 성패"] --> own["자기 코드의 작성법"]
trim --> dep["의존 라이브러리의 작성법"]
dep -.-> ex["reflection 계열이나 실행 시 스캔 계열"]
dep --> care["warning을 진지하게 읽고 확인한다"]
그림 10: 놓치기 쉬운 것은 의존 라이브러리 쪽이다. 「publish가 통과했으니 됐다」는 나중에 씁쓸해진다.
그렇다고 「진지하게 읽어라」만으로는 다음 행동이 나오지 않으므로, 대처 우선순위를 적어 둡니다. 공식 문서도 이 순서로 시도하라고 권합니다.
| 순서 | 대처 | 쓰는 장면 |
|---|---|---|
| 1 | reflection을 그만둔다 | Activator.CreateInstance(Type)를 generic 인자로 바꿀 수 있다, source generator로 치우칠 수 있다 |
| 2 | DynamicallyAccessedMembers를 붙인다 |
reflection은 필요하지만 대상 타입은 컴파일 시점에 알고 있다 |
| 3 | RequiresUnreferencedCode를 붙인다 |
타입 이름을 실행 시 문자열로 정하는 등, 본질적으로 정적 분석이 안 된다 |
| 4 | UnconditionalSuppressMessage로 억제한다 |
위가 전부 안 되고, 안전하다고 확인했을 때의 마지막 수단 |
첫 걸음으로 가장 많은 것은 2번, 「타입은 알고 있는데 경고만 나는」 패턴입니다.
예를 들어 다음 코드는 IL2070이 납니다.
// IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods'
void PrintMethodNames(Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
GetMethods()를 부를 거라면 public 메서드를 남겨 달라고 특성으로 선언하면 사라집니다.
using System.Diagnostics.CodeAnalysis;
void PrintMethodNames(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
// 호출 쪽이 typeof로 넘기고 있으면 요구는 자동으로 충족됩니다
PrintMethodNames(typeof(DateTime));
호출하는 API와 필요한 지정은 대체로 대응합니다. GetMethod / GetMethods면 PublicMethods, GetProperty / GetProperties면 PublicProperties, Activator.CreateInstance면 PublicParameterlessConstructor 또는 PublicConstructors입니다.
DynamicallyAccessedMemberTypes.All은 편하지만 대상 타입의 멤버를 통째로 남겨 크기가 부풀어 오르고, 남긴 멤버가 다른 warning을 끌고 오므로 필요 최소한을 지정하는 것이 원칙입니다.
참고로 특성을 붙여도 warning이 안 사라질 때는 reflection을 쓰는 곳에서 호출 쪽으로 거슬러 올라가, 경로 전부에 붙어 있는지를 확인합니다. 중간에 한 곳이 빠지면 거기서 요구가 끊깁니다.
flowchart TB
accTitle: 특성을 붙여도 warning이 안 사라질 때
accDescr: DynamicallyAccessedMembers는 필요 최소한을 지정하고, warning이 안 사라질 때는 reflection을 쓰는 곳에서 호출 쪽으로 거슬러 올라가 경로 전부에 특성이 붙어 있는지 확인한다. 한 곳이 빠지면 거기서 요구가 끊긴다.
warnleft["특성을 붙였는데 warning이 남는다"] --> back["호출 쪽으로 거슬러 올라간다"]
back --> chain["경로 전부에 붙어 있는지 확인한다"]
chain -.-> cut["한 곳이 빠지면 요구가 끊긴다"]
warnleft -.-> minrule["지정은 필요 최소한이 원칙"]
그림 11: 특성은 점이 아니라 경로에 붙인다. 중간에 한 곳이 빠지면 요구는 거기서 끊긴다.
5.3. 플랫폼마다 게시한다
Native AOT는 RID(Runtime Identifier) 고정으로 publish합니다.
즉 win-x64용으로 만든 것을 그대로 linux-x64에서 돌리는 세계가 아닙니다.
- Windows x64
- Windows Arm64
- Linux x64
- Linux Arm64
- macOS Arm64
처럼 타깃마다 배포물을 만드는 전제입니다.
여기는 평범한 framework-dependent .NET보다 한결 「네이티브 앱 같은」 감각이 됩니다.
5.4. Windows 데스크톱 / COM 맥락은 상당히 신중하게
KomuraSoft 맥락에서는 여기가 특히 중요합니다.
Windows에서는 Native AOT에 built-in COM이 없습니다. 게다가 WPF는 trimming과 궁합이 나쁘고, WinForms는 built-in COM marshalling에 대한 의존이 무겁기 때문에, 적어도 지금은 둘 다 「첫 Native AOT 후보」로는 상당히 신중하게 보는 편이 좋습니다.
여기서 말하는 「지금」의 기준을 적어 둡니다. 이 글의 서술은 .NET 8 / 9 / 10 세대의 공식 문서(2026년 7월 시점)에 근거합니다. WPF와 WinForms에 대해서는 Microsoft의 「Known trimming incompatibilities」에서, WPF는 reflection과 실행 시 코드 검사에 대한 의존이 강해 trimming 뒤에 거의 동작하지 않으므로, WinForms는 built-in COM marshalling에 대한 의존이 무거우므로, 둘 다 .NET SDK 쪽에서 trimming 지원이 비활성화되어 있다고 명시되어 있습니다. 즉 「신중하게 보는 편이 좋다」가 아니라, 현황은 SDK가 막고 있는 상태입니다. 나중에 이 서술이 바뀔 가능성은 있으므로, 읽는 시점에 같은 페이지를 확인하시기 바랍니다.
flowchart TB
accTitle: WPF와 WinForms가 막혀 있는 이유
accDescr: Windows의 Native AOT에는 built-in COM이 없고, WPF는 reflection과 실행 시 코드 검사 의존 때문에 trimming 뒤에 거의 동작하지 않으며, WinForms는 built-in COM marshalling 의존이 무거워서, 둘 다 .NET SDK 쪽에서 trimming 지원이 비활성화되어 있다.
win["Windows의 Native AOT"] --> nocom["built-in COM이 없다"]
wpf["WPF"] --> refl["reflection 의존이 강하다"]
wf["WinForms"] --> commar["COM marshalling 의존이 무겁다"]
refl --> off["SDK 쪽에서 trimming이 비활성화"]
commar --> off
그림 12: 「신중하게」가 아니라, 현황은 SDK가 막고 있다. 데스크톱 본체는 첫 후보로 두지 않는다.
요컨대,
- WPF / WinForms 본체를 갑자기 Native AOT화한다
- COM interop를 평범한 감각으로 그대로 가져온다
이 부근은 publish 시점의 warning과 실행 시 제약이 한꺼번에 늘어서, 대응 비용이 튀기 쉽습니다.
반대로,
- 콘솔
- worker
- 작은 Web API
- 네이티브 연동 중에서도 C 함수 경계에 치우치기 쉬운 부품
이 입구로서 수월합니다.
COM이 필요하면 JIT 상태로 두거나, ComWrappers / source-generated COM을 전제로 설계를 다시 하는 편이 맞는 장면도 있습니다.
6. 최소 절차
그 전에 Native AOT의 publish에는 네이티브 툴체인이 필요합니다.
PublishAot만 붙인 채로 dotnet publish를 치면, 컴파일이 아니라 마지막 네이티브 링크에서 실패합니다. 여기가 첫 관문이므로 먼저 넣어 두시기 바랍니다.
| 환경 | 필요한 것 |
|---|---|
| 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 이후에서 대응) |
요컨대 컴파일러 툴체인과, .NET 런타임이 의존하는 라이브러리의 개발용 패키지입니다. CI에서 돌린다면 dotnet/samples의 Native AOT 샘플에 Linux / Windows 양쪽 Dockerfile이 있으므로, 거기서 전제 설치 절차를 가져오는 편이 빠릅니다.
참고로 Linux에서 빌드한 바이너리는 같거나 그보다 새로운 Linux에서만 동작합니다. Ubuntu 20.04에서 만든 것은 20.04 이후에서는 동작하지만 18.04에서는 동작하지 않습니다. 빌드 환경을 고르는 방식이 곧 배포 가능 범위가 됩니다.
flowchart TB
accTitle: publish 전의 관문과 배포 가능 범위
accDescr: Native AOT의 publish에는 네이티브 툴체인이 필요하며, 없으면 마지막 네이티브 링크에서 실패한다. 또한 Linux에서 빌드한 바이너리는 같거나 그보다 새로운 Linux에서만 동작하며, 빌드 환경을 고르는 방식이 배포 가능 범위가 된다.
tool{"툴체인이 있는가"}
tool -->|"없다"| fail["네이티브 링크에서 실패한다"]
tool -->|"있다"| bin["RID마다 바이너리가 나온다"]
bin -.-> range["Linux는 같거나 새로운 환경에서만 동작한다"]
range -.-> pick["빌드 환경을 고르는 방식이 배포 범위"]
그림 13: 첫 관문은 툴체인이다. Linux에서는 빌드 환경의 오래됨이 배포 가능 범위를 정한다.
6.1. csproj
먼저 프로젝트 파일에 PublishAot를 넣습니다.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
샘플은 net8.0이면 충분합니다. 생각 자체는 .NET 9 / 10에서도 거의 같습니다.
중요한 것은 dotnet publish 명령줄에만 잠깐 붙이는 것보다,
평소부터 프로젝트에 두고 build / publish 시점의 분석을 일상적으로 보는 것입니다.
참고로 <PublishAot>true</PublishAot>를 넣어도 평소의 로컬 실행까지 갑자기 Native AOT가 되는 것은 아닙니다.
일상의 dotnet run이나 일반 실행은 JIT이고, Native AOT 컴파일의 실제 작업은 publish 때입니다.
flowchart TB
accTitle: PublishAot를 넣은 뒤의 일상
accDescr: PublishAot를 프로젝트에 두어도 일상의 dotnet run은 JIT 그대로이고, Native AOT 컴파일의 실제 작업은 publish 때 돈다. 평소부터 프로젝트에 두고 build와 publish 시점의 분석을 일상적으로 보는 것이 중요하다.
put["csproj에 PublishAot를 둔다"] --> daily["일상의 dotnet run은 JIT 그대로"]
put --> pub["publish 때 AOT 컴파일"]
daily -.-> watch["분석과 warning을 일상적으로 본다"]
그림 14: 일상은 JIT, 실제 작업은 publish. 그래서 설정은 잠깐 붙임이 아니라 상시 두고 분석을 계속 본다.
6.2. publish
예를 들어 Windows x64용이라면 이런 형태입니다.
dotnet publish -c Release -r win-x64
Linux x64용이라면 이렇습니다.
dotnet publish -c Release -r linux-x64
출력은 RID 고정입니다. 「한 개로 어디서든 동작하는 .NET DLL」보다 「그 OS / 아키텍처용으로 만든 실행 파일」이라는 관점으로 바뀝니다.
Web API 쪽에서 손대려면 Native AOT 전제 템플릿에서 들어가는 편이 편합니다.
dotnet new webapiaot -o MyFirstAotWebApi
worker라면 이쪽입니다.
dotnet new worker -o WorkerWithAot --aot
6.3. JSON 작성법
Native AOT에서 은근히 자주 부딪히는 것이 JSON입니다.
System.Text.Json은 평소 감각으로 쓰면 reflection 쪽으로 치우치기 쉬우므로, source generation으로 치우치는 편이 무난합니다.
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonSerializable(typeof(AppConfig))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
public sealed class AppConfig
{
public string? Name { get; init; }
public int RetryCount { get; init; }
}
var config = new AppConfig
{
Name = "sample",
RetryCount = 3
};
string json = JsonSerializer.Serialize(config, AppJsonContext.Default.AppConfig);
실무에서는 Native AOT 대응이라기보다 「실행 시에 타입을 찾게 하지 않는」 쪽으로 치우친다고 기억해 두면 빗나가기 어렵습니다.
7. 잘 맞는 경우
Native AOT가 기분 좋게 맞기 쉬운 것은 이런 경우입니다.
- 시작이 주역인 CLI / 도구
- 컨테이너로 대량 배포하는 작은 API
- worker / 백그라운드 서비스
- 서버리스나 단명 프로세스
- 네이티브 앱에 끼워 넣는 작은 .NET 부품
- 실행 환경에 .NET 런타임 사전 설치를 요구하고 싶지 않은 장면
공통점은 경계가 비교적 분명하고, 동적인 구조를 줄이기 쉽다는 것입니다.
8. 맞지 않는 경우
반대로 처음부터 Native AOT를 주력으로 두지 않는 편이 좋은 장면도 분명합니다.
- WPF / WinForms의 기존 대규모 앱 본체
- built-in COM interop 전제의 구성
- 실행 시 plugin 로드가 주역인 앱
- reflection으로 타입을 탐색하는 프레임워크 의존이 강한 구성
System.Reflection.Emit이나 동적 proxy를 당연하게 쓰는 라이브러리- C++/CLI를 끼운 설계
이 부근은 JIT 전제의 평범한 .NET, 또는 ReadyToRun, 또는 설계의 경계 재검토가 더 맞습니다.
9. 빠지기 쉬운 지점
마지막으로 Native AOT 첫걸음에서 밟기 쉬운 점을 정리합니다.
- publish warning을 가볍게 본다
- 앞에서 말한 대로 Native AOT의 warning은 진지하게 읽을 대상입니다.
- build는 통과하는데 publish에서 깨진다
- publish 시점에 의존 라이브러리까지 포함한 분석이 본격적으로 돌아가므로, 여기서 처음 보이는 것이 있습니다.
- ReadyToRun과 Native AOT를 같은 기분으로 다룬다
- 비슷한 말이지만 제약의 세기가 꽤 다릅니다.
- desktop 앱 본체부터 갑자기 시작한다
- 우선 console / worker / 작은 API가 더 무난합니다.
- JSON이나 설정 바인딩을 평소 감각으로 쓴다
- reflection 전제 작성법은 나중에 먹힙니다.
- 플랫폼 독립인 줄 알고 배포한다
- Native AOT의 배포물은 RID 고정입니다.
- 「Native AOT = 무엇이든 빨라진다」고 생각한다
- 주역은 시작, 배포, 실행 환경입니다. 여기를 빼면 기대치가 어긋납니다.
Native AOT에서는 가능 여부를 최종적으로 정하는 것은 dotnet build가 아니라 dotnet publish입니다.
여기를 일찍 돌리기 시작하면 후반에 덜 막힙니다.
flowchart TB
accTitle: 가능 여부를 정하는 것은 publish
accDescr: build는 통과하는데 publish에서 깨지는 일이 있는 것은, publish 시점에 의존 라이브러리까지 포함한 분석이 본격적으로 돌아가기 때문이며, Native AOT의 가능 여부를 최종적으로 정하는 것은 dotnet build가 아니라 dotnet publish이다.
buildok["dotnet build는 통과한다"] --> notyet["아직 가능 여부는 정해지지 않았다"]
pubx["dotnet publish"] --> deep["의존 포함 분석이 본격적으로 돈다"]
deep --> verdict["여기서 처음 가능 여부가 보인다"]
verdict -.-> early["그래서 일찍 publish를 돌린다"]
그림 15: 빠지기 쉬운 지점의 공통점. 「build가 통과했다」로 안심하지 말고, publish를 일찍 돌린다.
10. 정리
Native AOT를 한 줄로 말하면, .NET 앱을 동적인 실행 모델에서, 정적으로 확정하기 쉬운 배포 모델로 치우치는 장치입니다.
봐 둘 포인트는 이 다섯 가지로 끝입니다.
- Native AOT는 publish 시점에 네이티브 코드로 미리 컴파일한다
- 시작, 메모리, 배포 사정에는 잘 먹힌다
- 그 대신 reflection, 동적 코드 생성, built-in COM, trimming 미대응 코드에는 엄하다
- 첫 대상은 desktop 본체보다 console / worker / 작은 API가 더 무난하다
- warning을 지우면서 publish 기반으로 일찍 확인하는 것이 중요하다
Native AOT는 모든 .NET 앱에 붙이는 표준 스위치가 아닙니다. 다만 시작이 중요하고, 배포를 가볍게 하고 싶고, 실행 환경의 전제를 줄이고 싶다는 장면에서는 꽤 강한 무기입니다.
반대로 WPF / WinForms / COM이 진한 세계에서는 아직 평범한 .NET이 더 맞는 장면도 많습니다. 여기를 가릴 수 있으면 Native AOT는 「어려운 신기능」이 아니라, 쓸 곳이 분명한 선택지가 됩니다.
11. 참고 자료
- Native AOT deployment overview - .NET
- Native AOT deployment overview - .NET (日本語)
- Introduction to AOT warnings - .NET
- Fixing trim warnings - .NET
- DynamicallyAccessedMembersAttribute Class - .NET
- Prepare .NET libraries for trimming - .NET
- Known trimming incompatibilities - .NET
- How to use source generation in System.Text.Json - .NET
- ASP.NET Core support for Native AOT
- ReadyToRun deployment overview - .NET
- Building native libraries - .NET
- ComWrappers source generation - .NET
- 관련 기사: C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출
- 관련 기사: C#에서 네이티브 DLL을 쓴다면 C++/CLI 래퍼가 유력한 이유 - P/Invoke와 비교해 정리
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
멀티스레드 실무 베스트 프랙티스 .NET 편 ── 스레드를 늘리기 전에 정해 둘 것
「스레드를 만들었더니 가끔 죽거나 멈춘다」를 막는 설계의 정석을 .NET/C# 대상으로 정리합니다. 스레드를 직접 만들지 않고 Task에 맡기기, 공유 가변 상태 줄이기, 락의 규율, CancellationToken으로 정지 설계하기, UI 스레...
업무 시스템의 코드 설계 ── 상품 코드·고객 코드를 정하는 법과 check digit
상품 코드·고객 코드 등 업무 시스템의 코드 체계를 정하는 실무 가이드. 유의미 코드와 무의미 일련번호 판단표, JAN·Luhn 등의 check digit 산식과 C# 구현, Excel의 선행 0 소실 대책, 자릿수 초과와 이행까지 정리합니다.
Windows 세션 분리를 어떻게 이해할 것인가 ── Session 0·RDP·다중 사용자 동시 실행
Windows 앱 개발자가 혼동하기 쉬운 「세션」 개념을 정리합니다. 서비스가 UI를 띄울 수 없는 Session 0 분리의 이유, RDP 연결 시 세션의 동작, named object의 세션 분리, 공유 PC·RDS 환경에서 자주 나오는 설계 ...
Windows의 프로세스 간 통신을 어떻게 고를까 ── Named Pipe / TCP / gRPC / 공유 메모리 / COM 판단표
Windows 앱끼리의 연동 수단을 어떻게 고를지 정리합니다. Named Pipe, 로컬 TCP, gRPC, 공유 메모리, 파일 연동, COM의 강점과 함정을 판단표로 정리하고, 정석 구성과 Named Pipe 구현 예까지 설명합니다.
Windows 앱 데이터 저장 위치 고르기 ── SQLite / JSON / 레지스트리 / Access 판단표
Windows 데스크톱 앱의 데이터를 어디에, 무엇으로 저장할지. AppData/ProgramData 구분, SQLite·JSON 파일·레지스트리·Access(.accdb) 각각의 강점과 함정을 판단표와 함께 정리하고, 손상 대책과 비트 수 문제...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
32비트 / 64비트 상호 운용
32비트 / 64비트 상호 운용, 네이티브 경계, 관련된 Windows 설계 판단을 정리한 토픽 페이지입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- Native AOT란 무엇인가요?
- .NET 앱을 publish 시점에 네이티브 코드로 미리 컴파일해 배포하는 게시 방식입니다. 실행 시 JIT를 쓰지 않으므로 시작 시간과 메모리 점유가 나아지기 쉽고, .NET 런타임이 설치되지 않은 환경에도 배포하기 쉬워집니다. 그 대신 자유로운 리플렉션, 동적 코드 생성, built-in COM, trimming 미대응 라이브러리와는 궁합이 나빠집니다. 빨라지는 마법이라기보다, 시작·배포·실행 환경 사정 때문에 동적인 세계를 조금 버리고 정적인 세계로 치우치는 배포 모델입니다.
- Native AOT와 ReadyToRun은 무엇이 다른가요?
- ReadyToRun은 IL을 남긴 채로 JIT의 일을 조금 앞당기는 방식이며, 실행 시 JIT를 아직 쓰는 장면이 있습니다. 호환성은 넓고 동적 기능도 대체로 쓰기 쉬운 편입니다. 반면 Native AOT는 실행 시 JIT 자체를 전제로 하지 않고, 배포물은 네이티브 실행 파일 중심이 되며, 시작은 상당히 나아지기 쉬운 대신 제약이 강해집니다. 같은 AOT라는 말이 붙어 있어도 ReadyToRun은 「JIT를 조금 덜어 주는」 방향, Native AOT는 「JIT를 전제로 하지 않는」 방향이라 결이 꽤 다릅니다.
- WPF나 WinForms 앱에서 Native AOT를 쓸 수 있나요?
- 지금은 상당히 신중하게 보는 편이 좋습니다. Windows의 Native AOT에는 built-in COM이 없고, WPF는 trimming과 궁합이 나쁘며, WinForms는 built-in COM marshalling에 대한 의존이 무겁기 때문에, 둘 다 첫 Native AOT 후보로는 맞지 않습니다. COM이 필요하면 JIT 상태로 두거나 ComWrappers / source-generated COM을 전제로 설계를 다시 하는 편이 맞는 장면도 있습니다. 입구로는 콘솔, worker, 작은 Web API가 더 수월합니다.
- Native AOT는 어떤 앱에 잘 맞나요?
- 시작이 주역인 CLI 도구, 컨테이너로 대량 배포하는 작은 API, worker나 백그라운드 서비스, 서버리스나 단명 프로세스, 네이티브 앱에 끼워 넣는 작은 .NET 부품, 실행 환경에 .NET 런타임 사전 설치를 요구하고 싶지 않은 장면에 맞습니다. 공통점은 경계가 비교적 분명하고 동적인 구조를 줄이기 쉽다는 것입니다. 반대로 실행 시 플러그인 로드가 주역인 앱이나, 리플렉션으로 타입을 탐색하는 프레임워크 의존이 강한 구성에는 맞지 않습니다.