앱 꺼져도 끊기지 않는 다운로드 UX로 전면 개선
목차
기존 다운로드 구조의 근본적인 문제는 단순했다. 다운로드가 액티비티 수명에 묶여 있었다. 화면 떠나면 끊기고, 다시 들어오면 처음부터. 사용자 입장에서는 받던 게 없어진 건지, 아직 받고 있는 건지 구분도 안 됐다. 콘텐츠 하나를 두세 번 받게 만드는 구조였으니, UX 측면에서도 트래픽 측면에서도 손해였다.
이번 작업은 썸네일 캐시, 포그라운드 서비스, 토큰 영속화, 원탭 큐 진입을 하나의 흐름으로 묶어서 전면 재설계한 것. 각각은 이미 알려진 패턴들이지만, 이걸 하나로 조립하는 과정이 생각보다 복잡했다.
포그라운드 서비스 - 안드로이드 백그라운드 제약을 돌아가는 법
안드로이드는 백그라운드 작업에 점점 강한 제약을 걸어왔다. Doze 모드, 앱 스탠바이 버킷, Android 12 이후로 더 강해진 배터리 최적화. 일반 Service로는 화면 꺼지는 순간 OS가 죽여버릴 수 있다.
포그라운드 서비스는 그 제약을 피하는 공식 경로다. 노티피케이션을 달아서 "이 앱이 지금 뭔가 하고 있다"는 걸 사용자에게 노출하는 대신, OS가 함부로 종료하지 못하게 하는 계약이다. 다운로드처럼 중단되면 안 되는 작업에는 거의 유일한 선택지.
Android 14부터는 포그라운드 서비스 타입 명시가 강제됐다. 선언 안 하면 서비스가 죽는 게 아니라 앱이 크래시한다. 타입이 dataSync, mediaPlayback, location 등으로 세분화돼 있는데, 다운로드 목적이면 dataSync가 맞다.
<service
android:name=".download.DownloadService"
android:foregroundServiceType="dataSync"
android:exported="false" />
매니페스트 선언만 하면 끝인 줄 알았는데, 런타임 호출에도 타입을 명시해야 했다.
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
이게 타깃 SDK 올릴 때마다 한 번씩 터지는 지점이다. 버전 분기를 명확하게 해두지 않으면 구버전 기기에서 멀쩡히 돌던 게 신버전에서 조용히 죽는 패턴이 반복됨.
서비스 수명은 다운로드 큐가 비는 시점에 스스로 종료하도록 했다. 큐가 있을 때만 살고, 없으면 죽는다. WorkManager를 쓰는 방법도 있지만, 진행률 노티피케이션을 실시간으로 업데이트해야 하는 요구사항에서는 포그라운드 서비스가 더 직접적이었다. WorkManager는 지연 허용, 재시도 내성이 필요한 작업에 더 맞는 도구다. 둘의 용도가 다르다.
노티피케이션 업데이트 빈도도 신경 써야 했다. 진행률이 바뀔 때마다 매번 업데이트하면 시스템 부하가 올라가고 간헐적으로 ANR 원인이 된다. 500ms 스로틀링을 걸어서 일정 간격으로만 갱신하게 했음.
세션 유지 - 토큰 저장으로 끝이 아니었다
세션 문제는 처음에 "토큰을 영속화하면 되겠다"고 단순하게 봤다. 실제로는 재발급, 재시도, 폴백까지 한 흐름으로 묶어야 진짜 유지였다.
기존 구조는 토큰을 메모리에만 들고 있었다. 앱 프로세스가 죽으면 토큰도 같이 휘발되니까, 다운로드 도중 앱이 백그라운드로 밀려나서 프로세스가 재생성되면 401이 떨어졌다. 포그라운드 서비스를 달아봐야, 토큰이 없으면 다운로드가 멈추는 건 마찬가지다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 토큰 보관 | 메모리 (앱 종료 시 휘발) | 암호화 저장소 |
| 만료 처리 | 401 → 강제 로그아웃 | 자동 재발급 후 재시도 |
| 재개 방식 | 처음부터 다시 | 직전 바이트부터 이어받기 |
암호화 저장소는 Android Keystore 기반의 EncryptedSharedPreferences를 썼다. 단순 SharedPreferences에 박으면 루팅 기기에서 토큰 노출이 가능하니까.
이어받기는 HTTP Range 헤더를 활용한다. 요청에 Range: bytes=<offset>-를 붙이면 서버가 206 Partial Content로 응답하면서 해당 오프셋부터 내려보낸다. 문제는 서버가 Range를 지원 안 하는 경우다. 그냥 200을 내려보내면서 처음부터 다 보내는 경우가 있어서, 응답 코드로 분기했다.
val response = httpClient.download(url, offset = downloadedBytes)
when (response.code) {
200 -> {
// 서버가 Range 무시함 - 처음부터 다시 받기
file.delete()
writeFromStart(response.body)
}
206 -> {
// 이어받기 성공
appendFrom(file, response.body)
}
401 -> {
tokenManager.refresh()
retry()
}
}
재발급 흐름도 단순하지 않았다. 다운로드 큐에 항목이 여러 개면, 각 항목이 동시에 401을 받고 동시에 재발급을 시도할 수 있다. 레이스 컨디션이 생기는 지점. 이걸 mutex로 묶어서 재발급 요청이 한 번만 날아가고 결과를 공유하도록 처리해야 했다. 이 부분이 "세션 유지"의 핵심이었는데, 토큰 저장만 해결하고 넘어갔다면 다운로드 항목이 많을수록 재발급 요청이 폭발하는 버그로 이어졌을 것.
원탭 큐 진입 + 썸네일
UX 개선 중 체감이 가장 컸던 부분은 확인 다이얼로그 제거였다. 다운로드 버튼 누르면 뜨던 "정말 받으시겠습니까?" 다이얼로그를 없앴다. 버튼 탭 하나로 바로 큐에 들어간다.
확인 다이얼로그가 필요한 경우가 있긴 하다. 삭제처럼 되돌리기 어려운 작업, 과금이 발생하는 작업. 하지만 다운로드는 다르다. 취소할 수 있고, 용량이 부족하면 어차피 실패한다. 마찰을 줄이는 게 맞는 방향. 다이얼로그 하나 빼는 게 별것 아닌 것 같지만, 전환율 차이가 꽤 컸다. UX 마찰은 단계마다 누적되기 때문에, 역으로 한 단계씩 빼면 누적으로 회복된다.
노티피케이션 진행 상태는 이런 구조로 노출했다.
[썸네일] 콘텐츠명
▓▓▓▓▓▓▓░░░ 67%
[취소]
썸네일은 미리 캐시해서 노티피케이션에 붙였다. 다운로드가 진행되는 동안 어떤 콘텐츠인지 시각적으로 보이면 사용자가 진행 상태를 더 직관적으로 파악한다. 큐에 항목이 여러 개일 때 특히 유효하다. 썸네일 없이 텍스트만 있으면 알림이 여러 개 쌓였을 때 어떤 게 어떤 건지 구분이 안 된다.
이 작업을 마치고 정리되는 게 몇 가지 있었다.
포그라운드 서비스는 OS 버전마다 규칙이 바뀐다. 타깃 SDK를 올릴 때마다 매니페스트 선언, 런타임 타입, 권한 요청 방식이 달라질 수 있어서 버전 대응 코드가 쌓인다. 한 번 잘 짜놨다고 끝이 아니고, SDK 올릴 때마다 점검 항목이 하나씩 늘어나는 영역이다.
세션 유지는 중간 고리 하나가 빠지면 사용자가 어디선가 조용히 실패를 경험한다. 토큰이 저장돼도 재발급이 실패하면, 재발급이 돼도 재시도가 없으면, 재시도가 있어도 동시 재발급이 안 막히면 - 각각이 독립적으로 필요한 조건이지 선택지가 아니다.
작은 마찰 하나하나가 합산돼서 경험이 된다. 다이얼로그 한 번, 로딩 인디케이터 하나, 진행 상태 노출 하나. 이번 작업에서 다이얼로그 제거 하나가 가장 크게 느껴진 이유가 거기 있었다.
댓글 0
첫 댓글 달아줘.