Offline-First Android: 네트워크가 없을 때도 계속 작동하는 앱 만들기

발행: (2025년 12월 27일 오후 06:39 GMT+9)
10 min read
원문: Dev.to

Source: Dev.to

위 링크에 있는 전체 텍스트를 제공해 주시면, 해당 내용을 한국어로 번역해 드리겠습니다. 현재는 번역할 본문이 없으므로, 번역이 필요한 텍스트를 복사해서 알려 주세요.

소개

모바일 연결은 “좋다” 혹은 “나쁘다”가 아니라 불규칙합니다. 쇼핑몰, 지하철 터널, 붐비는 공항, 국립공원 캠프장, 그리고 긴 시골 고속도로 구간… 사용자는 모두 그 안에 살아갑니다.

앱의 주요 흐름이 **“API 호출 → UI 렌더링”**이라면, 모든 타임아웃은 다음과 같이 나타납니다:

  • 스피너,
  • 재시도 버튼,
  • 그리고 사용자의 신뢰 상실.

오프라인‑퍼스트는 계약을 바꿉니다: 네트워크가 없더라도 UI는 기능을 유지합니다. 네트워크는 기본 UX를 위한 의존성이 아니라 동기화 채널이 됩니다.

이번 포스트에서는 배포 가능한 오프라인‑퍼스트 구조를 구축합니다:

  • UI 진실 원본으로 Room
  • stale‑while‑revalidate (SWR) 로 읽기
  • Outbox pattern 으로 쓰기
  • WorkManager 로 백그라운드 동기화

정신 모델

온라인‑우선 (취약)

UI loads data by calling the server; if the network fails, the screen fails.

API가 실패하면 화면도 실패합니다.

오프라인‑우선 (탄력적)

UI reads from Room instantly; WorkManager syncs with the server in the background.

  • Room은 UI가 신뢰하는 것입니다.
  • 네트워크는 결국 로컬 상태를 서버와 일치시키는 역할을 합니다.

핵심 패턴: SWR + Outbox

1️⃣ Reads – Stale‑While‑Revalidate (SWR)

  • UI가 Room에서 즉시 렌더링됩니다.
  • Repository가 데이터가 “충분히 오래됐는지”를 판단해 새로 고침 여부를 결정합니다.
  • 새로 고침은 백그라운드에서 실행됩니다.
  • Room이 업데이트되면 UI도 업데이트됩니다 (왜냐하면 Flow를 수집하고 있기 때문입니다).

2️⃣ Writes – Outbox pattern

  1. 사용자 편집 내용을 즉시 Room에 저장하고 레코드를 PENDING 상태로 표시합니다.
  2. outbox 테이블에 작업을 추가합니다.
  3. WorkManager가 연결이 가능할 때 업로드합니다.
  4. 성공하면 SYNCED 로 표시합니다.
  5. 충돌이 발생하면 CONFLICTED 로 표시합니다 (또는 직접 정의한 병합 규칙을 적용합니다).

데이터 모델: 도메인 + 동기화 메타데이터

우리는 아래와 같이 다양한 이름을 사용할 것입니다:

SymbolMeaning
Clip사용자가 보고/편집하는 항목
VaultDbRoom 데이터베이스
CourierApiRetrofit API
DispatchSyncWorkerWorkManager 워커

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를 고려하세요.
Back to Blog

관련 글

더 보기 »

Android 권한 완벽 가이드

markdown !dss99911https://media2.dev.to/dynamic/image/width=50,height=50,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuplo...