Android R8 — 코드 축소·최적화·난독화
Play Console에서 「DEX 코드 최적화가 기준점 미만입니다」(난독화 1%) 경고를 받았다. 원인은 릴리스 빌드에서 R8이 꺼져 있던 것. 켜고 나서 알게 된 R8의 동작을 정리한다.
R8이란?
Java/Kotlin 바이트코드(.class)를 Android가 실행하는 DEX로 변환하면서,
동시에 축소(shrink) · 최적화(optimize) · 난독화(obfuscate) 를 수행하는 Google의 컴파일러다.
- D8:
.class→.dex변환만 하는 dexer - R8: D8 + 축소기. AGP 3.4(2019)부터 ProGuard를 대체한 기본 도구
- 예전엔
ProGuard(축소) → D8(변환)2단계였지만 R8은 한 번에 처리한다 - 그래서 규칙 파일 이름·문법은 아직 ProGuard 것을 쓴다 (
proguard-rules.pro,-keep)
flowchart LR
SRC[Kotlin / Java] -->|kotlinc / javac| CLS[.class]
CLS -->|minifyEnabled false| D8[D8: 변환만]
CLS -->|minifyEnabled true| R8[R8: 축소 · 최적화 · 난독화 + 변환]
D8 --> DEX1[큰 .dex]
R8 --> DEX2[작은 .dex + mapping.txt]켜는 법 (app/build.gradle):
android {
buildTypes {
release {
minifyEnabled true // R8
shrinkResources true // 미사용 리소스 제거 (minify 필수)
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}Expo(CNG)라면 android/를 직접 고치지 말고 expo-build-properties로:
["expo-build-properties", {
android: {
enableMinifyInReleaseBuilds: true,
enableShrinkResourcesInReleaseBuilds: true,
// extraProguardRules: "-keep class com.example.Foo { *; }",
},
}]Expo 템플릿의
build.gradle은android.enableMinifyInReleaseBuilds속성이 없으면 기본값이false다. 아무것도 안 하면 R8은 한 번도 돌지 않는다.
R8이 하는 4가지 일
① Tree Shaking — 안 쓰는 코드 제거
진입점(entry point) 에서 출발해 호출 그래프를 따라가며, 도달 가능한 클래스·메서드·필드만 남기고 나머지는 지운다.
- 진입점 = Manifest의 Activity/Service, keep 규칙에 적힌 클래스 등
- 라이브러리를 통째로 넣어도 실제로 부르는 건 일부 → 가장 큰 절감 효과
flowchart TD
E[진입점: MainActivity] --> A[LoginScreen]
E --> B[Network Client]
B --> C[JSON Parser]
X[Library.unusedFeature] -.-> Y[Library.helper]
style X stroke-dasharray: 5 5,color:#999
style Y stroke-dasharray: 5 5,color:#999점선은 어디서도 도달하지 않으므로 제거된다.
② 최적화
- 인라이닝: 짧은 메서드를 호출 자리에 펼침
- 클래스 병합: 구현체가 하나뿐인 인터페이스 등을 합침
- 상수 전파, 죽은 분기 제거, 가상 호출 → 직접 호출(devirtualization)
- Kotlin이 넣는 null 체크(
Intrinsics.checkNotNull...) 제거 - AGP 8부터는 더 공격적인 full mode 가 기본
③ 난독화 — 이름 짧게 바꾸기
com.example.translate.NativeTranslateModule -> f9.a
processTextIntent(java.lang.String) -> e- DEX에는 클래스·메서드 이름이 문자열로 저장된다 → 짧게 바꾸면 크기도 준다
- 부수 효과로 역분석이 어려워진다
- Play Console의 “난독화 1%“가 바로 이 비율 (1%는 원래 난독화된 채 배포되는 서드파티 라이브러리 몫)
④ 리소스 축소 (shrinkResources)
엄밀히는 R8이 아니라 AGP의 별도 단계. R8 결과를 보고 코드가 참조하지 않는
res/ 파일(drawable, layout 등)을 지운다. 그래서 minifyEnabled 없이는 켤 수 없다.
실측 (React Native + Expo 앱)
| R8 꺼짐 | R8 켜짐 | |
|---|---|---|
| dex | 5개 · 50.1MB | 3개 · 16.6MB (−67%) |
| 매핑 파일 | 없음 | AAB BUNDLE-METADATA/에 포함 (58MB) |
AAB 전체 크기는 77MB → 72MB로 덜 줄어든다. 매핑 파일이 AAB 안에 들어가기 때문인데, 매핑은 기기에 설치되지 않는다. 사용자가 받는 크기는 dex 감소분만큼 줄어든다.
keep 규칙 — R8의 핵심이자 함정
R8은 코드에 정적으로 적힌 호출만 추적한다. 아래 경우는 “안 쓰인다”고 오판한다.
| 경우 | 예 | 증상 (release에서만) |
|---|---|---|
| 리플렉션 | Class.forName("com.x.Foo") | ClassNotFoundException |
| JNI | C++(Hermes, Reanimated)이 이름으로 Java 메서드를 찾음 | NoSuchMethodError |
| 직렬화 | Gson이 필드 이름으로 JSON 매핑 | 필드 값이 null |
| 문자열 속 클래스명 | Manifest, 레이아웃 XML | aapt가 자동으로 규칙 생성 |
규칙 문법
# 클래스와 모든 멤버를 보존
-keep class com.example.Foo { *; }
# 클래스는 지워도 되지만, 남는다면 이 어노테이션이 붙은 멤버는 보존
-keepclassmembers class * {
@com.facebook.proguard.annotations.DoNotStrip *;
}
# 제거는 허용, 이름만 유지
-keepnames class com.example.model.**
# 선택적 의존성이 없어서 나는 경고 무시
-dontwarn com.optional.**규칙은 여러 곳에서 모여 합쳐진다
flowchart LR
A[aapt_rules.txt<br/>Manifest·XML 자동 생성] --> M[최종 규칙<br/>configuration.txt]
B[proguard-android-optimize.txt<br/>AGP 기본] --> M
C[app/proguard-rules.pro<br/>앱 규칙] --> M
D[라이브러리 consumer rules<br/>AAR에 동봉] --> M
M --> R8[R8]- consumer rules 덕분에 대부분은 규칙 없이도 동작한다
- React Native:
@DoNotStrip보존 규칙 - Expo Modules:
Module하위 클래스 보존 규칙 - Reanimated, Google Sign-In 등도 각자 싣고 온다
- React Native:
- 그래서 규칙 없이 시작 → release에서 깨지는 클래스만 최소 범위로 추가 가 정석
-keep class com.example.** { *; }처럼 넓게 잡으면 R8 효과가 사라진다
매핑 파일 (mapping.txt)
원래 이름 → 바뀐 이름 대응표. 난독화된 크래시 스택을 되돌릴 때(retrace) 쓴다.
# 크래시 스택 (난독화됨)
at f9.a.e(SourceFile:1)
# retrace mapping.txt stack.txt 결과
at com.example.translate.NativeTranslateModule.processTextIntent(NativeTranslateModule.kt:17)- 빌드마다 바뀐다 → 스토어에 올린 빌드의 매핑은 반드시 보관
- AAB로 올리면
BUNDLE-METADATA에 자동 포함 → Play Console은 알아서 읽는다 - Sentry, Crashlytics 같은 외부 크래시 도구에는 별도 업로드가 필요
R8이 건드리지 않는 것
- JS 번들 (
index.android.bundle, Hermes 바이트코드) — RN 앱 로직 대부분은 여기 있다. JS 쪽 축소는 Metro(minifier)의 몫 - 네이티브
.so(C/C++) - iOS — Swift 컴파일러 최적화 + dead-strip이 대응 개념이며 기본으로 켜져 있다
트러블슈팅 순서
- 증상: release에서만 크래시,
ClassNotFoundException/NoSuchMethodError- dev(debug) 빌드엔 R8이 안 걸리므로 release로만 재현된다
adb logcat으로 스택 확보 → 난독화된 이름(f9.a.e)retrace mapping.txt stack.txt로 원래 이름 복원usage.txt에서 그 클래스가 지워졌는지 확인-keep을 최소 범위로 추가 후 재빌드
R8 산출물 읽기
app/build/outputs/mapping/release/
| 파일 | 내용 | 대응 |
|---|---|---|
mapping.txt | 이름 대응표 | ③ 난독화 |
usage.txt | 지워진 클래스·멤버 | ① tree shaking |
seeds.txt | keep 규칙으로 남은 진입점 | keep 규칙 |
configuration.txt | 합쳐진 전체 규칙 + 각 규칙의 출처 파일 | 규칙 병합 |
resources.txt | 리소스 축소 판단 근거 | ④ shrinkResources |
# 내 모듈이 어떻게 바뀌었나
grep "com.example" mapping.txt | head
# 어떤 라이브러리 코드가 지워졌나
grep "com.google" usage.txt | head
# 규칙이 어디서 왔나
grep "^# The proguard configuration file" configuration.txt정리
- R8 = D8(변환) + 축소 · 최적화 · 난독화. ProGuard의 후계자
- 효과의 대부분은 tree shaking, 이름 축소는 크기와 보안을 함께 챙긴다
- 위험은 리플렉션 · JNI · 직렬화 — 정적 분석이 못 보는 호출
- 라이브러리의 consumer rules 덕분에 규칙 없이 시작해 필요할 때만 추가하면 된다
- R8은 release에만 걸린다 → 내부 테스트 트랙에서 실기기 검증 후 배포