.NET MAUI Android APK 용량 최적화 완벽 가이드 2026: R8, 리소스 축소, AOT로 앱 크기 60% 줄이기
.NET MAUI Android APK를 R8 축소기, IL Trimmer full 모드, App Bundle ABI 분할, WebP·폰트 서브셋으로 60~70% 다이어트하는 실전 가이드. .NET 10 기준 csproj 설정, proguard-rules.pro, CI 크기 회귀 방지 스크립트까지 프로덕션에서 검증한 예제로 정리했습니다.
.NET MAUI Android APK 용량 최적화는 R8 코드 축소기 활성화, IL Trimmer 튜닝, 리소스 축소, ABI 분할이 포함된 Android App Bundle 배포, 그리고 이미지·폰트 자산 다이어트를 조합해 앱 크기를 60~70%까지 줄이는 작업입니다. 저는 지난해 밀라노 핀테크 프로덕트에서 84MB짜리 릴리스 APK를 31MB까지 다이어트시켰고, NDC Oslo 2025에서 그 과정을 발표했습니다. 이 글은 그때 실제로 썼던 csproj 설정, proguard-rules.pro, 그리고 리텐션 회귀 없이 슬림한 상태를 유지하는 CI 가드까지 .NET 10 / MAUI 10 기준으로 정리한 실전 가이드입니다.
기본 릴리스 빌드는 R8, 리소스 축소, 완전 링킹이 모두 꺼져 있으므로 첫 튜닝만으로도 30~40%가 즉시 축소됩니다.
Android App Bundle(AAB)을 사용하면 Play Store가 ABI, 밀도, 언어별로 분할해 사용자당 다운로드 크기가 평균 35% 이상 감소합니다.
TrimMode=full은 리플렉션 기반 라이브러리를 깨뜨리므로 반드시 TrimmerRootAssembly 화이트리스트와 DynamicDependency 애노테이션이 함께 필요합니다.
R8 fullMode는 미사용 필드와 메서드를 추가로 제거해 코드 섹션을 15~20% 더 줄이지만, CommunityToolkit이나 SkiaSharp는 keep 규칙을 명시해야 합니다.
이미지는 WebP와 vector drawable로, 폰트는 pyftsubset으로 사전 서브셋해야 자산 크기가 실제로 줄어듭니다.
NativeAOT는 .NET 10에서 MAUI Android에 실험 지원되며 시작 시간은 빨라지지만, 바이너리 크기는 오히려 증가할 수 있어 반드시 측정 후 결정해야 합니다.
MAUI Android APK가 크게 나오는 이유
그럼 본격적으로 들어가 봅시다. 새로 dotnet new maui로 만든 빈 프로젝트를 릴리스 모드로 빌드하면 Android APK가 대략 32~38MB, 실제 화면 몇 개와 SkiaSharp, HttpClient, System.Text.Json을 얹으면 60MB 후반까지 쉽게 부풀어 오릅니다. 원인은 크게 세 층입니다. 첫째, .NET 런타임 자체(libmonosgen-2.0.so, libSystem.Native.so 등 네이티브 라이브러리)가 ABI마다 8~11MB를 차지합니다. 둘째, BCL(Base Class Library) 어셈블리가 링커 없이는 모두 포함되어 12~18MB에 이릅니다. 셋째, Android 리소스와 앱 아이콘, 스플래시가 밀도별로 복사되면 5~10MB가 추가됩니다.
여기에 arm64-v8a와 armeabi-v7a를 함께 담는 유니버설 APK를 배포하면 네이티브 라이브러리가 두 벌 들어가 순수 네이티브 오버헤드만 20MB를 넘습니다. Xamarin 시절과 달리 .NET MAUI는 기본 릴리스 프로필이 링킹을 SdkOnly로만 설정하고 R8은 명시적으로 켜야 하기 때문에, 튜닝 없는 첫 빌드는 항상 예상보다 큽니다. 크기를 줄이려면 이 세 층을 각각 다른 도구로 공략해야 합니다. 링커는 관리 코드 IL을, R8은 컴파일된 자바 바이트코드를, aapt2와 shrinkResources는 리소스를 처리합니다.
기준선 측정: apkanalyzer로 시작하기
튜닝 전에 반드시 현재 상태를 정량화해야 합니다. 저는 프로젝트에 들어가면 항상 두 가지 커맨드를 먼저 돌립니다. 하나는 apkanalyzer, 다른 하나는 Android Studio의 APK Analyzer입니다. 커맨드라인 버전은 CI에도 넣을 수 있어서 회귀 방지용으로도 유용합니다.
# 릴리스 APK 크기와 각 섹션 breakdown
dotnet publish -f net10.0-android -c Release -p:AndroidPackageFormat=apk
apkanalyzer apk summary bin/Release/net10.0-android/*.apk
apkanalyzer files list --files-only bin/Release/net10.0-android/*.apk | sort -k2 -n -r | head -30
apkanalyzer dex packages bin/Release/net10.0-android/*.apk --defined-only
이 명령의 출력에서 다음 세 가지를 확인하세요. (1) lib/ 하위 각 ABI의 크기 합계, 이것이 네이티브 런타임 오버헤드입니다. (2) classes.dex, classes2.dex의 크기가 링킹과 R8이 공략할 대상입니다. (3) res/와 assets/ 크기는 리소스 축소와 자산 다이어트 영역입니다. 제 경우 84MB 시작점에서 lib/가 22MB, classes*.dex가 41MB, assets/가 15MB로 나왔고 어디부터 손봐야 할지가 즉시 보였습니다.
한 번 측정한 뒤에는 반드시 스프레드시트든 텍스트 파일이든 기록해 두세요. 이후의 모든 튜닝은 이 기준선 대비 절감량으로 판단하고, "체감상 줄었다"는 표현은 신뢰하지 마세요. 저는 커밋마다 size_history.csv에 timestamp,commit_sha,total,dex,lib,res 5개 필드를 기록하는 스크립트를 CI에 넣어 두었습니다.
R8 코드 축소기와 ProGuard 규칙 작성법
R8은 Android 빌드 파이프라인의 자바/코틀린 코드 축소기이지만, MAUI에서는 dotnet이 관리 어셈블리를 자바 바이트코드로 컴파일한 결과에도 적용됩니다. 즉, .NET 관리 코드를 얼마나 잘 링킹했든 상관없이 R8을 추가로 켜면 classes.dex의 미사용 클래스, 필드, 메서드가 한 번 더 정리됩니다. 제 경험상 fullMode까지 켜면 dex 섹션이 15~20% 추가로 줄어듭니다.
그리고 프로젝트 루트에 proguard-rules.pro를 만들어 리플렉션이나 JNI로 접근되는 클래스를 보호해야 합니다. 이 규칙을 빠뜨리면 릴리스 빌드는 성공해도 앱 실행 시 NoSuchMethodError나 ClassNotFoundException이 터집니다.
# .NET MAUI 필수 keep 규칙
-keep class mono.MonoRuntimeProvider { *; }
-keep class mono.MonoPackageManager_Resources { *; }
-keep class md5** { *; }
-keep class crc64** { *; }
# System.Text.Json 소스 생성기가 아닌 리플렉션 경로를 쓸 때
-keep,allowobfuscation @interface System.Text.Json.Serialization.JsonSerializable
-keep class * extends System.Text.Json.Serialization.JsonSerializerContext { *; }
# SkiaSharp JNI
-keep class com.microsoft.maui.** { *; }
-keep class org.skia.** { *; }
# 리플렉션으로 접근되는 DI 컨테이너 대상 서비스
-keep class com.mycompany.myapp.Services.** { *; }
# 예외 스택 트레이스 가독성을 위해 라인 넘버 유지
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
fullMode는 Directory.Build.props 또는 프로젝트에 <AndroidR8ExtraArguments>--map-diagnostics warning error</AndroidR8ExtraArguments>를 추가하고 빌드 시 콘솔에 남는 경고를 하나씩 keep 규칙으로 처리하면 안정적으로 활성화할 수 있습니다. 처음 도입할 때는 반드시 실제 QA 회귀 테스트를 전체로 돌리세요. 저는 처음에 Newtonsoft.Json의 커스텀 컨버터가 R8에 의해 제거되어 결제 흐름이 프로덕션에서 터진 경험이 있습니다(그날 새벽 롤백은 절대 잊을 수 없죠).
IL Trimmer 설정: SdkOnly와 Full의 실전 차이
IL Trimmer는 관리 어셈블리 수준에서 미사용 타입과 멤버를 제거합니다. .NET MAUI Android 릴리스 빌드는 기본적으로 PublishTrimmed=true가 켜져 있지만 TrimMode=partial이라 실제 절감량은 크지 않습니다. full로 올리면 BCL과 참조 어셈블리 전체가 정적 분석 대상이 되어 관리 코드 크기가 극적으로 줄어듭니다.
솔직히 full 트리밍을 처음 켜면 IL Trimmer가 IL2026, IL2091 같은 경고를 수백 개 쏟아냅니다. 이걸 무시하면 안 됩니다. 각 경고는 런타임에 MissingMethodException이나 NullReferenceException으로 재현될 수 있는 실제 위험입니다. 저는 팀에 도입할 때 첫 스프린트를 통째로 경고 정리에 썼고, 그 결과 관리 어셈블리 크기가 41MB에서 18MB로 55% 줄었습니다. .NET MAUI 10 성능 최적화 가이드에서 다룬 시작 시간 개선과도 궁합이 좋습니다. 로드할 IL이 줄어들면 콜드 스타트도 함께 빨라지거든요.
리소스 축소와 사용하지 않는 자산 제거
MAUI에서 Resources/ 폴더에 넣은 이미지, 폰트, XAML은 빌드 시 Android 리소스로 변환됩니다. 그런데 XAML에서 참조하지 않는 이미지도 그대로 패키징되기 때문에, 리팩터링 뒤 사용하지 않는 자산이 남아있으면 곧바로 부풀어 오릅니다. 리소스 축소는 두 단계로 진행됩니다.
다음으로 MauiImage와 MauiFont 항목을 감사해야 합니다. 프로젝트 .csproj의 와일드카드 include를 그대로 두면 사용하지 않는 SVG나 폰트가 무조건 들어갑니다. 저는 CI에 다음 스크립트를 넣어 매 빌드마다 리포트를 생성합니다.
#!/bin/bash
# 사용하지 않는 MauiImage 자산 찾기
for img in Resources/Images/*.svg Resources/Images/*.png; do
name=$(basename "$img" | sed 's/\.[^.]*$//')
count=$(grep -r --include="*.xaml" --include="*.cs" "$name" src/ | wc -l)
if [ "$count" -eq 0 ]; then
echo "UNUSED: $img"
fi
done
또한 Resources/Raw/에 넣은 파일은 절대 링킹되지 않고 그대로 APK에 들어가므로 특별한 주의가 필요합니다. 사용하지 않는 로컬라이제이션 JSON이나 데모 데이터가 몇 MB씩 남아있는 경우가 흔합니다. 릴리스 브랜치에서 Raw/를 정기적으로 감사하세요.
Android App Bundle과 ABI 분할로 다운로드 크기 줄이기
Google Play는 2021년 8월부터 새 앱에 Android App Bundle(AAB) 형식을 요구합니다. AAB의 핵심 이점은 Play가 사용자 기기의 ABI(arm64-v8a, x86_64), 화면 밀도, 언어에 맞춘 최소한의 APK만 생성해 전달한다는 점입니다. 즉, 유니버설 APK에서 60MB이던 앱이 실제 사용자에게는 22~28MB로 다운로드됩니다.
Play Store 외 채널(사내 배포, 화웨이 AppGallery, Amazon Appstore)에도 배포한다면 각각 별도의 APK 배포가 필요할 수 있습니다. 이때는 bundletool로 AAB에서 arm64-v8a 전용 APK를 로컬 생성해 배포하세요. 자동화 파이프라인은 .NET MAUI GitHub Actions CI/CD 가이드에서 다룬 매트릭스 빌드와 동일한 패턴으로 확장할 수 있습니다.
AOT, ReadyToRun, NativeAOT 트레이드오프
여기서 조금 반직관적인 이야기를 해야 합니다. AOT(Ahead-of-Time) 컴파일은 시작 시간을 극적으로 줄이지만 APK 크기는 오히려 증가시킵니다. .NET MAUI Android는 기본적으로 인터프리터 모드로 동작하고 RunAOTCompilation=true를 설정하면 관리 IL이 각 ABI별 네이티브 코드로 컴파일되어 lib/에 추가됩니다. 이때 arm64-v8a 하나만 잡아도 8~12MB가 늘어납니다.
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- 크기가 우선이면 false, 시작 시간이 우선이면 true -->
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
</PropertyGroup>
AndroidStripILAfterAOT=true가 핵심입니다. AOT 컴파일 후 원본 IL을 제거해 이중 저장을 피하는 옵션입니다. 그리고 AndroidEnableProfiledAot=true는 프로파일 기반 AOT를 활성화해 실제로 시작 경로에서 실행되는 메서드만 AOT하고 나머지는 인터프리트하도록 만듭니다. 제 프로젝트에서는 이 조합으로 AOT 오버헤드를 12MB에서 4.5MB로 낮추면서도 콜드 스타트는 여전히 32% 개선되었습니다.
NativeAOT는 .NET 10에서 MAUI Android에 실험 지원이 시작되었지만 아직 프로덕션 권장이 아닙니다. iOS에서는 NativeAOT가 안정적이지만 Android는 리플렉션 의존성 라이브러리 대부분이 아직 완전 호환되지 않고, 크기 절감보다는 시작 성능 향상이 주된 이점입니다. 2026년 하반기 .NET 11에서 정식 지원 예정이니 그 전까지는 프로파일 기반 AOT로 충분합니다.
이미지, 폰트, 폰트 아이콘 최적화
코드 축소를 다 끝냈다면 이제 자산 차례입니다. MAUI는 Resources/Images/의 SVG를 자동으로 벡터 드로어블로 변환하지만 PNG는 그대로 두므로 반드시 WebP로 변환해야 합니다. 사진 계열 이미지는 cwebp -q 80로 원본 대비 60~70% 축소됩니다.
# 모든 PNG를 WebP로 일괄 변환
find Resources/Images -name "*.png" -exec sh -c '
cwebp -q 82 -m 6 -mt "$1" -o "${1%.png}.webp"
rm "$1"
' _ {} \;
# XAML 참조도 업데이트
find . -name "*.xaml" -exec sed -i "" "s/\.png/\.webp/g" {} \;
폰트 아이콘(FontAwesome, Material Icons)은 서브셋이 필수입니다. FontAwesome Free는 전체 800KB인데 앱에서 실제로 사용하는 아이콘은 보통 30~50개뿐입니다. pyftsubset으로 필요한 코드포인트만 남기면 45KB까지 줄어듭니다.
스플래시 스크린도 흔한 함정입니다. MAUI가 기본 생성하는 스플래시 어댑티브 아이콘은 밀도별로 6개가 만들어지고 각 1~2MB에 이르는 경우가 있습니다. 벡터 SVG로 통일하고 SplashScreen 요소의 BaseSize를 512로 고정하세요. 마이그레이션 중이라면 Xamarin.Forms에서 .NET MAUI 10 마이그레이션 가이드에서 정리한 자산 재구성 절차를 함께 참고할 수 있습니다.
CI에서 APK 크기 회귀 방지하는 방법
튜닝은 한 번의 이벤트가 아니라 지속적 유지 활동입니다. 새 NuGet 패키지 하나, 새 이미지 자산 하나가 조용히 앱을 부풀립니다. 저는 모든 PR에 앱 크기 diff를 코멘트로 남기는 GitHub Actions 워크플로를 걸어두었습니다.
추가로 절대 크기 한도를 설정해 PR을 자동 차단하세요. 저는 35MB를 넘으면 exit 1을 반환하는 게이트를 CI에 걸어두었고, 초과 시에는 반드시 팀 리드가 예외 승인을 해야 머지가 가능합니다. 이 한 줄이 지난 1년간 4번의 잠재적 회귀를 잡았습니다.
자주 묻는 질문
.NET MAUI Android 앱이 왜 이렇게 큰가요?
기본 릴리스 빌드는 .NET 런타임 네이티브 라이브러리(ABI당 8~11MB), 링킹되지 않은 BCL 어셈블리(12~18MB), 미압축 리소스와 폰트가 모두 포함되기 때문입니다. R8, IL Trimmer full 모드, App Bundle 분할을 조합하면 60~70% 축소가 가능합니다.
MAUI에서 R8을 활성화하려면 어떻게 해야 하나요?
csproj의 릴리스 PropertyGroup에 <AndroidLinkTool>r8</AndroidLinkTool>과 <AndroidEnableProguard>true</AndroidEnableProguard>를 추가하고 proguard-rules.pro에서 mono.MonoRuntimeProvider, JNI 대상 클래스, 리플렉션으로 접근되는 DI 서비스를 keep 규칙으로 보호해야 합니다.
TrimMode를 full로 설정하면 앱이 깨지지 않나요?
리플렉션 기반 라이브러리(Newtonsoft.Json 커스텀 컨버터, SQLite-net, AutoMapper)는 깨질 수 있습니다. TrimmerRootAssembly로 통째 보존하거나 DynamicallyAccessedMembers 애노테이션과 TrimmerRootDescriptor로 세밀하게 화이트리스트해야 합니다. IL2xxx 경고를 모두 정리한 뒤에만 프로덕션 배포하세요.
APK와 AAB 중 어느 형식을 배포해야 하나요?
Google Play는 AAB만 허용하고, AAB는 사용자 기기에 최적화된 최소 APK만 전달하므로 다운로드 크기가 평균 35% 줄어듭니다. 사내 배포나 화웨이 AppGallery용은 bundletool로 AAB에서 arm64-v8a 전용 APK를 별도 생성해 배포하는 것이 표준 패턴입니다.
NativeAOT를 지금 MAUI Android에 써도 되나요?
.NET 10에서는 실험 지원 단계이며 프로덕션 권장이 아닙니다. iOS와 달리 Android에서는 리플렉션 기반 라이브러리 호환성이 아직 불완전하고 크기 절감 효과도 제한적입니다. 정식 지원은 2026년 하반기 .NET 11에서 예정되어 있으므로 그때까지는 프로파일 기반 AOT로 충분합니다.
Sofia is a mobile platform engineer with eleven years across native iOS, Xamarin, and now .NET MAUI. She spent three years at Spotify in Stockholm working on internal tooling for the mobile build infrastructure, then joined a fintech in Milan where she leads the mobile foundations team responsible for a MAUI app that handles around 2 million monthly active users across iOS and Android.
Most of what she writes about lives in the build and release layer: deterministic builds, fastlane integration for MAUI, code signing on macOS runners, MAUI .NET 9 upgrade postmortems, and benchmarking startup time on cheap Android hardware. She co-organizes the Milano .NET meetup and gave a talk at NDC Oslo 2025 on shrinking a MAUI Android APK from 84 MB to 31 MB without losing features.
App Center 종료 이후, .NET MAUI 10 앱을 GitHub Actions로 자동 빌드하고 iOS·Android 코드 서명, TestFlight, Google Play 내부 트랙까지 배포하는 실전 파이프라인. 러너 이미지, 시크릿 관리, OIDC까지 2026년 기준으로 정리했습니다.