.NET의 Native AOT란 무엇인가 - JIT, ReadyToRun, trimming과의 차이를 먼저 정리

· · C#, .NET, Native AOT, 배포, 설계

전에 C#을 Native AOT로 네이티브 DLL로 만드는 방법 - UnmanagedCallersOnly로 C/C++에서 호출을 썼는데, 사실 순서가 조금 뒤바뀌었습니다.
원래는 그 앞에 「애초에 Native AOT란 무엇인가」를 두는 편이 자연스러웠습니다.

Native AOT 이야기는 처음에 이런 부분들이 섞이기 쉽습니다.

  • JIT를 없애는 이야기인가
  • self-contained나 single-file과 무엇이 다른가
  • ReadyToRun과 같은 계통인가
  • trimming warning이 대량으로 나오는 것은 무엇이 일어나고 있는 것인가
  • WPF나 WinForms에서도 그대로 올릴 수 있는가

이런 것들이 섞인 채로 있으면 Native AOT가 「그냥 빨라지는 마법」으로 보이거나, 반대로 「제약 투성이라서 건드려서는 안 될 것」으로 보이기도 합니다.
어느 쪽도 조금 거칩니다.

Native AOT는 한마디로 .NET 앱을 publish 시점에 꽤 정적으로 굳히는 배포 모델입니다.
그 대신 기동이나 배포 사정에는 잘 듣습니다.
반대로 실행 시점에 이것저것 동적으로 해결하고 싶은 코드와는 궁합이 떨어집니다.

이 글에서는 그 부분을 실무 쪽으로 정리합니다.

1. Native AOT는 무엇을 하고 있는가

평소의 .NET은 먼저 IL을 만들고, 실행 시점에 필요한 곳을 JIT합니다.
Native AOT는 그 「실행 시점에 네이티브 코드를 만드는」 부분을 publish 시점에 상당히 앞당깁니다.

그래서 겉보기로는 「.NET을 네이티브 앱답게 배포한다」 방향입니다.

여기서 중요한 것은 publish 시점에 필요한 코드가 꽤 보였으면 좋겠다는 점입니다.

즉,

  • 실행 시점에 타입을 찾는다
  • 실행 시점에 코드를 만들어 낸다
  • 실행 시점에 어셈블리를 읽어 판단한다

같은 작성 방식과는 아무래도 궁합이 나빠집니다.

Native AOT를 「빨라지는 기능」으로만 보면 여기서 어긋납니다.
본질은 오히려 동적인 세계를 조금 버리고, 정적으로 굳히기 쉬운 세계로 치우치는 것입니다.

2. ReadyToRun과 무엇이 다른가

여기를 먼저 나누어 두면 꽤 편해집니다.

관점 일반 JIT ReadyToRun Native AOT
실행 시 JIT 사용 아직 사용 사용하지 않음
배포물 IL 중심 IL + 사전 생성 코드 네이티브 실행 파일 중심
기동 기준 개선하기 쉬움 더 개선하기 쉬움
호환성 넓음 비교적 넓음 제약이 강함

ReadyToRun은 JIT의 일을 조금 앞당기는 방향입니다.
한편 Native AOT는 실행 시 JIT를 전제로 하지 않는 방향입니다.

비슷한 말이지만 실제 감촉은 꽤 다릅니다.
ReadyToRun은 「우선 기동을 조금 편하게 하고 싶다」.
Native AOT는 「기동, 배포, 실행 환경의 사정을 위해 설계도 조금 정적 쪽으로 만든다」입니다.

3. Native AOT로 무엇이 좋은가

가장 알기 쉬운 것은 역시 기동입니다.

CLI 도구나 단명 프로세스, 컨테이너의 작은 API, 상주하지만 기능이 좁혀진 도구에서는 JIT의 무거움이 잘 보입니다.
Native AOT는 그곳을 먼저 처리하므로 초동이 가벼워지기 쉽습니다.

또 하나는 배포 이야기입니다.
「배포 대상에 .NET Runtime을 먼저 설치해 주세요」라고 말하고 싶지 않은 장면에서는 상당히 마음이 편해집니다.

  • 작은 도구를 1개만 배포하고 싶다
  • 컨테이너 이미지를 가능한 한 가볍게 하고 싶다
  • 실행 환경에 JIT를 두고 싶지 않다

이런 때 Native AOT는 쓸 곳이 분명합니다.

그래서 Native AOT가 꽂히는 장면은 「모든 앱」이 아니라 기동, 배포, 실행 환경의 전제를 줄이고 싶은 앱입니다.

4. 반대로 무엇이 힘들어지는가

여기는 꽤 분명합니다.
먼저 리플렉션이나 동적 코드 생성입니다.

  • Assembly.LoadFile
  • System.Reflection.Emit
  • 실행 시점에 타입을 열거하여 찾는 구성
  • 문자열 이름으로 타입을 조회해 실체화하는 구조

이쪽은 publish 시점에 필요 코드를 확정하기 어려우므로 AOT warning의 온상이 됩니다.

Native AOT에서 warning이 잔뜩 나올 때 「시끄럽네」가 아니라 「그 설계는 publish 시점에 보이기 어렵구나」로 읽는 편이 본질에 가깝습니다.
특히 RequiresDynamicCode 계열은 대체로 진지하게 보는 편이 좋습니다.

다음으로 trimming입니다.
Native AOT는 trimming과 상당히 깊게 연결됩니다.
여기서 까다로운 것은 자신의 코드뿐만 아니라 의존 라이브러리의 작성 방식까지 영향을 준다는 점입니다.

예를 들어,

  • 리플렉션 기반의 시리얼라이저
  • 실행 시 스캔을 전제로 하는 DI
  • 동적 proxy를 쓰는 라이브러리

이쪽은 번거로워지기 쉽습니다.

즉 Native AOT를 할 때는 「실행 시점에 똑똑하게 어떻게든 한다」보다 빌드 시점에 알 수 있는 형태로 치우치는 편이 다루기 쉽습니다.

5. Windows 데스크톱에서는 조금 신중하게 보는 편이 좋다

Windows에서는 Native AOT에 built-in COM이 없습니다.
게다가 WPF는 trimming과 궁합이 좋지 않고, WinForms도 built-in COM marshalling에의 의존이 무겁습니다.

따라서,

  • WPF / WinForms 본체를 바로 Native AOT화한다
  • COM interop을 평소의 감각 그대로 가져온다

이쪽은 신중하게 보는 편이 좋습니다.

반대로 입구로 고르기 쉬운 것은,

  • 콘솔
  • worker
  • 작은 Web API
  • 경계가 분명한 작은 부품

입니다.

Native AOT는 「.NET이면 뭐든지 그대로 올라간다」는 느낌은 아닙니다.
특히 Windows 데스크톱과 COM이 진한 세계에서는 아직 JIT 상태가 더 자연스러운 경우도 많습니다.

6. 최소 절차는 심플

프로젝트 쪽에서는 우선 csprojPublishAot를 넣습니다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

publish는 예를 들어 Windows x64라면 이렇습니다.

dotnet publish -c Release -r win-x64

Linux x64라면,

dotnet publish -c Release -r linux-x64

이 시점에서 이미 꽤 「네이티브 앱 같은 세계」입니다.
RID 고정이므로, 하나로 어디서든 돌아가는 DLL이라기보다 그 OS / 아키텍처용으로 만드는 실행물이 됩니다.

그리고 의외로 자주 걸리는 것이 JSON입니다.
System.Text.Json을 평소의 느낌으로 리플렉션 쪽으로 쓰기보다 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. 어디부터 시도하는 것이 좋은가

처음 대상은 다음 중 어느 것이 들어오기 쉽습니다.

  • 기동이 중요한 CLI
  • worker
  • 작은 Web API
  • 네이티브 연계 중에서도 책임이 좁은 부품

이쪽이라면 동적인 구조를 줄이기 쉽고 Native AOT의 기쁨도 잘 보입니다.

반대로 WPF / WinForms의 기존 대규모 앱 본체, plugin 로드가 주역인 구성, COM 의존이 강한 구성은 처음 대상으로는 그다지 온화하지 않습니다.

8. 정리

Native AOT는 .NET을 publish 시점에 꽤 정적으로 굳히는 구조입니다.

  • 기동에는 잘 듣는다
  • 배포도 쉬워진다
  • 그 대신 동적인 구조에는 엄격해진다

이 3가지를 처음에 잡아 두면 시야가 꽤 좋아집니다.

Native AOT는 「모든 .NET 앱에 붙이는 표준 스위치」가 아닙니다.
하지만 기동이 중요, 배포를 가볍게 하고 싶다, 실행 환경의 전제를 줄이고 싶다는 장면에서는 꽤 강한 선택지입니다.

반대로 Windows 데스크톱이나 COM이 진한 세계에서는 아직 평범한 .NET 쪽이 자연스러운 경우도 많습니다.
여기를 구별할 수 있게 되면 Native AOT는 「어려운 신기능」이 아니라 쓸 곳이 분명한 도구로 보이게 됩니다.

9. 참고 자료

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

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

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

저자 프로필

기사 저자의 프로필 페이지입니다.

Go Komura

합동회사 코무라소프트 대표

Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.

블로그 목록으로 돌아가기