배포와 인프라6 분 읽기

R8이 route 필드를 반복해서 지웠다

Android release 빌드가 세 버전에 걸쳐 같은 NPE로 죽었습니다. keep rule을 계속 넓혔고, 한 번은 잠잠해졌다가 다시 회귀했습니다. keep rule이 안 먹은 게 아니라, keep이 걸린 위치와 R8이 자르는 위치가 달랐습니다. 정답은 keep이 아니라 패턴을 바꾸는 것이었습니다.

#android#r8#kotlin#gotchas
개념 도식: sealed class + data object 구조에서 keep rule 화살표는 child에 꽂히는데 R8의 가위는 base class에서 route를 자른다. enum으로 바꾸면 R8이 필드를 자동 보존해 keep rule 자체가 불필요.
keep rule과 R8의 가위가 서로 다른 클래스를 향하고 있었다.

Android 앱의 release 빌드가 같은 NPE로 반복해서 죽었습니다.

NullPointerException: Attempt to invoke virtual method
'java.lang.String ...BottomNavItem.getRoute()' on a null object reference

v0.3.0부터 v0.5.2까지 계속이었습니다. 첫 화면 이동이나 권한 허용 후 recomposition에서 터집니다. debug 빌드는 멀쩡하고 minify를 끈 release도 멀쩡합니다. R8이 범인인 건 초반에 알았습니다.

그래서 keep rule을 넣었습니다. 안 되면 더 넓게 넣었습니다. 결국 이렇게까지 갔습니다.

-keep class ...BottomNavItem { *; }
-keep class ...BottomNavItem$* { *; }
-keepclassmembers class ...BottomNavItem { *; }
-keepclassmembers class ...BottomNavItem$* { *; }
-keepclassmembers class com.ootssu.plotta.** { *** route; }

v0.4.0에서 이걸 다 넣고 minify를 재활성했더니 잠잠했습니다. v0.5.x에서 회귀했습니다.

keep이 안 먹은 게 아니라, 위치가 달랐다

패턴은 이거였습니다.

sealed class BottomNavItem(val route: String, ...)
data object Map : BottomNavItem("map", ...)
data object Leaderboard : BottomNavItem("leaderboard", ...)

R8은 super-constructor argument를 inlining합니다. data object는 인스턴스가 하나뿐이고 상태가 고정이라, R8이 "constructor 파라미터를 읽은 뒤로는 다시 안 쓴다"고 판단해서 route 필드 자체를 제거합니다. 그리고 이 작업을 child의 synthetic class가 아니라 base class 위에서 합니다. child 클래스에 아무리 keep을 걸어도 소용이 없었던 이유가 그것입니다.

fullMode 최적화나 stale build cache와 겹치면 route가 null로 남습니다. 그래서 빌드마다 재현되기도, 안 되기도 했고, "고쳤다"고 판단한 v0.4.0이 사실은 우연이었습니다.

결론은 keep rule을 더 정교하게 쓰는 게 아니었습니다. 패턴을 바꾸는 것이었습니다.

enum class BottomNavItem(val route: String, val labelRes: Int, val icon: ImageVector) {
    MAP(...), LEADERBOARD(...), PROFILE(...);
    companion object { val all = entries.toList() }
}

R8은 enum의 필드를 자동으로 보존합니다. keep rule이 아예 필요 없습니다. v0.5.3에서 전환하고 종결됐습니다(commit 382b90cb).

Compose Navigation에서 "route String을 가진 sealed class 데이터 객체 컬렉션"은 굉장히 흔한 패턴이고 공식 예제에도 나옵니다. 그런데 R8 fullMode와는 궁합이 나쁩니다. 처음부터 enum으로 쓰면 이 문제 자체가 없습니다.

등장 앱: Plotta (Android) · iOS.

Plotta 지도 화면

정직하게

  • 세 버전에 걸쳐 잘못된 층위에서 싸웠습니다. keep rule을 계속 넓히는 건 "R8이 뭘 하는지 모르지만 일단 다 지키라고 하자"는 접근이고, 넓힐수록 왜 되는지/안 되는지도 알 수 없어집니다. v0.4.0에서 잠잠해진 걸 해결로 받아들인 게 특히 나빴습니다 — 광범위 keep rule로 증상이 가려졌을 뿐인데 원인을 안다고 착각했습니다.
  • 중간 v0.3.1에서 minify를 아예 끄고 우회했습니다. 앱 크기와 성능을 포기한 채로 릴리스가 나갔습니다.
  • 왜 회귀했는지는 여전히 정확히 모릅니다. stale build cache와 fullMode 최적화가 겹친 조건이라고 추정하지만 재현 조건을 특정하지 못했습니다. enum으로 바꾸면서 문제 자체가 사라져서 더 파지 않았습니다. 원인을 완전히 규명하지 않고 패턴을 갈아엎어 회피한 셈입니다.
  • 이 결론은 다른 데이터 객체 패턴에는 검증하지 않았습니다. sealed class + data object 조합 전반이 위험한지, Navigation의 route 패턴에 국한된 문제인지 구분하지 못했습니다.

버전 요약: v0.3.0~v0.5.2 증상 / v0.3.1 minify off 우회 / v0.4.0 광범위 keep 후 재활성(회귀) / v0.5.3 enum 전환 종결.

정답이 "keep rule을 더 잘 쓰는 것"이 아니라 "그 패턴을 안 쓰는 것"인 경우가 있습니다. 지금 당신 proguard 파일에 점점 넓어진 keep rule이 있다면, 그건 최적화기가 뭘 하는지 아직 모른다는 신호일 수 있습니다.

관련 글