Android 개발/트러블슈팅

안드로이드 ANR 해결, Play Console '메인 5초' 리포트만 보고 범인 찾은 자리

stackD 2026. 7. 2. 18:00
반응형

 

월요일 아침에 Play Console 열었더니 ANR 발생률이 0.62%로 빨갛게 떠 있었어요. 재현은 안 되고, 스택 최상단은 nativePollOnce 한 줄이 전부였습니다.

 

기준치는 0.47%인데, 한 줄로 0.15%p 차이지만 Store 노출 불이익이 28일 롤링 평균으로 평가되는 수치라 한가하게 둘 상황이 아니었습니다. 다행히 안드로이드 스튜디오(Android Studio)에서 R8 매핑 파일을 번들에 자동 포함시키도록 해둔 덕에 난독화 해제는 그대로 됐어요. 거기서부터 한 줄씩 클러스터를 까보기 시작했습니다.

 

Play Console ANR 리포트, 'nativePollOnce' 클러스터 걸러내기

가장 먼저 한 일은 nativePollOnce 가 top frame인 클러스터를 일단 옆으로 치워두는 거였어요. 입력 디스패치 5초 ANR의 경우, ANR 발생 후에 스택 덤프가 늦게 수집되면서 메인 스레드가 이미 유휴 상태로 돌아간 모습이 찍히는 경우가 많은 것으로 알려져 있어요. 전체 ANR 중 상당수가 이 늦은 덤프 패턴으로 묶인다는 자료도 본 적이 있습니다.

 

다만 BroadcastReceiver ANR이라면 nativePollOnce 가 떠도 다른 스레드가 락을 들고 있어서 못 깬 케이스일 수 있으니, 클러스터 옆에 붙은 ANR 타입은 한 번 확인하시는 게 안전합니다. 제 경우엔 입력 디스패치 케이스라 1번 클러스터는 일단 보류했어요.

 

그다음 클러스터를 열어보니 top frame이 SharedPreferencesImpl$EditorImpl.commit 이었습니다. 특정 저가형 한 모델에서 DAU의 9.4%가 ANR을 경험하고 있었는데, 디바이스별 임계치 8%도 같이 넘긴 상태였어요. 범인 후보가 한 번에 좁혀집니다.

 

 

StrictMode로 메인 스레드 디스크 위반 사전 탐지

리포트만 보고 다음 빌드를 올리기엔 자신이 없어서 디버그 빌드에 StrictMode를 깔았습니다.

 

if (BuildConfig.DEBUG) {
    StrictMode.setThreadPolicy(
        StrictMode.ThreadPolicy.Builder()
            .detectDiskReads()
            .detectDiskWrites()
            .detectNetwork()
            .penaltyLog()
            .penaltyDeath()
            .build()
    )
    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectLeakedClosableObjects()
            .detectLeakedSqlLiteObjects()
            .penaltyLog()
            .build()
    )
}

 

콜드 스타트 한 번 돌렸는데 위반이 18건이 떴어요. 그중 6건이 메인 스레드 디스크 읽기였고, 나머지는 광고 SDK와 Firebase 초기화에서 올라온 거라 그쪽은 예외 처리로 걸러뒀습니다. penaltyDeath 를 안 걸어두면 로그에 묻혀버려서 모르고 지나가는 일이 잦은데, 디버그에서는 일부러 죽게 두는 쪽이 결국 안전해요.

 

VmPolicy 쪽에서는 미반환 Cursor가 두 군데 잡혔습니다. 백그라운드 락 점유 시간이 240ms 정도 짧아지면서 메인 스레드가 락 풀리길 기다리는 구간도 같이 줄어들었어요.

 

 

SharedPreferences를 DataStore로 — '침묵의 ANR' 차단

가장 큰 한 방은 SharedPreferences 마이그레이션이었습니다. apply() 를 쓰고 있어서 안심하고 있었는데, 정작 문제는 QueuedWork.waitToFinish() 였어요. 액티비티가 onStop() 을 탈 때 미처 디스크에 못 쓴 작업을 메인 스레드에서 동기로 기다리는 구조인데, 저가형에서 P95 응답이 1.8초까지 늘어지고 있었거든요. 참고로 Android 8.0(API 26)부터 QueuedWork가 재작성되면서 handleStopActivity()·handleStopService() 경로에서 모두 블로킹이 걸리도록 정리됐기 때문에, 8.0+ 단말이 다수인 분포라면 영향이 더 크게 누적됩니다.

 

사용자 설정 14개는 DataStore Preferences로 옮기고, 자주 안 쓰는 캐시 6개는 백그라운드 코루틴에서 lazy 로딩으로 돌렸습니다. 콜드 스타트가 920ms → 610ms로 떨어졌어요. 같이 손 본 김에 메인 스레드에서 호출되던 BitmapFactory.decodeFile 도 코일(Coil)로 갈아끼웠고, 디코딩 작업을 IO 디스패처로 빼면서 관련 클러스터의 ANR이 71% 줄었습니다.

 

여기서 한 가지 짚고 가자면, DataStore가 만능은 아닙니다. 첫 read가 일어날 때 Flow가 백그라운드에서 채워지는 동안 잠깐 빈 값을 들고 있어야 해요. UI에서 초기 상태 처리를 따로 안 해두면, 빈 화면이 잠깐 떴다가 채워지는 모양새가 생깁니다. 마이그레이션과 함께 초기 상태 UI를 같이 손보시는 게 좋습니다.

 

 

R8 풀모드와 baseline-prof로 ANR 11% 추가 감소

여기까지 하고 나서 ANR률이 0.31%까지 떨어졌는데, 기준선 0.47% 아래로는 들어왔지만 마음에 안 들었어요. R8 풀모드를 한 번 더 켰습니다.

 

android.enableR8.fullMode=true

 

그리고 release 빌드 타입에 isShrinkResources = true 까지 같이 켰습니다. 데드 코드가 깎이면서 클래스 초기화 경합이 줄었고, baseline profile도 새 빌드 기준으로 다시 뽑아 AOT 컴파일 정확도를 올렸어요. 이 두 가지로 ANR률이 약 11% 추가로 줄었습니다.

 

다만 풀모드는 리플렉션 라이브러리와 부딪히는 일이 종종 생깁니다. 저 같은 경우엔 -keep 규칙을 13줄 추가하고, Play Console 사전 출시 보고서로 회귀가 없는지 확인한 다음에 단계 출시를 걸었어요. 풀모드 켤 땐 사전 출시 보고서가 진짜 큰 보험이더라고요.

 

 

Play Console ANR 지표가 못 잡는 4.9초 frozen frame

28일이 지난 시점의 최종 ANR률은 0.18%로 안정화됐습니다. 다만 Play Console 지표만 보고 안심하면 안 되는 한 가지가 남아요.

 

Play Console은 정확히 5초 이상 멈춘 케이스만 ANR로 집계합니다. 4.9초짜리 freeze가 반복돼도 ANR률은 0으로 찍힌다는 얘기지요. 사용자 체감 상으로는 이미 앱이 죽은 느낌일 텐데 지표는 깨끗하다는 뜻이에요.

 

이 영역은 Firebase Performance의 frozen frame 지표를 같이 보셔야 합니다. Firebase는 한 프레임 렌더링이 700ms 넘게 걸리는 구간을 frozen frame으로 잡아주는데, ANR로 집계되지 않는 4~5초 구간 freeze를 이 지표로 미리 포착할 수 있어요. 그리고 락 경합으로 의심되는 클러스터를 진짜로 파헤치려면 메인 스레드 외 다른 스레드 덤프가 필요한데, 이건 Play Console만으로는 불가능합니다. 저는 크래시리틱스(Crashlytics)와 센트리(Sentry)를 같이 붙여서 ANR 직전 트레이스를 확인하고 있어요. 전체 스레드 덤프 한 장이 짐작 30분을 줄여주더라고요.

 

ANR은 결국 '느린 코드'의 문제가 아니라 '메인 스레드에서 실행된 코드'의 문제라는 점이, 28일을 굴려보고 나서 가장 또렷해진 결론이었습니다. 한 차례 핫픽스로 끝낼 수 있는 종류가 아니라, StrictMode로 빌드 단계에서 잡고 지표는 4주 단위로 관찰하는 루틴이 결국 답이었어요. 이번 분기엔 frozen frame 쪽 지표도 같은 호흡으로 한 번 더 파볼 생각입니다.

 

반응형
LIST