면접 질문 목록으로 가기
면접 질문실전 질문꼬리 질문

R8 트리 쉐이킹(Tree Shaking), 최적화, 그리고 풀 모드(Full Mode)

skydovesJaewoong Eum (skydoves)||10분 소요

R8 트리 쉐이킹(Tree Shaking), 최적화, 그리고 풀 모드(Full Mode)

R8은 안드로이드 애플리케이션의 기본 코드 축소기(code shrinker), 최적화기, 그리고 난독화 도구입니다. 빌드 타임에 실행되어 컴파일된 바이트코드를 받아, 사용되지 않는 코드를 제거하고 인라이닝(inlining)과 역가상화(devirtualization) 같은 최적화를 적용하며, 심볼명을 축소하여 더 작고 빠른 APK를 생성합니다. 대부분의 개발자는 ProGuard 규칙을 작성하거나 릴리스 빌드에서 가끔 발생하는 ClassNotFoundException을 해결할 때만 R8을 접하게 되지만, R8이 무엇을 유지하고 무엇을 제거하는지 결정하는 원리를 이해하는 것은 축소 관련 이슈를 디버깅하고 올바른 keep 규칙을 작성하는 데 필수적입니다. 특히 릴리스 빌드에서 갑자기 크래시가 발생했을 때, R8의 동작 원리를 모르면 원인을 찾기가 매우 어렵기 때문에 면접뿐만 아니라 실무에서도 반드시 알아두셔야 할 주제입니다.

이번 면접 질문을 통하여 아래 내용들을 학습하실 수 있습니다.

  • R8이 진입점(entry point)에서 시작하는 도달 가능성 분석(reachability analysis)을 통해 데드 코드를 식별하는 방식을 설명할 수 있습니다.
  • -keep, -keepclassmembers, -keepnames, -keepclasseswithmembers의 차이점을 구분할 수 있습니다.
  • R8이 적용하는 핵심 최적화인 인라이닝(inlining), 역가상화(devirtualization), 클래스 병합(class merging), enum 언박싱(enum unboxing)을 설명할 수 있습니다.
  • R8 호환 모드(compat mode)와 풀 모드(full mode)의 차이점, 그리고 풀 모드가 전제하는 가정을 설명할 수 있습니다.
  • R8이 런타임 동작을 깨뜨리는 일반적인 시나리오와 이를 방지하는 방법을 파악할 수 있습니다.

트리 쉐이킹(Tree Shaking): 진입점으로부터의 도달 가능성 분석

R8은 트리 쉐이킹이라 불리는 과정을 통해 코드를 제거합니다. 먼저 진입점(entry point), 즉 런타임에 안드로이드 프레임워크가 호출하기 때문에 반드시 보존해야 하는 클래스와 메서드의 집합에서 시작하여, 해당 진입점에서 도달 가능한 모든 참조를 추적합니다. 도달할 수 없는 코드는 데드 코드(dead code)로 판단되어 제거됩니다.

진입점은 여러 소스에서 생성됩니다. 안드로이드 빌드 도구는 AndroidManifest.xml에 선언된 컴포넌트, 즉 Activity, Service, BroadcastReceiver, ContentProvider에 대한 keep 규칙을 자동으로 생성합니다. 이러한 컴포넌트는 시스템이 리플렉션(reflection)을 통해 이름으로 인스턴스화하기 때문에 반드시 유지해야 합니다. 자동 생성 규칙에는 Application 하위 클래스, XML 인플레이션에 사용되는 View 생성자, 레이아웃 XML에서 참조하는 모든 클래스도 포함됩니다.

추적 과정은 개념적으로 그래프 탐색과 동일합니다. 진입점에서 시작하여 R8은 모든 필드 접근, 메서드 호출, 타입 참조, 어노테이션을 따라가면서 "살아 있는(live)" 클래스와 멤버의 집합을 구축합니다. 이 집합 바깥에 있는 모든 코드는 제거됩니다.

class AppDatabase {
    fun query(): List<User> { ... }
    fun migrate(): Unit { ... }    // 어떤 진입점에서도 호출되지 않는 메서드
}

class UserRepository(private val db: AppDatabase) {
    fun getUsers(): List<User> = db.query()
}

UserRepository.getUsers()가 도달 가능하지만 AppDatabase.migrate()를 호출하는 코드가 어디에도 없다면, R8은 최종 바이트코드에서 migrate()를 완전히 제거합니다. 런타임 관점에서 보면 해당 메서드는 처음부터 존재하지 않았던 것과 마찬가지입니다.

문제는 R8이 추적할 수 없는 경로를 통해 코드에 접근하는 경우에 발생합니다. 리플렉션, Class.forName(), JNI, 직렬화 라이브러리, 서비스 로더 등이 이에 해당합니다. 가령 코드에서 Class.forName("com.example.MyDriver")를 호출하면, 클래스 이름이 컴파일 타임 참조가 아닌 런타임 문자열이기 때문에 R8은 MyDriver를 유지해야 한다는 사실을 알 수 없습니다. keep 규칙이 존재하는 근본적인 이유가 바로 이 때문입니다.

Keep 규칙: 유지 대상 제어

ProGuard/R8 규칙은 어떤 클래스와 멤버를 보존할지 축소기에 알려줍니다. 네 가지 주요 keep 지시어는 보호 대상과 방식에서 차이가 있습니다.

**-keep**은 클래스 자체와 지정된 멤버를 제거와 이름 변경 모두로부터 보호합니다. 가장 넓은 범위의 보호이며, 프레임워크가 리플렉션을 통해 인스턴스화하는 클래스에 사용합니다.

-keep class com.example.MyApplication { <init>(); }

**-keepclassmembers**는 클래스의 멤버를 보존하되, 해당 클래스가 다른 참조를 통해 트리 쉐이킹에서 살아남는 경우에만 적용됩니다. 어떤 코드도 해당 클래스를 참조하지 않으면 클래스 전체(keep 대상 멤버 포함)가 제거됩니다. 이미 코드에서 참조되는 클래스의 멤버 중 리플렉션으로 접근하는 멤버를 보호할 때 유용합니다.

이 면접 질문은 구독자 전용입니다

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

구독하기
면접 질문 목록으로 가기