|

Offline-first Android 앱 구조: Room, Flow, WorkManager는 어떻게 역할을 나눌까

Offline-first Android 앱 구조: Room, Flow, WorkManager는 어떻게 역할을 나눌까
오프라인 우선 구조에서는 화면이 네트워크보다 로컬 데이터베이스를 먼저 바라본다

Offline-first Android 앱 구조의 핵심은 네트워크가 아니라 로컬 데이터를 먼저 믿는 것입니다. 화면은 서버 응답을 직접 기다리기보다 Room에 저장된 데이터를 Flow로 관찰하고, 동기화는 WorkManager가 뒤에서 처리하는 식으로 역할을 나누는 편이 안정적입니다.

이 글은 Room, Flow, WorkManager를 한꺼번에 쓰자는 말이 아닙니다. 각각이 맡아야 할 책임을 분리해서 오프라인, 느린 네트워크, 재시도, 화면 재진입 상황에서도 덜 흔들리는 구조를 잡는 것이 목표입니다.

Offline-first Android에서 Room Flow WorkManager 역할 분리 카드 이미지
오프라인 우선 구조는 Room, Flow, Repository, WorkManager의 역할을 분리해야 안정적이다

Offline-first Android 구조를 한 장으로 보기

오프라인 우선 구조에서 화면은 서버를 직접 바라보지 않습니다. 화면은 ViewModel의 uiState를 보고, ViewModel은 Repository를 통해 로컬 데이터 소스를 관찰합니다. 서버 통신은 Repository나 동기화 계층에서 처리하고, 결과는 다시 로컬 DB에 저장됩니다.

  1. UI는 ViewModel의 StateFlow 또는 UI state를 구독한다
  2. ViewModel은 Repository에서 Flow를 받는다
  3. Repository는 Room DAO의 Flow를 노출한다
  4. 네트워크 결과는 Room에 저장한다
  5. 동기화가 길거나 재시도가 필요하면 WorkManager가 맡는다

이 구조의 장점은 화면이 네트워크 성공 여부에 직접 묶이지 않는다는 점입니다. 서버가 느려도 마지막으로 저장된 데이터를 보여줄 수 있고, 동기화가 끝나면 Room 변경을 통해 화면이 자연스럽게 갱신됩니다. 화면 상태 저장 경계는 rememberSaveable과 SavedStateHandle 차이 글과 함께 보면 더 잘 이어집니다.


Room의 역할: source of truth

Android 공식 offline-first 문서는 로컬 데이터 소스를 canonical source of truth로 두는 방향을 설명합니다. 여기서 Room은 단순 캐시가 아니라 화면이 믿고 읽는 기준점입니다.

예를 들어 할 일 목록 화면이 있다면 화면은 API 응답을 직접 표시하지 않습니다. Room의 todos 테이블을 Flow로 관찰하고, 서버에서 새 데이터를 받으면 Room을 갱신합니다. 그러면 화면은 같은 경로로 최신 값을 받습니다.

@Dao
interface TodoDao {
    @Query("SELECT * FROM todos ORDER BY updatedAt DESC")
    fun observeTodos(): Flow<List<TodoEntity>>

    @Upsert
    suspend fun upsertAll(items: List<TodoEntity>)
}

이렇게 하면 UI 입장에서는 데이터가 네트워크에서 왔는지, 로컬에 있던 값인지 크게 중요하지 않습니다. 항상 같은 Flow를 보면 됩니다.


Repository의 역할: 로컬과 원격 사이의 경계

Repository는 Room과 API를 그냥 묶어 놓는 클래스가 아닙니다. 화면이 알아야 할 데이터 흐름과 화면이 몰라도 되는 동기화 세부사항을 나누는 경계입니다.

class TodoRepository(
    private val dao: TodoDao,
    private val api: TodoApi
) {
    fun observeTodos(): Flow<List<Todo>> {
        return dao.observeTodos()
            .map { entities -> entities.map { it.toDomain() } }
    }

    suspend fun refresh() {
        val remote = api.fetchTodos()
        dao.upsertAll(remote.map { it.toEntity() })
    }
}

간단한 refresh는 Repository의 suspend 함수로 충분할 수 있습니다. 하지만 앱이 종료되어도 이어져야 하거나, 네트워크 조건을 기다려야 하거나, 실패 후 재시도가 필요하면 WorkManager 쪽으로 넘기는 편이 좋습니다.


WorkManager의 역할: 오래 가는 동기화와 재시도

WorkManager는 화면이 살아 있는 동안 잠깐 실행하는 비동기 작업을 위한 도구가 아닙니다. Android 공식 WorkManager 문서는 WorkManager를 앱이 보이지 않거나 재시작된 뒤에도 이어져야 하는 persistent work에 권장합니다.

오프라인 우선 앱에서는 사용자가 오프라인에서 만든 변경을 큐에 쌓아 두고, 네트워크가 가능할 때 서버에 보내야 할 수 있습니다. 이때 WorkManager의 constraints, retry, backoff가 의미를 가집니다.

val request = OneTimeWorkRequestBuilder<SyncTodosWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        30,
        TimeUnit.SECONDS
    )
    .build()

WorkManager.getInstance(context).enqueueUniqueWork(
    "sync-todos",
    ExistingWorkPolicy.KEEP,
    request
)

실무에서 흔한 실수

  • UI에서 API를 직접 호출하고 Room은 나중에 저장소처럼만 쓴다
  • Room을 캐시라고 부르면서도 실제로는 화면의 기준점으로 쓰지 않는다
  • 모든 네트워크 호출을 WorkManager로 보내서 사용자 액션의 즉시성을 잃는다
  • 동기화 실패 상태를 사용자에게 설명하지 않고 조용히 삼킨다
  • 서버와 로컬 충돌 정책을 정하지 않은 채 upsert만 반복한다

특히 충돌 정책은 뒤로 미루면 나중에 더 어려워집니다. 최신 시간이 이기는지, 서버가 이기는지, 사용자의 로컬 수정이 우선인지 도메인별로 기준이 필요합니다.


마무리

Offline-first Android 구조는 기술 스택 이름보다 책임 분리가 중요합니다. Room은 화면이 믿는 로컬 source of truth, Flow는 변경 전파, Repository는 경계, WorkManager는 오래 가는 동기화와 재시도를 맡습니다.

이 역할을 분리하면 네트워크가 불안정한 상황에서도 화면 구조가 단순해집니다. UI는 ‘서버가 지금 성공했는가’보다 ‘현재 앱이 보여줄 수 있는 신뢰 가능한 로컬 상태가 무엇인가’를 기준으로 움직이면 됩니다.

함께보면 좋은 글