
60fps에서 한 프레임에 주어진 예산이 딱 16.6ms예요. 16.6ms가 어느 정도냐면, 눈 한 번 깜빡이는 시간의 20분의 1 정도 되는 찰나거든요. SharedPreferences 한 줄 읽기가 저사양 기기에선 그 예산을 통째로 삼켜버리는데, 정작 제 개발 기기에선 절대 안 보입니다.
작년에 사이드 프로젝트를 굴리다가 딱 이 벽에 부딪혔어요. 특정 화면에 진입할 때만 프레임이 튀는데, 안드로이드 스튜디오(Android Studio) 프로파일러를 아무리 들여다봐도 원인 코드가 안 잡히더군요. 증상만 있고 위치를 모르는 상태에서, 재현 가능한 위반 목록을 손에 쥐기까지의 기록입니다.
가끔 끊기는 안드로이드 UI 끊김 원인은 ANR 아닌 프레임 드랍
일단 오해부터 풀어야 합니다. "뚝 끊긴다"고 하면 다들 ANR부터 떠올리는데요. ANR은 메인 스레드가 5초쯤 막혀야 뜨는 극단적인 상황입니다.
제가 겪은 건 그게 아니었어요. 렌더링 프레임 하나 안에서 5~8ms짜리 짧은 디스크 읽기가 벌어지면, ANR 문턱엔 한참 못 미쳐도 그 프레임은 예산을 넘겨버립니다. 화면이 한 번 덜컥, 하는 프레임 드랍(janky frame)이 되는 거죠.
문제는 이 "가끔"이 재현이 안 된다는 점입니다. 제 플래그십 개발 기기는 저장장치가 빨라서 그 5ms를 태연히 삼켜버리거든요. 근데 사용자 손에 있는 저사양 기기에선 같은 코드가 200~500ms로 튀어요. 결국 원인은 느린 코드가 아니라 잘못된 위치에 놓인 코드였습니다. 진단의 축을 '속도'에서 '위치'로 바꿔야 비로소 보이더라고요.

안드로이드 StrictMode 사용법 — ThreadPolicy와 VmPolicy 나눠 켜기
StrictMode는 API 9(안드로이드 2.3)부터 있던 개발자용 진단 도구입니다. 정책이 두 종류로 나뉘는데, 이 둘을 다 켜야 사각지대가 없어요.
ThreadPolicy— 스레드 단위 I/O를 봅니다. 디스크 읽기·쓰기, 네트워크 접근이 메인 스레드에서 일어나면 잡아줘요.VmPolicy— 프로세스 전역 리소스 누수를 봅니다. 안 닫은 커서나 파일 같은 것들이죠.
설치 위치는 Application.onCreate() 최상단을 권합니다. 첫 액티비티보다 일러야 앱 초기화 단계의 I/O까지 감시할 수 있거든요.
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
// 디스크·네트워크 3종만 명시적으로 — detectAll()은 노이즈가 심함
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedClosableObjects() // 안 닫은 파일·커서
.detectLeakedSqlLiteObjects() // SDK 원문 표기 그대로 (SqlLite — 오타 아님)
.penaltyLog()
.build()
)
}
}
}
여기서 detectAll()을 쓰고 싶은 유혹이 크실 텐데요. 개인적으로는 처음엔 디스크·네트워크 3종만 켜는 걸 권하고 싶습니다. detectAll()은 프레임워크와 서드파티 SDK 위반까지 죄다 쏟아내서, 정작 내가 고쳐야 할 위반이 로그 더미에 묻혀버리는 경우가 많거든요.
penaltyLog는 함정, penaltyDeath·penaltyListener로 강제하기
여기가 이 글에서 제일 하고 싶은 얘기입니다. penaltyLog()는 안전하지만 함정이에요.
로그로만 남기면 어떻게 되냐면요. 위반 로그가 수십 줄씩 쏟아지고, 며칠 지나면 그냥 노이즈로 여겨서 아무도 안 봅니다. "보이게 만드는 것"과 "고치게 만드는 것"은 전혀 다른 문제였습니다.
그래서 저는 로컬 개발 빌드엔 penaltyDeath()를 물렸어요. 위반이 감지되면 앱을 강제 종료시킵니다. 제가 AOSP 동작을 직접 뜯어보며 관찰한 바로는, penaltyDeath가 로그를 먼저 남긴 뒤 마지막에 종료를 실행하더라고요(공식 문서에 순서가 명시돼 있진 않습니다). 그래서 죽기 직전에 위반 로그와 스택은 그대로 남아요. 앱이 죽어버리니 무시할 수가 없더라고요.
CI에는 penaltyListener()를 붙여서 콜백을 받아 테스트를 실패 처리했습니다. "이 위반이 있으면 머지 불가"라는 벽이 생기니까, 그제서야 PR 단위로 하나씩 수정되기 시작했어요.
다만 함정이 하나 있는데요. 광고·분석·푸시 SDK가 초기화 중에 몰래 디스크를 건드리는 일이 많습니다. 남의 코드라 제가 고칠 수가 없어요. 이럴 땐 그 초기화 블록만 permitDiskReads()로 화이트리스트해서 거짓 사망을 막아야 합니다.

메인 스레드 디스크 접근을 코루틴·DataStore로 밀어내기
위반 목록을 뽑아보니 절반이 SharedPreferences였어요. 여기서 널리 퍼진 통념 하나를 짚어야겠네요. "commit은 나쁘고 apply는 착하다"는 얘기, 많이들 배우셨을 겁니다.
근데 apply()도 안전하지 않아요. 값 첫 로드나 onPause() 시점의 디스크 flush 대기에서 메인 스레드를 물 수 있거든요. 실제로 제가 겪은 프로덕션 ANR 상위 원인 중 하나가 이 '착한' apply()였어요.
수정 방향은 이렇게 잡았습니다.
- 조회성 읽기는
withContext(Dispatchers.IO)로 백그라운드에 넘기기. - 설정 저장소는 SharedPreferences에서 DataStore로 이관하기. Flow 기반이라 구조적으로 메인 스레드 블로킹이 '생길 수 없게' 됩니다.
- 네트워크 위반은 대부분 자연 해소됐어요. Retrofit의 suspend 함수는 이미 IO 디스패처 위에서 도니까요.
// 무거운 읽기만 IO로 넘기고, 결과 반영은 다시 메인에서
suspend fun loadUserFlag(): Boolean = withContext(Dispatchers.IO) {
prefs.getBoolean("some_flag", false)
}
여기서 제가 배운 게 있어요. 코루틴·Room·DataStore·Retrofit을 처음부터 깔고 시작하는 새 프로젝트에선 StrictMode 존재감이 약합니다. 구조가 이미 메인 I/O를 막아주거든요. StrictMode의 위력은 레거시 코드에서 극대화된다고 보시면 되겠습니다.
한 가지 주의할 점은, 마이크로초 단위 읽기까지 죄다 async로 밀어내려다간 오히려 복잡도와 스레드 경합이 늘어난다는 겁니다. '위반=무조건 문제'가 아니라, 프레임 예산을 실제로 위협하는지로 우선순위를 잡는 게 맞아요.
릴리스엔 끄고 개발·CI에만 켜는 StrictMode 운영법
StrictMode 자체도 성능을 조금 먹습니다. 그래서 릴리스 빌드엔 절대 남기면 안 돼요. 특히 penaltyDeath()가 릴리스에 잔존하면, 프레임워크 위반 하나로 실사용자 앱이 그냥 죽어버립니다. if (BuildConfig.DEBUG) 가드는 그래서 필수예요.
대신 debug 전용이라 실사용자 경로는 못 본다는 한계가 있습니다. 이건 QA·베타 빌드에 penaltyListener()를 붙여서 위반을 크래시 리포터로 수집하는 식으로 보완하고 있어요.
그리고 StrictMode가 만병통치약은 아닙니다. 잡아주는 건 어디까지나 메인 스레드에 잘못 놓인 I/O예요. 백그라운드 스레드 경합이나 데드락은 못 봅니다. Play 콘솔의 vitals에서 사용자 체감 ANR이 0.47%를 넘으면 검색 노출·랭킹에 불이익이 온다고 하니, vitals와 Macrobenchmark를 같이 돌리는 게 맞다고 봅니다.
돌아보면 StrictMode의 값어치는 '느린 코드'를 찾아주는 데 있지 않았어요. 제 개발 기기에선 죽어도 안 보이던 '잘못된 위치의 I/O'를, 켜는 순간 매번 드러나는 위반으로 바꿔준다는 데 있었습니다. 재현 안 되던 '가끔'을 목록으로 만들고 나면, 그다음부턴 하나씩 백그라운드로 미는 단순 작업만 남더군요.
지금 특정 화면에서만 프레임이 튀는 앱을 붙들고 계신 분이라면, 다음 커밋에서 Application.onCreate()에 저 열 줄부터 얹어보세요. 며칠 뒤엔 로그창에 '가끔'의 정체가 스택 트레이스로 또박또박 찍혀 있을 겁니다.

'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| Baseline Profiles로 콜드 스타트 324ms→229ms 줄인 실측 일지 (0) | 2026.10.22 |
|---|---|
| 코루틴 테스트 runTest로 delay 5초를 밀리초로 줄인 트러블슈팅 기록 (0) | 2026.10.19 |
| DataStore 마이그레이션 실전기: 설정값 손실 없이 SharedPreferences 걷어낸 일지 (0) | 2026.10.13 |
| 6년차도 커서만 깜빡였다: 낯선 레거시 코드 읽는 순서 (0) | 2026.10.11 |
| 에뮬레이터 3분 부팅을 90밀리초로: Robolectric로 JVM에서 UI 테스트 돌린 일지 (0) | 2026.10.09 |