Gitsunmin

TIL

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 켜짐
dex5개 · 50.1MB3개 · 16.6MB (−67%)
매핑 파일없음AAB BUNDLE-METADATA/에 포함 (58MB)

AAB 전체 크기는 77MB → 72MB로 덜 줄어든다. 매핑 파일이 AAB 안에 들어가기 때문인데, 매핑은 기기에 설치되지 않는다. 사용자가 받는 크기는 dex 감소분만큼 줄어든다.


keep 규칙 — R8의 핵심이자 함정

R8은 코드에 정적으로 적힌 호출만 추적한다. 아래 경우는 “안 쓰인다”고 오판한다.

경우예증상 (release에서만)
리플렉션Class.forName("com.x.Foo")ClassNotFoundException
JNIC++(Hermes, Reanimated)이 이름으로 Java 메서드를 찾음NoSuchMethodError
직렬화Gson이 필드 이름으로 JSON 매핑필드 값이 null
문자열 속 클래스명Manifest, 레이아웃 XMLaapt가 자동으로 규칙 생성

규칙 문법

# 클래스와 모든 멤버를 보존
-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 등도 각자 싣고 온다
  • 그래서 규칙 없이 시작 → 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이 대응 개념이며 기본으로 켜져 있다

트러블슈팅 순서

  1. 증상: release에서만 크래시, ClassNotFoundException / NoSuchMethodError
    • dev(debug) 빌드엔 R8이 안 걸리므로 release로만 재현된다
  2. adb logcat 으로 스택 확보 → 난독화된 이름(f9.a.e)
  3. retrace mapping.txt stack.txt 로 원래 이름 복원
  4. usage.txt 에서 그 클래스가 지워졌는지 확인
  5. -keep 을 최소 범위로 추가 후 재빌드

R8 산출물 읽기

app/build/outputs/mapping/release/

파일내용대응
mapping.txt이름 대응표③ 난독화
usage.txt지워진 클래스·멤버① tree shaking
seeds.txtkeep 규칙으로 남은 진입점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에만 걸린다 → 내부 테스트 트랙에서 실기기 검증 후 배포

참고