Android 개발/트러블슈팅

안드로이드 배터리 드레인, 밤새 깨어 있던 8시간을 추적한 기록

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

 

예전에 출시한 앱 리뷰에 '밤새 30% 빠진다'는 별 1점이 달렸는데요. 제 테스트폰에선 도무지 재현이 안 되더라고요. 그래서 '가짜 밤'을 만들기로 했습니다.

 

dumpsys와 Battery Historian으로 8시간 동안 기기가 깨어 있던 지점을 분 단위로 끊어낸 기록입니다. 같은 리뷰를 받고 막막한 분들이 똑같이 헛돌지 마시라고 정리해두었어요.

 

안드로이드 배터리 드레인, '가짜 밤'부터 만들기

야간 드레인은 책상 위에서 잘 안 잡힙니다. 화면 끄고 충전선만 뽑아둔다고 Doze 모드가 즉시 풀로 들어가지 않거든요. 그래서 adb로 강제로 야간 환경을 흉내 내야 합니다.

 

# 충전 케이블 뽑힌 상태로 위장
adb shell dumpsys battery unplug

# 내 앱을 사용 빈도 낮은 상태로 표시
adb shell am set-inactive com.example.app true

# Doze 모드 강제 진입
adb shell dumpsys deviceidle force-idle

 

이렇게 만들어둔 뒤 한 시간만 방치해도 실제 야간 8시간의 일부 패턴이 재현됩니다. 다만 OEM 커스터마이징(삼성 게임 부스터 같은 것)이 결과를 흔드는 일이 잦은 편이라, Play Console Vitals의 "Excessive wake locks" 지표로 문제 기기 모델을 먼저 특정하는 게 순서예요.

 

한 가지 짚어둘 게 있는데요, 야간 드레인은 연속 소모가 아니라 Doze 유지보수 시간(maintenance window)에 작업이 몰리면서 생기는 짧은 스파이크 패턴일 때가 많습니다. 평균값만 보고 안심하면 안 되고 분포를 봐야 해요.

 

 

Battery Historian으로 야간 wake 구간 펼쳐 보기

이제 본격 추적입니다. adb bugreport bug.zip으로 덤프를 받은 뒤 Battery Historian Docker 이미지로 HTML 리포트를 띄우면, 8시간이 한 화면에 깔립니다.

 

저는 트랙을 다 보지 않고 CPU running, Partial wakelock, JobScheduler, Top app 네 개부터 봐요. 화면 꺼진 구간에서 CPU running 트랙에 제 앱 패키지가 5초 이상 잡히면 그 시점이 1순위 용의자입니다.

 

그 위에 Partial wakelock 트랙을 겹쳐 보면 어떤 TAG가 깨워두고 있었는지 한눈에 보이더라고요. 수치 합산이 필요하면 Battery Historian의 raw batterystats 텍스트나 dumpsys batterystats --checkin 출력을 직접 파싱해 패키지별 wake 시간을 합산할 수 있어요. 광고 SDK가 800ms씩 꾸준히 점유하고 있던 게 8시간 동안 누적되면 꽤 큰 숫자가 됩니다. 800ms가 감이 잘 안 오신다면, 2초마다 한 번 잠깐 깨우는 정도가 8시간 누적되면 1만 5천 번 가까이 깨운 셈이 된다는 얘기예요.

 

 

WorkManager Doze 충돌, 주기 작업이 한꺼번에 도는 이유

15분 주기로 걸어둔 PeriodicWorkRequest가 Doze 모드에선 그대로 돌지 않습니다. 시스템이 모아뒀다가 maintenance window에서 한꺼번에 풀어버리거든요. "왜 안 도냐"가 아니라 "왜 한꺼번에 도냐"가 진짜 문제인 거죠.

 

이 시점에 Worker 여러 개가 동시에 깨어나면서 CPU·네트워크를 같이 점유하면, 그 짧은 1~2분이 야간 드레인의 절반을 차지하는 일도 생깁니다.

 

해법은 의외로 단순합니다.

 

val request = PeriodicWorkRequestBuilder<SyncWorker>(
    repeatInterval = 1, repeatIntervalTimeUnit = TimeUnit.HOURS,
    flexTimeInterval = 30, flexTimeIntervalUnit = TimeUnit.MINUTES
).setConstraints(
    Constraints.Builder()
        .setRequiresBatteryNotLow(true)
        .setRequiresCharging(true) // 동기화는 충전 중에만
        .build()
).build()

 

setRequiresBatteryNotLow, setRequiresCharging 같은 제약을 걸면 시스템이 알아서 충전 시점으로 작업을 미뤄줍니다. flex interval을 같이 주면 묶어서 한 번에 처리할 여지도 생기고요.

 

그리고 Expedited Work는 함부로 쓰면 안 되는데요, Doze를 우회하겠다고 모든 동기화를 expedited로 돌려버리면 Play의 "Excessive Partial Wake Lock" 정책에 걸릴 수 있어요. 24시간 누적 2시간 이상 partial wake lock을 가진 사용자 세션 비율이 일정 기준을 넘으면 2026-03-01부터 추천 같은 prominent discovery surfaces에서 제외된다고 알려져 있어요.

 

 

WakeLock 네이밍과 setExactAndAllowWhileIdle 오남용

WakeLock TAG를 그냥 "sync", "job"처럼 던지지 마세요. Play Console에 wake lock names table이 추가되면서 태그별로 원인이 노출되는데, 익명 태그면 어디서 새는지 본인도 못 찾습니다.

 

// ❌ 추적 안 되는 태그
val wl = pm.newWakeLock(PARTIAL_WAKE_LOCK, "sync")

// ⭕ 패키지::용도 형식
val wl = pm.newWakeLock(
    PowerManager.PARTIAL_WAKE_LOCK,
    "com.example.app::NightSync"
)

try {
    wl.acquire(10 * 60 * 1000L /* 10분 타임아웃 */)
    doSync()
} finally {
    if (wl.isHeld) wl.release()
}

 

try-finally로 해제를 보장하고, acquire(timeout) 형태로 보조 타임아웃까지 깔아두는 게 안전합니다. 실제로 누수의 상당수가 예외 경로에서 release를 못 부른 케이스라는 얘기가 많더라고요.

 

정확한 시간이 중요한 약 복용 알림 같은 경우엔 WorkManager가 아니라 AlarmManager.setExactAndAllowWhileIdle()을 써야 합니다. 다만 이걸 1분 단위로 호출하기 시작하면 그 자체가 Doze를 계속 깨우는 드레인 원인이 됩니다. 하루 몇 번 수준으로 제한해두는 게 맞다고 봐요.

 

마지막으로 Battery Historian에서 본인 코드 wake lock이 적은데도 야간 드레인이 안 잡히면, 광고·푸시·분석 SDK가 잡고 있을 가능성이 높아요. 서드파티 TAG 점유율도 같이 확인하셔야 합니다.

 

 

배터리 회귀를 막는 야간 점검 루틴

마지막으로 제가 PR 머지 전에 돌리는 야간 점검 루틴 세 가지를 정리해둡니다.

 

  1. 백그라운드 작업 수정 PR은 머지 전 dumpsys batterystats --reset 후 1시간 유휴 측정으로 wake 시간 회귀를 확인합니다.
  2. 주간 Vitals의 "Excessive partial wake locks" 비율을 봐서 임계치 가까이 가면 선제 대응합니다.
  3. WorkManager의 STOP_REASON을 로깅해 TIMEOUT 비율이 높은 Worker를 분기마다 솎아냅니다.

 

개인적으로 야간 점검은 사후가 아니라 PR 단계에 끼워두시는 게 훨씬 낫더라고요. 리뷰에 별 1점이 달린 뒤 추적하기 시작하면, 그땐 이미 비슷한 경험을 한 사용자가 한참 쌓인 뒤거든요. 한 시간 유휴 측정이 별거 아닌 것 같아도 야간 8시간 드레인의 상당 부분은 그 한 시간 안에서 형체가 드러나더라고요.

 

별 1점 리뷰 한 줄이 가장 정확한 버그 리포트일 때가 있습니다. 재현이 안 된다고 덮지 마시고 dumpsys와 Battery Historian으로 직접 '가짜 밤'을 만들어 들여다보시는 걸 자신 있게 추천합니다.

 

반응형
LIST