본문 바로가기

C#

C# NativeAOT로 실행 파일 최적화하기

C# 애플리케이션 시작 속도를 크게 끌어올리고, 배포 크기를 줄이고, JIT 의존성을 제거하고 싶다면 NativeAOT가 적합합니다. .NET 7부터 정식 포함되었고 .NET 8에서 더 성숙해졌습니다. 이 글은 콘솔 앱을 기준으로 NativeAOT로 빠르게 빌드/배포하는 실전 가이입니다.

1. NativeAOT 한눈에 보기

- NativeAOT는 IL을 사전에 네이티브 코드로 컴파일해 단일 실행 파일을 생성합니다. 실행 시 JIT이 필요 없어 시작 시간이 매우 빠릅니다.

- ReadyToRun(R2R)와 비교: R2R은 IL+네이티브 혼합으로 여전히 JIT이 개입할 수 있습니다. NativeAOT는 순수 네이티브 바이너리입니다.

- 장점: 빠른 시작, 배포 단순화(단일 파일), 낮은 메모리 풋프린트, 컨테이너/서버리스에 적합합니다.

- 제약: 동적 코드 생성(Reflection.Emit), 런타임 어셈블리 로드, 일부 리플렉션 패턴은 제한됩니다. 호환성 경고(IL2026 등)를 해소해야 합니다.

2. 가장 빠른 시작

최소 콘솔 앱으로 NativeAOT를 확인합니다.

// Program.cs
using System;

Console.WriteLine("Hello, NativeAOT!");

기본적으로는 CLI에서 게시 시점에 NativeAOT를 켜는 것이 가장 간단합니다.

// Windows x64
// 프로젝트 폴더에서 실행
// Release 모드로 NativeAOT 게시
// 결과: bin/Release/net8.0/win-x64/publish 폴더의 단일 exe

dotnet publish -r win-x64 -c Release -p:PublishAot=true -p:IlcOptimizationPreference=Size -p:InvariantGlobalization=true

// Linux x64
dotnet publish -r linux-x64 -c Release -p:PublishAot=true

// macOS Apple Silicon
dotnet publish -r osx-arm64 -c Release -p:PublishAot=true

결과물은 플랫폼별로 빌드되므로 대상 OS에 맞는 Runtime Identifier(RID)를 지정해야 합니다.

3. 프로젝트 파일 설정(선택)

자주 사용하는 옵션을 .csproj에 고정하면 편합니다.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>            <!-- 기본 NativeAOT 게시 -->
    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
    <SelfContained>true</SelfContained>        <!-- 런타임 포함 -->
    <PublishSingleFile>true</PublishSingleFile> <!-- 단일 파일 -->
    <InvariantGlobalization>true</InvariantGlobalization> <!-- 크기 절감 -->
    <IlcOptimizationPreference>Size</IlcOptimizationPreference> <!-- Size 또는 Speed -->
  </PropertyGroup>
</Project>

참고: JIT 기반의 빠른 시작만 원하면 NativeAOT 대신 R2R을 고려할 수 있습니다.

// R2R 대안 (JIT 존재)
dotnet publish -r win-x64 -c Release -p:PublishReadyToRun=true

4. 크기/성능 스위치

- IlcOptimizationPreference: Size(크기 최소화) 또는 Speed(최대 성능)로 최적화 방향을 지정합니다.

- InvariantGlobalization: true로 설정하면 글로벌라이제이션 데이터 포함을 줄여 크기를 줄입니다(다국어 정렬/서식 필요 시 false 권장).

- PublishSingleFile/SelfContained: 단일 파일 배포 및 런타임 포함을 제어합니다. AOT 게시 시 기본적으로 단일 네이티브 바이너리를 생성하지만 명시적으로 설정해 일관성을 유지합니다.

5. 리플렉션/동적 코드 다루기

트리밍과 AOT는 사용되지 않는 코드 제거를 aggressive하게 수행합니다. 리플렉션으로 생성자/메서드를 찾는 경우 보존을 명시해야 합니다. 다음은 생성자를 보존하는 예시입니다.

using System;
using System.Diagnostics.CodeAnalysis;

class MyService
{
    public MyService() { }
}

class Program
{
    static void Main()
    {
        var obj = CreateService(typeof(MyService));
        Console.WriteLine(obj.GetType().Name);
    }

    static object CreateService([
        DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)
    ] Type t)
    {
        return Activator.CreateInstance(t)!;
    }
}

- 자주 쓰는 동적 시나리오: JSON 직렬화는 Source Generator(System.Text.Json 소스 생성)를 사용하면 AOT 친화적으로 해결됩니다.

- 제한되는 기능: Reflection.Emit, 런타임 코드 생성 프록시, 런타임 어셈블리 컴파일/로드는 동작하지 않거나 제한됩니다.

6. 호환성 점검과 문제 해결

- 경고 해결: dotnet publish 시 IL2026/IL3050 등 트리밍 경고를 꼼꼼히 확인하고, 필요한 멤버 보존 특성(DynamicallyAccessedMembers)이나 소스 생성 기반 API로 전환합니다.

- 네이티브 종속성: P/Invoke를 사용한다면 대상 플랫폼의 네이티브 라이브러리가 배포 환경에서 로드 가능한지 확인하세요.

- 플랫폼 빌드: 각 OS/RID마다 별도로 게시해야 합니다. 예) win-x64, linux-x64, linux-musl-x64(Alpine), osx-arm64 등.

- 디버깅: AOT 바이너리는 심볼 스트리핑 시 스택 추적이 제한될 수 있습니다. 개발 중에는 StripSymbols를 끄고, Release 배포에만 사용하세요.

7. 언제 NativeAOT를 쓰면 좋은가

- 서버리스/Function, CLI 도구, 데스크톱 유틸리티처럼 시작 시간과 배포 단순성이 중요한 경우 최적입니다.

- 리플렉션/동적 생성이 많은 대형 프레임워크 앱은 마이그레이션 비용이 있을 수 있습니다. 이 경우 R2R 또는 부분적인 소스 생성 도입으로 점진적으로 전환하세요.

NativeAOT는 .NET 앱의 시작 속도와 배포 단순화를 실질적으로 개선합니다. 위 설정을 바탕으로 작은 콘솔 도구부터 단계적으로 적용해 경고를 해소하고, 대상 플랫폼별 성능과 크기를 비교해 보시기 바랍니다.