Offline-First Android: 네트워크가 없을 때도 계속 작동하는 앱 만들기
Source: Dev.to
위 링크에 있는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 현재는 번역할 본문이 없으므로, 번역이 필요한 텍스트를 복사해서 알려 주세요.
소개
모바일 연결은 “좋다” 혹은 “나쁘다”가 아니라 불규칙합니다. 쇼핑몰, 지하철 터널, 붐비는 공항, 국립공원 캠프장, 그리고 긴 시골 고속도로 구간… 사용자는 모두 그 안에 살아갑니다.
앱의 주요 흐름이 **“API 호출 → UI 렌더링”**이라면, 모든 타임아웃은 다음과 같이 나타납니다:
- 스피너,
- 재시도 버튼,
- 그리고 사용자의 신뢰 상실.
오프라인‑퍼스트는 계약을 바꿉니다: 네트워크가 없더라도 UI는 기능을 유지합니다. 네트워크는 기본 UX를 위한 의존성이 아니라 동기화 채널이 됩니다.
이번 포스트에서는 배포 가능한 오프라인‑퍼스트 구조를 구축합니다:
- UI 진실 원본으로 Room
- stale‑while‑revalidate (SWR) 로 읽기
- Outbox pattern 으로 쓰기
- WorkManager 로 백그라운드 동기화
정신 모델
온라인‑우선 (취약)

API가 실패하면 화면도 실패합니다.
오프라인‑우선 (탄력적)

- Room은 UI가 신뢰하는 것입니다.
- 네트워크는 결국 로컬 상태를 서버와 일치시키는 역할을 합니다.
핵심 패턴: SWR + Outbox
1️⃣ Reads – Stale‑While‑Revalidate (SWR)
- UI가 Room에서 즉시 렌더링됩니다.
- Repository가 데이터가 “충분히 오래됐는지”를 판단해 새로 고침 여부를 결정합니다.
- 새로 고침은 백그라운드에서 실행됩니다.
- Room이 업데이트되면 UI도 업데이트됩니다 (왜냐하면
Flow를 수집하고 있기 때문입니다).
2️⃣ Writes – Outbox pattern
- 사용자 편집 내용을 즉시 Room에 저장하고 레코드를 PENDING 상태로 표시합니다.
- outbox 테이블에 작업을 추가합니다.
WorkManager가 연결이 가능할 때 업로드합니다.- 성공하면 SYNCED 로 표시합니다.
- 충돌이 발생하면 CONFLICTED 로 표시합니다 (또는 직접 정의한 병합 규칙을 적용합니다).
데이터 모델: 도메인 + 동기화 메타데이터
우리는 아래와 같이 다양한 이름을 사용할 것입니다:
| Symbol | Meaning |
|---|---|
Clip | 사용자가 보고/편집하는 항목 |
VaultDb | Room 데이터베이스 |
CourierApi | Retrofit API |
DispatchSyncWorker | WorkManager 워커 |
Sync‑state 열거형
enum class SyncFlag { SYNCED, PENDING, SYNCING, CONFLICTED }
enum class OpKind { UPSERT, DELETE }
Room entity – ClipEntity
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "clips")
data class ClipEntity(
@PrimaryKey val clipId: String, // client‑generated UUID
val title: String,
val body: String,
// sync metadata
val localUpdatedAt: Long, // device time when user edited
val serverUpdatedAt: Long?, // server timestamp if known
val cachedAt: Long, // last successful fetch time
val lastOpenedAt: Long, // for cleanup / LRU
val syncFlag: SyncFlag // SYNCED / PENDING / …
)
아웃박스 – OutboxOp (대기 중인 작업)
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "outbox_ops")
data class OutboxOp(
@PrimaryKey val opId: String, // UUID
val clipId: String,
val kind: OpKind, // UPSERT / DELETE
val payloadJson: String?, // null for delete
val sequence: Long, // ordering per clip
val attempts: Int,
val lastAttemptAt: Long?
)
DAO – UI용 Flow와 동기화용 쿼리
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface ClipDao {
// UI reads
@Query("SELECT * FROM clips ORDER BY lastOpenedAt DESC")
fun observeClips(): Flow<List<ClipEntity>>
@Query("SELECT * FROM clips WHERE clipId = :id LIMIT 1")
fun observeClip(id: String): Flow<ClipEntity?>
// Writes
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertClip(item: ClipEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertClips(items: List<ClipEntity>)
// Outbox
@Query("SELECT * FROM outbox_ops ORDER BY clipId ASC, sequence ASC")
suspend fun loadOutboxOrdered(): List<OutboxOp>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun enqueueOutbox(op: OutboxOp)
@Query("DELETE FROM outbox_ops WHERE opId = :opId")
suspend fun deleteOutbox(opId: String)
// Deletions from server pull
@Query(
"""
DELETE FROM clips
WHERE clipId IN (:ids)
AND syncFlag NOT IN ('PENDING','CONFLICTED')
"""
)
suspend fun deleteClipsIfSafe(ids: List<String>)
// Helpers
@Query("SELECT MAX(cachedAt) FROM clips")
suspend fun lastCacheTime(): Long?
@Query("UPDATE clips SET lastOpenedAt = :now WHERE clipId = :id")
suspend fun touchClip(id: String, now: Long)
// Cleanup (example placeholder – adjust as needed)
@Query(
"""
DELETE FROM clips
WHERE syncFlag NOT IN ('PENDING','CONFLICTED')
AND lastOpenedAt < :threshold
"""
)
suspend fun cleanupOldEntries(threshold: Long)
}
저장소: SWR 읽기 + Outbox 쓰기
레포지토리는 여러분의 정책 두뇌입니다:
- UI는 항상 Room에서 읽습니다.
- 새로 고침은 캐시 연령을 기준으로 백그라운드에서 수행됩니다.
- 쓰기는 Room을 즉시 업데이트하고 outbox 작업을 큐에 넣습니다
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.util.UUID
class VaultRepository(
private val dao: ClipDao,
private val syncScheduler: SyncScheduler,
private val clock: () -> Long,
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO)
) {
fun observeClips(): Flow<List<ClipEntity>> {
ioScope.launch { maybeRefresh() }
return dao.observeClips()
}
fun observeClip(id: String): Flow<ClipEntity?> {
ioScope.launch { dao.touchClip(id, clock()) }
return dao.observeClip(id)
}
suspend fun saveClip(title: String, body: String, existingId: String? = null) {
val id = existingId ?: UUID.randomUUID().toString()
val now = clock()
val entity = ClipEntity(
clipId = id,
title = title,
body = body,
localUpdatedAt = now,
serverUpdatedAt = null,
cachedAt = now,
lastOpenedAt = now,
syncFlag = SyncFlag.PENDING
)
dao.upsertClip(entity)
val op = OutboxOp(
opId = UUID.randomUUID().toString(),
clipId = id,
kind = OpKind.UPSERT,
payloadJson = """{"clipId":"$id","title":${title.json()},"body":${body.json()},"serverUpdatedAt":0}""",
sequence = now,
attempts = 0,
lastAttemptAt = null
)
dao.enqueueOutbox(op)
syncScheduler.kick()
}
suspend fun removeClip(id: String) {
val now = clock()
val op = OutboxOp(
opId = UUID.randomUUID().toString(),
clipId = id,
kind = OpKind.DELETE,
payloadJson = null,
sequence = now,
attempts = 0,
lastAttemptAt = null
)
dao.enqueueOutbox(op)
syncScheduler.kick()
}
private suspend fun maybeRefresh() {
val now = clock()
val last = dao.lastCacheTime() ?: 0L
val ageMs = now - last
val shouldRefresh = when {
last == 0L -> true
ageMs > 15 * 60_000L -> true // 15 minutes
else -> false
}
if (shouldRefresh) syncScheduler.refreshSoon()
}
}
/** Escape a string for JSON. */
private fun String.json(): String =
buildString {
append('"')
for (ch in this@json) {
when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(ch)
}
}
append('"')
}
WorkManager와 동기화 스케줄링
SyncScheduler
import android.content.Context
import androidx.work.*
class SyncScheduler(private val context: Context) {
fun kick() = enqueueUnique("vault_sync_now")
fun refreshSoon() = enqueueUnique("vault_sync_refresh")
private fun enqueueUnique(name: String) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = OneTimeWorkRequestBuilder<DispatchSyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
30_000L,
java.util.concurrent.TimeUnit.MILLISECONDS
)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
name,
ExistingWorkPolicy.KEEP,
request
)
}
}
작업자: 아웃박스 푸시 → 델타 풀 → Room 업데이트
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.Result
import kotlinx.coroutines.delay
class DispatchSyncWorker(
appContext: Context,
params: WorkerParameters,
private val db: VaultDb,
private val api: CourierApi,
private val syncTokenStore: SyncTokenStore,
private val clock: () -> Long
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
// Helps with flappy “connected for 1 second” networks
delay(1500)
val dao = db.clipDao()
return try {
// 1) PUSH pending ops in order
val outbox = dao.loadOutboxOrdered()
for (op in outbox) {
when (op.kind) {
OpKind.UPSERT -> {
val dto = op.payloadJson!!.toClipDto()
val saved = api.upsertClip(dto.copy(serverUpdatedAt = 0L))
// Update local entity as SYNCED
dao.upsertClip(
ClipEntity(
clipId = saved.clipId,
title = saved.title,
body = saved.body,
localUpdatedAt = clock(),
serverUpdatedAt = saved.serverUpdatedAt,
cachedAt = clock(),
lastOpenedAt = clock(),
syncFlag = SyncFlag.SYNCED
)
)
}
OpKind.DELETE -> {
api.deleteClip(op.clipId)
// Optionally delete locally or keep tombstone
}
}
// Mark attempt
dao.updateOutboxAfterAttempt(op.opId, clock())
}
// 2) PULL deltas since last token
val token = syncTokenStore.readToken()
val response = api.pullChanges(token)
// Apply incoming upserts
response.clips.forEach { dto ->
dao.upsertClip(
ClipEntity(
clipId = dto.clipId,
title = dto.title,
body = dto.body,
localUpdatedAt = clock(),
serverUpdatedAt = dto.serverUpdatedAt,
cachedAt = clock(),
lastOpenedAt = clock(),
syncFlag = SyncFlag.SYNCED
)
)
}
// Apply deletions safely (don’t delete pending/conflicted rows)
if (response.deletedIds.isNotEmpty()) {
dao.deleteClipsIfSafe(response.deletedIds)
}
// Store new sync token
syncTokenStore.writeToken(response.newSyncToken)
Result.success()
} catch (e: Exception) {
// Backoff will be handled by WorkManager's policy
Result.retry()
}
}
}
/* Helper extensions – replace with proper serialization in production */
private fun String.toClipDto(): ClipDto {
val id = Regex(""""clipId"\s*:\s*"([^"]+)"""").find(this)?.groupValues?.get(1)
?: UUID.randomUUID().toString()
val title = Regex(""""title"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
val body = Regex(""""body"\s*:\s*"([^"]*)"""").find(this)?.groupValues?.get(1) ?: ""
return ClipDto(id, title, body, 0L)
}
interface SyncTokenStore {
fun readToken(): String?
fun writeToken(token: String)
}
UI 연결 (ViewModel)
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class VaultViewModel(private val repo: VaultRepository) : ViewModel() {
val clips = repo.observeClips()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun onSave(title: String, body: String) = viewModelScope.launch {
repo.saveClip(title, body)
}
fun onDelete(id: String) = viewModelScope.launch {
repo.removeClip(id)
}
}
동기화 순서 (엔드‑투‑엔드)
실용적인 가드레일
- 연결 상태 변경만으로 동기화를 트리거하지 마세요; backoff을 사용하고 폭풍을 피하세요.
- 쓰기 작업을 idempotent하게 유지하세요 (클라이언트 UUID + 서버 측 중복 제거).
- 정리 중에 PENDING 또는 CONFLICTED 데이터를 절대 삭제하지 마세요.
- 삭제의 경우, 신뢰할 수 있는 기기 간 일관성이 필요하면 tombstones를 고려하세요.
