배포 직후 서재가 비었다: DataStore에 남아 있던 구버전 정렬 키
캐시 관련 작업을 끝내고 로컬에서도 QA에서도 이상 없는 걸 확인한 뒤 1.9.0 을 배포했다. 배포 직후부터 내 서재에 작품이 안 보인다는 제보가 들어왔다. 원인은 새로 짠 캐시 코드가 아니라 앱이 예전 버전에 저장해둔 값 하나였다. 이 글은 그 사고의 원인과 임시 조치, 영속 데이터에는 버전을 같이 적어야 한다는 결론까지의 기록이다.
무슨 일이 있었나
정렬 기준은 사용자가 앱을 껐다 켜도 유지돼야 하는 값이라, Preferences DataStore 에 저장하고 있었다. 기존 명세는 recent, old 두 개였다.
1.9.0 에서 정렬 타입 명세가 바뀌었다. 이미 설치된 기기의 DataStore 에는 구 버전 키인 old 가 그대로 남아 있었고, 앱은 그 값을 읽어 서버에 실어 보냈다. 서버는 신 명세에 없는 정렬 키를 받았고, 적재된 컬럼 값이 명세와 맞지 않아 목록 응답이 정상적으로 내려오지 않았다.
더 고약한 건 화면이었다. UI 레이어에 방어적 코딩이 들어가 있어서, 알 수 없는 값이 오면 old 를 등록 오래된 순으로 매핑해 정렬 상태를 그려줬다. 화면상으로는 아무 문제가 없어 보였다. 실제로 실패한 건 네트워크 요청 쪽이었는데, 그 실패는 조용히 빈 목록으로 흘러들어왔다.
잘려 보이면 전체 화면으로 열기.
신규 설치 기기에는 저장된 값이 없어서 기본값이 들어간다. 그래서 개발과 QA 환경에서는 재현되지 않았다. 업데이트 설치 경로를 테스트하지 않으면 이 부류의 버그는 배포 직후에만 모습을 드러낸다.
진짜 원인은 코드보다 오래 사는 데이터였다
이번 사고의 교훈을 한 줄로 줄이면 이렇다.
코드는 배포하면 전부 교체되지만, 저장된 데이터는 교체되지 않는다.
앱 코드는 업데이트되는 순간 전부 신 버전이다. 하지만 DataStore와 SharedPreferences, Room, 파일 캐시에 들어 있는 값은 몇 년 전 버전의 앱이 쓴 그대로 남아 있다. 영속 저장소는 여러 버전의 앱이 남긴 값이 섞여 있는 공간이고, 새 코드는 그 값을 신뢰할 수 없는 외부 입력으로 다뤄야 한다.
나는 정렬 키를 그냥 내부 값처럼 취급했다. 명세를 바꾸면서 코드 안의 enum 만 바꿨고, 이미 디스크에 적힌 문자열은 손대지 않았다.
방어적 코딩이 오히려 사고를 키웠다
가장 뼈아팠던 지점이다. UI 에는 fallback 이 있었다.
1
2
3
4
5
6
// UI 레이어: 모르는 값이 와도 화면은 그린다. 문제를 숨기는 방어다.
fun String.toSortLabel(): SortLabel = when (this) {
"recent" -> SortLabel.RECENT
"old" -> SortLabel.OLDEST // 구 키를 그대로 받아준다
else -> SortLabel.RECENT // 알 수 없으면 기본값
}
이 코드 덕분에 크래시는 나지 않았다. 대신 증상이 사라졌다. 화면은 등록 오래된 순이라고 멀쩡히 표시되는데 목록만 비어 있으니, 사용자도 나도 원인을 정렬 키로 의심하지 못했다.
방어적 코딩 자체가 나쁜 건 아니다. 문제는 방어를 UI 경계에만 두고 네트워크 경계에는 두지 않은 것이다. 값이 API 요청으로 바깥에 나가는 지점에는 방어가 아니라 변환이나 실패가 있어야 했다.
- UI 경계에서는 관대하게 처리해 사용자에게 화면은 보여준다.
- 네트워크 경계에서는 엄격하게 처리해 신 명세로 매핑하거나, 매핑할 수 없으면 기본값으로 강제한다.
조치 1. 급한 불은 서버에서 껐다
핫픽스는 구 키를 서버에서 임시로 받아주는 방향으로 진행했다. 앱 배포는 심사 + 사용자 업데이트까지 시간이 걸리지만, 서버는 즉시 반영되기 때문이다. 장애 대응으로는 맞는 선택이었다고 생각한다.
다만 이건 부채다. 서버에 구 명세가 남는 순간 언제 지워도 되는지가 영원히 애매해진다. 그래서 임시 조치를 넣을 때 제거 조건을 같이 적어둬야 한다. 예를 들면 구 키 요청 비율이 0.1% 미만으로 떨어지고 최소 지원 버전이 1.9.0 이상이 되면 제거한다는 식이다.
조치 2. 나가기 전에 매핑한다
앱 쪽 정공법은 저장값을 읽은 직후, API로 나가기 전에 신 키로 변환하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum class SortType(val apiKey: String) {
LATEST("latest"),
OLDEST("oldest");
companion object {
// 구 명세 키 → 신 명세 키. 저장소에서 읽은 값은 전부 이 문을 통과한다.
private val LEGACY = mapOf(
"recent" to LATEST,
"old" to OLDEST,
)
fun fromStored(raw: String?): SortType =
entries.firstOrNull { it.apiKey == raw }
?: LEGACY[raw]
?: LATEST // 알 수 없는 값은 기본값으로 강제
}
}
entries 는 Kotlin 1.9 부터 쓸 수 있는 enum 항목 목록 접근 방식이다. 저장소에서 읽은 문자열이 그대로 API 요청에 흘러가는 경로를 없애는 것이 목적이다.
앞으로는 저장할 때 스키마 버전을 같이 적는다
이번 일을 겪고 내린 결론은 이렇다. DataStore든 SharedPreferences든 로컬 DB든, 값만 저장하지 말고 그 값을 쓴 스키마 버전을 같이 저장한다. 읽을 때 버전이 다르면 마이그레이션하고, 마이그레이션 규칙이 없으면 버리고 기본값을 쓴다.
flowchart TD
A[저장값 읽기] --> B{저장된 schemaVersion 존재?}
B -- 없음 --> D[구버전 값으로 간주]
B -- 있음 --> C{current == stored?}
C -- 같음 --> E[그대로 사용]
C -- 다름 --> D
D --> F{마이그레이션 규칙 있음?}
F -- 있음 --> G[신 명세로 변환 후 저장]
F -- 없음 --> H[값 폐기 → 기본값 사용]
G --> E
H --> E
DataStore 에는 이걸 위한 공식 훅이 있다. DataStore 를 만들 때 migrations 에 DataMigration 을 넘기면 첫 읽기나 쓰기 이전에 한 번 실행된다. 읽는 쪽마다 방어 코드를 흩뿌리는 것보다 안전하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private val SORT_ORDER = stringPreferencesKey("sort_order")
private val SCHEMA_VERSION = intPreferencesKey("schema_version")
private const val CURRENT_SCHEMA = 2 // 정렬 명세가 바뀔 때마다 올린다
private val sortMigration = object : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean =
currentData[SCHEMA_VERSION] != CURRENT_SCHEMA
override suspend fun migrate(currentData: Preferences): Preferences =
currentData.toMutablePreferences().apply {
val stored = currentData[SORT_ORDER]
// 규칙이 있으면 변환, 없으면 키 자체를 지워 기본값을 쓰게 한다
when (stored) {
"recent" -> this[SORT_ORDER] = SortType.LATEST.apiKey
"old" -> this[SORT_ORDER] = SortType.OLDEST.apiKey
null -> Unit
else -> remove(SORT_ORDER)
}
this[SCHEMA_VERSION] = CURRENT_SCHEMA
}
override suspend fun cleanUp() = Unit
}
val Context.settings: DataStore<Preferences> by preferencesDataStore(
name = "settings",
produceMigrations = { listOf(sortMigration) },
)
앱 versionCode 를 그대로 넣어도 되지만 나는 데이터 스키마 전용 버전을 따로 두는 쪽을 권한다. 앱 버전은 데이터와 무관하게 매 릴리스마다 올라가서 마이그레이션이 매번 도는 낭비가 생긴다. 앱 버전은 versionCode / versionName 문서 대로 릴리스 식별에 쓰고, 저장소는 자기 스키마 번호를 따로 관리하면 된다.
Room이 이미 이 모델을 쓴다. @Database(version = ...) 를 올리고 Migration 을 주지 않으면 앱이 바로 크래시한다. 조용히 잘못된 값을 흘려보내는 대신 개발자가 마이그레이션을 인지하게 만드는 설계다. 키-값 저장소에는 그런 강제가 없으니 규율은 내가 만들어야 한다.
SharedPreferences에서 넘어오는 경우라면SharedPreferencesMigration이 이미 준비돼 있다. 직접 읽어 옮기지 말 것.
다음부터 지킬 것
- 영속 저장소의 값은 외부 입력이다. 파싱하고 검증한 뒤에 쓴다.
- 저장할 때 스키마 버전을 같이 저장한다. 버전이 다르면 마이그레이션하거나 버린다.
- 명세를 바꿀 때는 이미 저장된 값을 체크리스트에 올린다. enum만 바꾸고 끝내지 않는다.
- 방어적 코딩은 사용자에게 보이는 경계에만 둔다. 값이 시스템 밖으로 나가는 경계에서는 매핑하거나 실패시킨다.
- QA 시나리오에 이전 버전에서 업데이트하는 경로를 넣는다. 신규 설치만 테스트하면 이 버그는 놓친다.
- 임시 조치에는 제거 조건을 같이 적는다.
- 위험한 명세 변경은 단계적 출시로 노출 범위를 먼저 좁힌다.
캐시를 새로 만드는 작업이었는데 발목을 잡은 건 몇 달 전 버전이 남긴 문자열 하나였다. 앞으로 로컬에 뭔가를 저장할 일이 있으면 값보다 먼저 버전을 어디에 적을지부터 정하려고 한다.