아티클 목록으로 가기

R8 Keep 규칙 해석: 안드로이드 컴파일러는 어떻게 코드의 생존 여부를 결정하는가

skydovesJaewoong Eum (skydoves)||21분 소요

R8 Keep 규칙 해석: 안드로이드 컴파일러는 어떻게 코드의 생존 여부를 결정하는가

안드로이드 릴리스 빌드는 모두 R8을 거칩니다. R8은 코드를 축소(shrink)하고, 난독화(obfuscate)하며, 최적화(optimize)한 뒤 사용자에게 전달하는 전체 프로그램 최적화 컴파일러입니다. R8의 의사 결정 핵심에는 keep 규칙이 자리하고 있습니다. keep 규칙은 어떤 클래스와 멤버가 최적화 파이프라인을 통과한 뒤에도 반드시 살아남아야 하는지를 컴파일러에게 알려주는 선언적 명세(declarative specification)입니다. -keep class com.example.MyClass라는 규칙을 작성하면, 전체 클래스 그래프에 대해 패턴을 매칭하고, 매칭된 항목을 워크리스트 기반 도달 가능성 분석기(reachability analyzer)에 공급하여, 최종 APK에서 무엇이 살고 무엇이 죽는지를 결정하는 정교한 규칙 해석 엔진과 상호작용하게 됩니다.

이 글에서는 R8이 내부적으로 keep 규칙을 어떻게 해석하는지 깊이 있게 살펴봅니다. 6가지 keep 옵션이 축소, 난독화, 최적화를 각각 독립적으로 제어하는 의미론적 매트릭스(semantic matrix)를 어떻게 형성하는지, RootSetBuilder가 규칙 패턴을 애플리케이션 내 모든 클래스에 대해 어떻게 매칭하는지, Enqueuer가 매칭된 루트로부터 워크리스트 기반 도달 가능성 분석을 어떻게 수행하는지, Minifier가 살아남은 클래스와 멤버의 이름을 어떻게 변환하는지, 조건부 -if/-keep 쌍이 어노테이션 기반 규칙 활성화를 어떻게 가능하게 하는지, full 모드와 compatibility 모드가 동작 수준에서 어떻게 다른지, 라이브러리가 META-INF 디렉터리를 통해 consumer 규칙을 어떻게 번들링하는지, 그리고 Dagger 및 Hilt 같은 DI 프레임워크가 R8의 트리 쉐이킹(tree shaking)과 어떻게 상호작용하는지를 다룹니다. 이 글은 keep 규칙을 작성하는 가이드가 아니라, keep 규칙을 해석하고 매칭하며 집행하는 컴파일러 내부 메커니즘을 탐구하는 글입니다.

근본적인 문제: 정적 분석과 동적 접근의 충돌

리플렉션(reflection)을 사용하는 간단한 안드로이드 애플리케이션을 생각해 봅시다.

public class PluginLoader {
    public Plugin loadPlugin(String className) throws Exception {
        Class<?> clazz = Class.forName(className);
        return (Plugin) clazz.getDeclaredConstructor().newInstance();
    }
}

R8은 정적 분석(static analysis)을 수행합니다. 모든 메서드의 모든 명령어(instruction)를 읽고, 무엇이 무엇을 참조하는지 그래프를 구축합니다. 하지만 Class.forName(className)의 문자열 인자는 런타임에서만 알 수 있는 값입니다. R8은 이 사용 패턴을 정적으로 파악할 수 없기 때문에, className이 가리키는 대상 클래스는 아무 데서도 참조되지 않는 것처럼 보입니다. 별도의 조치가 없으면 R8은 해당 클래스를 제거합니다.

가장 단순한 해결책은 모든 것을 유지하는 방법입니다.

-keep class ** { *; }

하지만 이렇게 하면 R8을 사용하는 의미 자체가 사라집니다. 축소도, 난독화도, 용량 감소도 이루어지지 않기 때문입니다. 진정한 과제는 외과적 정밀함에 있습니다. 동적 코드가 필요로 하는 것만 정확히 유지하고, 그 외에는 아무것도 남기지 않는 것입니다.

keep 규칙은 "이 클래스들과 멤버들이 진입점(entry point)이다. 여기에서부터 추적하고, 나머지는 모두 제거하라"라고 R8에게 알려주는 선언적 명세 언어(declarative specification language)를 제공하여 이 문제를 해결합니다. 즉, keep 규칙 시스템은 동적 접근 패턴에 대한 개발자의 지식과 R8의 정적 분석 엔진 사이를 연결하는 인터페이스인 셈입니다.

R8 파이프라인: keep 규칙이 위치하는 곳

keep 규칙을 상세히 살펴보기 전에, 빌드 파이프라인에서 R8이 어떤 위치에 있는지 이해하면 전체적인 맥락을 파악하는 데 도움이 됩니다.

별도의 단계로 동작하며 최적화된 자바 바이트코드를 생성한 뒤 이를 다시 DEX로 변환했던 ProGuard와 달리, R8은 자바 바이트코드를 읽어 최적화된 DEX를 바로 출력하는 **통합 단계(unified step)**입니다.

ProGuard (레거시):  .java → javac → .class → ProGuard → 최적화된 .class → dx → .dex
R8 (현재):          .java → javac/kotlinc → .class → R8 → 최적화된 .dex

내부적으로 R8은 세 가지 코드 표현(code representation)을 유지합니다. **CfCode**는 입력 .class 파일에서 가져온 JVM 클래스파일 바이트코드를 나타냅니다. **DexCode**는 출력용 Dalvik DEX 바이트코드를 나타냅니다. **IRCode**는 R8의 고수준 중간 표현(intermediate representation)으로, CfCodeDexCode 모두 최적화를 위해 이 레지스터 기반 SSA(Static Single Assignment) 형태로 변환(lift)됩니다.

R8.java의 마스터 파이프라인은 다음 단계들을 순서대로 실행합니다.

1. 입력 읽기             (ApplicationReader)
2. 타입 계층 구조 구축    (AppInfoWithSubtyping)
3. 루트 셋 구축           (RootSetBuilder)           ← keep 규칙이 매칭되는 단계
4. 큐잉/추적              (Enqueuer)                 ← 도달 가능성 분석
5. 트리 가지치기           (TreePruner)               ← 도달 불가능한 코드 제거
6. 어노테이션 제거         (AnnotationRemover)
7. 멤버 재바인딩           (MemberRebindingAnalysis)
8. 클래스 병합             (SimpleClassMerger)
9. IR 변환               (IRConverter.optimize())    ← SSA 기반 최적화
10. 두 번째 Enqueuer 패스 (Enqueuer)                 ← 최적화 후 재추적
11. 이름 축소(난독화)      (Minifier.run())           ← 이름 난독화
12. 출력 기록             (ApplicationWriter.write())

keep 규칙은 3단계에서 파이프라인에 진입하며, RootSetBuilder가 전체 클래스 그래프에 대해 규칙을 매칭합니다. 매칭된 항목은 4단계에서 Enqueuer의 도달 가능성 분석에 "루트(root)"로 투입됩니다.

keep 규칙 분류 체계: 6가지 옵션, 3개의 축

keep 규칙은 R8 동작의 세 가지 독립적인 축을 제어합니다. 축소(shrinking), 즉 도달 불가능한 코드를 제거하는 것, 난독화(obfuscation), 즉 식별자 이름을 변경하는 것, 그리고 최적화(optimization), 즉 코드를 재작성하는 것입니다. 6가지 keep 옵션은 2x3 매트릭스를 형성합니다.

6가지 keep 옵션

-keep: 매칭된 클래스와 지정된 멤버가 축소, 난독화, 최적화되는 것을 모두 방지합니다. 가장 광범위한 보호 수준입니다.

-keep class com.example.MyClass {
    public void myMethod();
}

MyClassmyMethod() 모두 세 가지 축 전체에서 완전히 보호됩니다.

-keepclassmembers: 지정된 멤버만 보호하고, 클래스 자체는 보호하지 않습니다. 클래스가 도달 불가능하면 멤버와 함께 제거됩니다.

-keepclassmembers class com.example.MyClass {
    public void myMethod();
}

MyClass가 다른 참조를 통해 축소 과정에서 살아남은 경우에만 myMethod()가 보호됩니다. MyClass 자체가 도달 불가능하면 모든 것이 제거됩니다.

-keepclasseswithmembers: 클래스와 지정된 멤버를 보호하되, 지정된 멤버가 실제로 해당 클래스에 모두 존재하는 경우에만 적용됩니다.

-keepclasseswithmembers class * {
    native <methods>;
}

네이티브 메서드가 있는 클래스만 매칭됩니다. 네이티브 메서드가 없는 클래스에는 영향을 주지 않습니다.

-keepnames: -keep,allowshrinking의 축약형입니다. 멤버가 도달 불가능하면 제거될 수 있지만, 살아남은 경우 이름이 보존됩니다.

-keepnames class com.example.MyClass

-keepclassmembernames: -keepclassmembers,allowshrinking의 축약형입니다. 멤버가 축소 과정에서 살아남은 경우에만 이름이 보존됩니다.

-keepclasseswithmembernames: -keepclasseswithmembers,allowshrinking의 축약형입니다. 조건부 이름 보존에 해당합니다.

효과 매트릭스를 통해 각 옵션의 차이를 정확히 파악할 수 있습니다.

옵션클래스 축소클래스 난독화멤버 축소멤버 난독화
-keep불가불가불가불가
-keepclassmembers가능가능불가불가
-keepclasseswithmembers불가불가불가불가
-keepnames가능불가가능불가
-keepclassmembernames가능가능가능불가
-keepclasseswithmembernames가능불가가능불가

수정자(Modifier): 세밀한 제어

수정자를 사용하면 개별 축을 선택적으로 해제하여 keep 동작을 더욱 세밀하게 조정할 수 있습니다.

-keep,allowshrinking,allowobfuscation class com.example.MyClass

위 규칙의 의미는 다음과 같습니다. "이 클래스를 최적화하지 마라. 단, 축소와 난독화는 허용한다."

수정자효과
allowshrinking매칭된 항목이 도달 불가능하면 제거될 수 있음
allowoptimization매칭된 항목의 코드가 재작성될 수 있음
allowobfuscation매칭된 항목의 이름이 변경될 수 있음
includedescriptorclasses메서드/필드 타입 시그니처에 등장하는 모든 클래스도 함께 유지

includedescriptorclasses 수정자는 JNI 환경에서 매우 중요합니다. 네이티브 C++ 코드가 자바 메서드를 호출할 때 매개변수 타입을 이름으로 참조하기 때문입니다. 이 수정자가 없으면 R8이 메서드 자체는 유지하면서 매개변수 타입의 이름만 변경할 수 있고, 이 경우 네이티브 경계에서 NoClassDefFoundError가 발생합니다.

와일드카드 시스템: 클래스 그래프를 위한 패턴 매칭

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기