배경
웹 리뉴얼에 맞춰 모바일 앱을 정비한 직후 바로 첫 스토어 출시를 준비했습니다. 그런데 출시 직전 Play Console이 앱 최적화 항목을 지적했습니다. 압축 해제 DEX가 22.95MB였는데 R8이 한 번도 켜진 적이 없었습니다. Metro는 트리셰이킹을 하지 않아서, 아이콘 라이브러리를 배럴로 import하면 실제로 쓰지 않는 아이콘까지 모두 번들에 들어가고 있었습니다.
심사 쪽도 위험 요소가 많았습니다. iOS Info.plist에는 기능상 효과가 없는 voip 배경 모드가 선언되어 있었고, 심사 메모에는 그 사유가 적혀 있었습니다. 병합된 Android 매니페스트에는 앱이 쓰지 않는 생체 인증·부팅 수신·정확 알람 권한이 라이브러리를 통해 들어와 있었습니다. 계정 탈퇴 경로도 없었습니다.
접근
먼저 Android 바이너리를 줄였습니다. R8 full mode와 리소스 축소를 켜고, keep 규칙은 라이브러리마다 이미 제공하는 consumer 규칙에 맡겼습니다. 직접 추가한 규칙은 리플렉션으로만 접근해 R8이 알아보지 못하는 지점에 한정했습니다. 통째로 keep하면 최적화율이 떨어지기 때문입니다. 실제 Play 배포 경로를 재현하려고 AAB에서 split APK를 뽑아 Maestro로 로그인부터 회의 입장까지 확인했습니다. 결과는 DEX 22.95 → 5.60MB, 다운로드 −30.6%였습니다.
JS 번들은 실제 hermesc로 측정한 다음 잘라냈습니다. 쓰지 않는 SDK 대입 한 줄 때문에 597KB가 딸려 들어오고 있었고, throttle 하나 때문에 lodash 전체가 실려 있었습니다. 이것들을 걷어내 Hermes −22%를 얻었습니다. 아이콘은 실제로 쓰는 85개만 딥임포트해서 다시 내보내는 배럴로 바꿔 −20.6%를 더 줄였습니다. 이름 규칙만으로는 맞힐 수 없는 별칭 아이콘 9개는 실제로 렌더한 SVG 도형까지 원본과 비교하는 테스트로 고정했습니다.
아이콘이 “조용히 다른 모양으로 그려지는” 회귀는 사람이 눈으로 찾기 어렵습니다. 그래서 계약 테스트가 원본 배럴 표 전체와 대조하도록 했습니다.
iOS에는 R8에 해당하는 빠진 단계가 없었습니다. 그래서 숨어 있던 기본값 11개를 빌드 설정에 명시하고 Swift whole module만 실제로 바꿨습니다. 측정해 보니 IPA 33MB 중 47.8%가 사용자에게 전달되지 않는 심볼 파일이었고, 실제 다운로드는 약 17MB였습니다. 없는 이득은 주장하지 않는다는 원칙에 따라 효과가 측정되지 않은 LTO와 에셋 옵션은 되돌렸습니다.
심사 대응은 반려 사유를 미리 걷어내는 쪽으로 진행했습니다. voip 배경 모드와 그 사유를 적은 심사 메모를 함께 지웠고, 라이브러리가 넣던 위험 권한 4종은 매니페스트 병합 결과를 실측하며 제거하거나 상한을 걸었습니다. 앱 내 계정 탈퇴는 서버 동작이 실제로는 비활성화라는 점을 확인한 뒤 확인창 문구도 사실에 맞게 적었습니다. 그래도 받은 반려(2.3.10, 앱 설명에 다른 플랫폼 이름을 쓴 한 줄)는 문안 수정과 회신으로 처리했습니다.
마지막으로 릴리스 경로의 함정을 테스트로 막았습니다. 앱 이름을 바꾼 뒤 업로드 스크립트가 같은 자리에 남아 있던 이전 번들 ID의 옛 IPA를 가리키던 문제는, 가장 최근 산출물을 동적으로 고르고 없으면 즉시 실패하도록 바꿨습니다. 스토어 스크린샷은 XCUITest 타깃이 접근성 트리로 화면을 조작하며 iPhone 6.9”와 iPad 13”용을 자동으로 촬영합니다.
결과
- Android DEX 22.95MB → 5.60MB, 다운로드 −30.6%, R8 예외 0건.
- Hermes 바이트코드 −22% (쓰지 않는 의존성 제거), 이어서 −20.6% (아이콘 배럴 1,767 → 85개).
- iOS 빌드 설정 11개 명시, 크래시 심볼화 보증. IPA 33MB 중 실제 다운로드 약 17MB.
- 첫 출시 1.0.0(빌드 10) 이후 5주 동안 1.0.5(빌드 29)까지 연속 배포.
- 위험 권한 4종 → 0건, 앱 내 계정 탈퇴, targetSdk 36 edge-to-edge 대응.
- 반려 1건과 정보 요청 1건을 바이너리 재업로드 없이 해결.
배운 점
출시 직전의 최적화는 측정 경로가 실제 배포 경로와 같을 때만 의미가 있었습니다. universal APK에서 통과해도 Play가 내려보내는 split APK에서 깨질 수 있어서, AAB에서 split을 직접 뽑아 검증한 것이 R8을 안심하고 켤 수 있었던 이유였습니다.
심사도 비슷했습니다. 반려는 대부분 앱이 실제로 하지 않는 일을 선언한 곳에서 나옵니다. 쓰지 않는 배경 모드, 쓰지 않는 권한, 사실과 다른 확인창 문구가 그런 예입니다. 선언과 실제 구현을 맞추고, 그 상태가 다시 어긋나지 않도록 테스트로 고정해 두는 것이 반려를 줄이는 가장 싼 방법이었습니다.