로컬 DB를 진실의 원천으로 두고, 네트워크는 그것을 갱신만 하는 구조.
화면 ──관찰──→ Room ←──갱신── 네트워크
화면은 네트워크를 모른다. Room 만 본다
무엇이 좋아지나
- 앱을 켜자마자 데이터가 보인다 (스피너 없이)
- 지하철·엘리베이터에서도 마지막 데이터가 남는다
- 네트워크 실패가 화면을 비우지 않는다
- 여러 화면이 같은 Room 을 보므로 자동으로 동기화된다
override fun observeUsers(): Flow<List<User>> =
dao.observeAll().map { it.map(UserEntity::toDomain) }
override suspend fun refresh(): Result<Unit> = runCatching {
val remote = api.getUsers()
dao.upsertAll(remote.map { it.toEntity() }) // 화면은 Flow 로 자동 갱신된다
}
쓰기는 어떻게 하나 — 낙관적 갱신
suspend fun toggleBookmark(id: Long) {
dao.setBookmarked(id, true) // ① 즉시 로컬 반영 → 화면이 바로 바뀐다
runCatching { api.bookmark(id) }
.onFailure { dao.setBookmarked(id, false) } // ② 실패하면 되돌린다
}
더 견고하게 하려면 '동기화 대기열' 을 둔다
- 로컬에 pending 플래그로 기록 → WorkManager 가 네트워크가 살아날 때 전송
- 앱을 꺼도 살아남는다
충돌을 어떻게 푸나
-
last-write-wins — 타임스탬프가 늦은 쪽이 이긴다. 가장 단순. 데이터를 잃을 수 있다
-
서버 우선 — 서버 값으로 덮는다. 로컬 편집이 있으면 위험
-
필드 병합 — 필드별로 최신 값을 취한다. 구현이 무겁다
-
대부분의 앱은 '읽기는 오프라인 우선, 쓰기는 서버 확인 후' 로 충분하다
무엇을 캐시하지 말아야 하나
-
실시간성이 본질인 데이터 (호가 · 잔여 좌석)
-
민감 정보 (평문 토큰 · 결제 수단)
-
자주 무효화되는 대용량 데이터
-
오래된 데이터를 보여줄 땐 "n분 전 기준" 을 화면에 적는 것이 정직하다
면접 함정
- ❌ "오프라인 우선 = 오프라인에서도 동작" → 온라인 상태의 체감 속도와 안정성이 주된 이득이다.
- ❌ "메모리 캐시로 충분하다" → 프로세스가 죽으면 사라진다. 앱을 다시 켰을 때가 진짜 문제다.
WorkManager로 동기화를 미룬다
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(ctx).enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, request)
WorkManager 를 쓰는 이유
- 앱이 죽어도 예약이 남는다 (내부 DB 에 기록된다)
- 재부팅을 넘긴다
- 네트워크가 연결될 때까지 시스템이 알아서 기다린다
- 지수 백오프로 재시도한다
viewModelScope.launch 로 동기화하면 화면을 떠나는 순간 취소된다
화면에 무엇을 보여 주나
-
데이터가 있고 갱신 중 — 옛 데이터 + 상단에 얇은 진행 표시
-
데이터가 없고 갱신 중 — 스켈레톤 또는 스피너
-
데이터가 있고 갱신 실패 — 옛 데이터 + "오프라인 — 3분 전 기준" 배너
-
데이터가 없고 갱신 실패 — 빈 화면 + 재시도 버튼
-
네 가지가 다르다. isLoading Boolean 하나로는 표현되지 않는다
-
UiState 를 sealed 로 나누거나 필드를 분리해야 하는 실제 이유