오프라인 우선 앱 빌드

오프라인 우선 앱은 인터넷 연결이 없어도 앱의 모든 기능 또는 중요한 핵심 기능을 실행할 수 있는 앱입니다. 즉, 비즈니스 로직의 일부 또는 전부를 오프라인으로 실행할 수 있습니다.

오프라인 우선 앱을 빌드할 때는 애플리케이션 데이터와 비즈니스 로직에 대한 액세스를 제공하는 데이터 레이어를 고려해야 합니다. 앱은 경우에 따라 기기 외부에 있는 소스의 이 데이터를 새로고침해야 할 수 있습니다. 이 과정에서 최신 정보를 가져오려면 네트워크 리소스를 호출해야 할 수 있습니다.

네트워크 가용성이 항상 보장되는 것은 아닙니다. 기기의 네트워크 연결이 불안정하거나 느린 경우가 종종 발생합니다. 사용자는 다음과 같은 현상을 경험할 수 있습니다.

  • 인터넷 대역폭 제한
  • 일시적인 연결 장애(예: 엘리베이터에 탑승하거나 터널을 지나갈 때)
  • 비정기적인 데이터 액세스(예: Wi-Fi 전용 태블릿)

앱은 이러한 상황에서 각종 이유로 인해 부적절하게 작동할 수 있습니다. 앱이 오프라인에서 올바르게 작동하려면 다음을 할 수 있어야 합니다.

  • 안정적인 네트워크 연결 없이도 사용 가능한 상태를 유지합니다.
  • 첫 번째 네트워크 호출이 완료되거나 실패할 때까지 기다리는 대신 사용자에게 즉시 로컬 데이터를 제공합니다.
  • 배터리 및 데이터 상태를 염두에 두고 데이터를 가져옵니다. 예를 들어, 충전 중이거나 Wi-Fi를 사용하고 있는 경우와 같이 최적의 조건에서만 데이터 가져오기를 요청할 수 있습니다.

위 기준을 충족하는 앱을 오프라인 우선 앱이라고 합니다.

오프라인 우선 앱 설계하기

오프라인 우선 앱을 설계할 때는 데이터 레이어와 앱 데이터를 대상으로 실행할 수 있는 다음 2가지 기본 연산에서 시작해야 합니다.

  • 읽기: 앱의 다른 부분에서 사용할(예: 사용자에게 정보 표시) 데이터를 가져옵니다.
  • 쓰기: 나중에 가져올 수 있도록 사용자 입력을 유지합니다.

데이터 레이어의 저장소는 여러 데이터 소스를 결합하여 앱 데이터를 제공하는 일을 담당합니다. 오프라인 우선 앱에는 가장 중요한 작업을 실행하는 데 네트워크 액세스가 필요하지 않은 데이터 소스가 하나 이상 있어야 합니다. 여기서 말하는 중요한 작업 중 하나로 데이터 읽기를 들 수 있습니다.

오프라인 우선 앱의 모델 데이터

오프라인 우선 앱은 네트워크 리소스를 사용하는 모든 저장소 하나당 최소한 다음과 같은 2개의 데이터 소스를 갖습니다.

  • 로컬 데이터 소스
  • 네트워크 데이터 소스
오프라인 우선 데이터 레이어는 로컬 데이터 소스와 네트워크 데이터 소스로 구성됩니다.
그림 1: 오프라인 우선 저장소

로컬 데이터 소스

로컬 데이터 소스는 앱의 표준 정보 소스입니다. 로컬 데이터 소스는 앱의 상위 레이어가 읽는 모든 데이터의 배타적인 소스여야 합니다. 이에 따라 여러 연결 상태 간의 데이터 일관성이 보장됩니다. 로컬 데이터 소스는 디스크에 유지되는 스토리지로 뒷받침되는 경우가 많습니다. 데이터를 디스크에 유지하는 몇 가지 방법은 다음과 같습니다.

  • 구조화된 데이터 소스. 예: Room과 같은 관계형 데이터베이스
  • 구조화되지 않은 데이터 소스. 예: Datastore가 있는 프로토콜 버퍼
  • 단순한 파일

네트워크 데이터 소스

네트워크 데이터 소스는 애플리케이션의 실제 상태입니다. 로컬 데이터 소스는 기껏해야 네트워크 데이터 소스와 동기화되는 것이 다입니다. 네트워크 데이터 소스보다 뒤처지는 경우도 있는데 그러면 다시 온라인 상태가 되었을 때 앱을 업데이트해야 합니다. 반대로 네트워크 데이터 소스도 연결이 복원되어 앱이 업데이트될 수 있을 때까지 로컬 데이터 소스보다 뒤처질 수 있습니다. 앱의 도메인 레이어와 UI 레이어는 네트워크 레이어와 직접 연결되어서는 안 됩니다. 도메인 레이어 및 UI 레이어와 통신하고 이를 사용하여 로컬 데이터 소스를 업데이트하는 일은 호스팅하는 repository가 담당합니다.

리소스 노출하기

앱이 로컬 데이터 소스와 네트워크 데이터 소스를 읽고 쓰는 방식은 두 데이터 소스 간에 크게 달라질 수 있습니다. 로컬 데이터 소스를 쿼리하는 일은 SQL 쿼리를 사용할 때와 같이 빠르고 유연할 수 있습니다. 반대로 네트워크 데이터 소스는 ID를 사용하여 RESTful 리소스에 증분식으로 액세스할 때와 같이 느리고 제한될 수 있습니다. 따라서 각 데이터 소스는 데이터 소스가 제공하는 데이터를 자체적으로 나타내야 하는 경우가 많습니다. 그러므로 로컬 데이터 소스와 네트워크 데이터 소스는 자체 모델을 가질 수 있습니다.

아래의 디렉터리 구조에서는 이 개념을 시각화하여 보여줍니다. AuthorEntity는 앱의 로컬 데이터베이스에서 읽어 들인 작성자를 나타내고, NetworkAuthor는 네트워크를 통해 직렬화된 작성자를 나타냅니다.

data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/

AuthorEntityNetworkAuthor의 세부정보는 다음과 같습니다.

/**
 * Network representation of [Author]
 */
@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

/**
 * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
 * It has a many-to-many relationship with both entities
 */
@Entity(tableName = "authors")
data class AuthorEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    @ColumnInfo(name = "image_url")
    val imageUrl: String,
    @ColumnInfo(defaultValue = "")
    val twitter: String,
    @ColumnInfo(name = "medium_page", defaultValue = "")
    val mediumPage: String,
    @ColumnInfo(defaultValue = "")
    val bio: String,
)

AuthorEntityNetworkAuthor를 둘 다 데이터 레이어 내부에 유지하고 외부 레이어가 사용할 수 있도록 다른 유형을 노출하는 것이 좋습니다. 이렇게 하면 앱의 동작을 근본적으로 바꾸지 않는 로컬 데이터 소스와 네트워크 데이터 소스의 사소한 변경사항으로부터 외부 레이어를 보호할 수 있습니다. 이는 다음 스니펫에 나와 있습니다.

/**
 * External data layer representation of a "Now in Android" Author
 */
data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

다음과 같이 네트워크 모델은 자신을 로컬 모델로 변환하는 확장 메서드를 정의할 수 있고, 마찬가지로 로컬 모델도 자신을 외부 모델로 변환하는 확장 메서드를 정의할 수 있습니다.

/**
 * Converts the network model to the local model for persisting
 * by the local data source
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * Converts the local model to the external model for use
 * by layers external to the data layer
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

읽기

읽기는 오프라인 우선 앱에 반드시 필요한 앱 데이터를 대상으로 하는 기본적인 연산입니다. 따라서 앱이 데이터를 읽을 수 있고 새로운 데이터가 확인되는 즉시 표시할 수 있도록 해야 합니다. 이게 가능한 앱은 관찰 가능한 유형을 사용하여 읽기 API를 노출하므로 반응형 이라고 합니다.

아래 스니펫에서 OfflineFirstTopicRepository는 모든 읽기 API에 Flows를 반환합니다. 이렇게 하면 네트워크 데이터 소스에서 업데이트를 수신한 경우 리더를 업데이트할 수 있습니다. 즉, 로컬 데이터 소스가 무효화될 경우 OfflineFirstTopicRepository가 변경사항을 푸시할 수 있도록 합니다. 따라서 OfflineFirstTopicRepository의 각 리더는 앱에서 네트워크 연결이 복원되면 트리거될 수 있는 데이터 변경사항을 처리할 수 있어야 합니다. 여기에 더해, OfflineFirstTopicRepository는 로컬 데이터 소스에서 직접 데이터를 읽으므로 먼저 로컬 데이터 소스를 업데이트해야 리더에 변경사항을 알릴 수 있습니다.

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) }
}

오류 처리 전략

오프라인 우선 앱에서 발생하는 오류는 오류가 발생한 데이터 소스에 따라 처리 방식이 달라집니다. 이어지는 하위 섹션에서는 이러한 전략을 설명합니다.

로컬 데이터 소스

로컬 데이터 소스에서 읽기가 이루어지는 동안에는 오류가 발생하는 경우가 드뭅니다. 리더를 오류로부터 보호할 수 있도록, 리더가 데이터를 수집하는 대상이 되는 Flows에서 catch 연산자를 사용합니다.

다음은 ViewModel에서 catch 연산자를 사용하는 예시를 보여줍니다.

class AuthorViewModel(
    authorsRepository: AuthorsRepository,
    ...
) : ViewModel() {
   private val authorId: String = ...

   // Observe author information
    private val authorStream: Flow<Author> =
        authorsRepository.getAuthorStream(
            id = authorId
        )
        .catch { emit(Author.empty()) }
}

네트워크 데이터 소스

네트워크 데이터 소스에서 데이터를 읽을 때 오류가 발생할 경우 앱은 휴리스틱을 사용하여 데이터 가져오기를 다시 시도해야 합니다. 자주 사용되는 휴리스틱은 다음과 같습니다.

지수 백오프

지수 백오프에서는 앱이 성공할 때까지 또는 그 밖의 조건으로 인해 중지해야 할 때까지 시간 간격을 늘려가며 네트워크 데이터 소스에서의 읽기를 계속 시도합니다.

지수 백오프로 데이터 읽기
그림 2: 지수 백오프로 데이터 읽기

앱이 계속해서 백오프해야 하는지 판단하는 조건:

  • 네트워크 데이터 소스가 나타낸 오류의 유형. 예를 들어, 연결이 없음을 나타내는 오류의 경우에는 오류를 반환하는 네트워크 호출을 다시 시도해야 합니다. 반면에 올바른 사용자 인증 정보가 제공되기 전까지는 승인되지 않는 HTTP 요청은 다시 시도해서는 안 됩니다.
  • 허용되는 최대 재시도 횟수.
네트워크 연결 모니터링

네트워크 연결 모니터링에서는 앱이 네트워크 데이터 소스에 연결할 수 있음을 확실히 알게 될 때까지 읽기 요청이 큐에 추가됩니다. 연결이 설정되면 읽기 요청이 큐에서 제거되고 데이터 읽기와 로컬 데이터 소스가 업데이트됩니다. Android에서는 이 큐를 Room 데이터베이스를 사용하여 유지하고 WorkManager를 사용하여 지속적인 작업으로 해제할 수 있습니다.

네트워크 모니터링과 큐를 사용한 데이터 읽기
그림 3: 네트워크 모니터링을 사용하여 큐 읽기

쓰기

오프라인 우선 앱에서 데이터를 읽는 권장되는 방법은 관찰 가능한 유형을 사용하는 것이지만, 이러한 읽기 방식에 대응되는 쓰기 API는 정지 함수 등과 같은 비동기 API입니다. 오프라인 우선 앱의 쓰기는 네트워크 경계를 통과할 때 실패할 수 있으므로 비동기 API를 사용하면 UI 스레드가 차단되지 않고 오류 처리에 도움이 됩니다.

interface UserDataRepository {
    /**
     * Updates the bookmarked status for a news resource
     */
    suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

위 스니펫에서는 위 메서드가 정지되면 코루틴되는 비동기 API가 사용되었습니다.

쓰기 전략

오프라인 우선 앱에서 데이터를 쓸 때는 세 가지 전략을 고려할 수 있습니다. 어느 전략을 선택할지는 쓰려는 데이터의 유형과 앱의 요구사항에 따라 달라집니다.

온라인 전용 쓰기

네트워크 경계를 넘어 데이터 쓰기를 시도합니다. 성공하면 로컬 데이터 소스를 업데이트하고 성공하지 않으면 예외를 발생시켜 호출자가 적절하게 응답하도록 합니다.

온라인 전용 쓰기
그림 4: 온라인 전용 쓰기

이 전략은 주로 온라인에서 실시간에 가깝게 이루어져야 하는 쓰기 트랜잭션에 사용됩니다. 은행 송금을 예로 들 수 있습니다. 쓰기가 실패할 수 있으므로 사용자에게 쓰기가 실패했음을 알리거나 사용자가 데이터 쓰기를 시도하는 것을 애초에 방지하는 것이 필요할 수 있습니다. 이 시나리오에서는 다음과 같은 전략을 사용할 수 있습니다.

  • 앱에서 데이터 쓰려면 인터넷 액세스가 필요한 경우 사용자에게 데이터 쓰기 UI를 표시하지 않거나 데이터 쓰기를 비활성화할 수 있습니다.
  • 사용자가 닫을 수 없는 팝업 메시지나 일시적인 메시지를 사용하여 사용자에게 현재 오프라인 상태임을 알릴 수 있습니다.

큐에 추가된 쓰기

쓰려는 객체를 큐에 삽입합니다. 앱이 다시 온라인 상태가 되면 지수 백오프를 사용하여 큐에서 해제합니다. Android에서 오프라인 큐를 해제하는 것은 종종 WorkManager에 위임되는 지속적인 작업입니다.

재시도를 사용하여 큐 쓰기
그림 5: 재시도를 사용하여 큐 쓰기

이 방법은 다음과 같은 경우에 적합합니다.

  • 데이터를 네트워크에 쓰는 것이 중요하지 않습니다.
  • 트랜잭션이 시간에 민감하지 않습니다.
  • 쓰기가 실패할 경우 사용자에게 알리는 것이 중요하지 않습니다.

이 방법의 사용 사례로는 분석 이벤트와 로깅을 들 수 있습니다.

지연 쓰기

먼저 로컬 데이터 소스에 쓴 다음, 가능한 가장 이른 시점에 네트워크에 알릴 수 있도록 쓰기를 큐에 추가합니다. 이 경우 앱이 다시 온라인 상태가 되면 네트워크와 로컬 데이터 소스 간에 충돌이 발생할 수 있으므로 그리 간단한 작업이 아닙니다. 충돌 해결에 관한 다음 섹션에서 더 자세히 알아보세요.

네트워크 모니터링을 사용하는 지연 쓰기
그림 6: 지연 쓰기

이 방법은 쓰기된 데이터가 앱에 중요한 경우에 사용해야 합니다. 예를 들어, 오프라인 우선 할 일 목록 앱에서는 데이터 손실을 방지하려면 사용자가 오프라인 상태에서 추가하는 모든 작업이 로컬에 저장되어야 합니다.

동기화 및 충돌 해결

오프라인 우선 앱의 연결이 복원되면 로컬 데이터 소스의 데이터와 네트워크 데이터 소스의 데이터가 조정되어야 합니다. 이 프로세스를 동기화라고 합니다. 앱이 네트워크 데이터 소스와 데이터를 동기화하는 방법에는 두 가지가 있습니다.

  • 풀 기반 동기화
  • 푸시 기반 동기화

풀 기반 동기화

풀 기반 동기화에서 앱은 네트워크에 접속하여 최신 애플리케이션 데이터를 주문형으로 읽습니다. 이 방법에서 자주 사용되는 휴리스틱은 앱이 사용자에게 데이터를 표시하기 직전에 데이터를 가져오는 탐색 기반입니다.

이 방법은 앱에서 네트워크 연결이 없는 기간이 짧거나 중간 정도 될 것으로 예상되는 경우에 가장 적합합니다. 데이터 새로고침은 편의적이고, 연결이 없는 기간이 길면 사용자가 오래되었거나 비어 있는 캐시를 사용하여 앱 도착 페이지를 방문하려고 시도할 가능성이 커지기 때문입니다.

풀 기반 동기화
그림 7: 풀 기반 동기화: 기기 A는 화면 A와 B의 리소스에만 액세스하고, 기기 B는 화면 B, C, D의 리소스에만 액세스함

특정 화면의 무한 스크롤 목록을 구성하는 항목을 가져오기 위해 페이지 토큰을 사용하는 앱을 생각해 보겠습니다. 이 구현은 네트워크에 느리게 접속하고 데이터를 로컬 데이터 소스에 유지한 다음 로컬 데이터 소스에서 정보를 읽어서 사용자에게 표시할 수 있습니다. 이때 네트워크 연결이 없으면 저장소가 로컬 데이터 소스에서만 데이터를 요청할 수 있습니다. 이는 Jetpack Paging 라이브러리RemoteMediator API를 사용할 때 사용하는 패턴입니다.

class FeedRepository(...) {

    fun feedPagingSource(): PagingSource<FeedItem> { ... }
}

class FeedViewModel(
    private val repository: FeedRepository
) : ViewModel() {
    private val pager = Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = FeedRemoteMediator(...),
        pagingSourceFactory = feedRepository::feedPagingSource
    )

    val feedPagingData = pager.flow
}

아래 표에는 풀 기반 동기화의 장점과 단점이 정리되어 있습니다.

장점 단점
비교적 쉽게 구현할 수 있습니다. 데이터 사용량이 높아지는 경향이 있습니다. 탐색 도착 페이지를 반복적으로 방문하면 변경되지 않은 정보의 불필요한 반복 가져오기가 트리거되기 때문입니다. 올바른 캐싱을 사용하면 이 현상을 줄일 수 있습니다. UI 레이어에서 cachedIn 연산자를 사용하거나 네트워크 레이어에서 HTTP 캐시를 사용하면 됩니다.
필요하지 않은 데이터를 가져오는 일이 없습니다. 풀링된 모델은 그 자체로 충분해야 하기 때문에 관계형 데이터와 함께 사용하기에 적합하지 않습니다. 동기화되는 모델이 데이터를 채우려면 다른 모델을 가져와야 하는 경우, 앞에서 언급한 높은 데이터 사용량 문제가 훨씬 더 심각해질 수 있습니다. 이에 더해 상위 모델의 저장소와 중첩된 모델의 저장소 사이에 종속 관계가 발생할 수 있습니다.

푸시 기반 동기화

푸시 기반 동기화에서 로컬 데이터 소스는 할 수 있는 한 네트워크 데이터 소스의 복제본을 모방하려고 시도합니다. 푸시 기반 동기화가 처음 시작되면 적절한 양의 데이터를 가져와서 기준선을 설정한 다음 이후부터는 서버의 알림을 바탕으로 어느 데이터가 오래되었는지 확인합니다.

푸시 기반 동기화
그림 8: 푸시 기반 동기화: 데이터가 변경되면 네트워크가 앱에 이 사실을 알리고 앱은 이에 대한 응답으로서 변경된 데이터를 가져옴

앱은 오래된 데이터 알림을 수신하면 네트워크에 접속하여 오래된 것으로 표시된 데이터만 업데이트합니다. 이 작업은 Repository에 위임됩니다. 저장소는 네트워크 데이터 소스에 접속하고 가져온 데이터를 로컬 데이터 소스에 유지합니다. 저장소는 관찰 가능한 유형으로 데이터를 노출하므로 리더는 변경사항이 있을 경우 알림을 받게 됩니다.

class UserDataRepository(...) {

    suspend fun synchronize() {
        val userData = networkDataSource.fetchUserData()
        localDataSource.saveUserData(userData)
    }
}

이 방법에서는 앱이 네트워크 데이터 소스에 의존하는 정도가 훨씬 적으므로 오랜 시간 동안 네트워크 데이터 소스 없이도 작동할 수 있습니다. 이 방법에서는 로컬에 최신 네트워크 데이터 소스의 정보가 있다고 가정되기 때문에 오프라인 상태에서 읽기 액세스와 쓰기 액세스가 모두 지원됩니다.

아래 표에는 푸시 기반 동기화의 장점과 단점이 정리되어 있습니다.

장점 단점
앱이 오프라인 상태로 무기한 유지될 수 있습니다. 충돌 해결을 위한 데이터 버전 관리가 간단한 작업이 아닙니다.
데이터 사용량이 최소화됩니다. 앱이 변경된 데이터만 가져옵니다. 동기화 중에 쓰기 문제를 고려해야 합니다.
관계형 데이터와 함께 사용하기에 적합합니다. 각 저장소는 저장소가 지원하는 모델의 데이터를 가져오는 일만 담당합니다. 네트워크 데이터 소스가 동기화를 지원해야 합니다.

하이브리드 동기화

데이터에 따라 풀 또는 푸시 기반 방법을 사용하는 하이브리드 접근 방식을 사용하는 앱도 있습니다. 예를 들어, 소셜 미디어 앱은 피드가 업데이트되는 빈도가 높기 때문에 사용자의 팔로잉 피드를 주문형으로 가져오기 위해 풀 기반 동기화를 사용할 수 있습니다. 또한 로그인한 사용자에 관한 데이터(사용자 이름, 프로필 사진 등)를 가져올 때는 푸시 기반 동기화를 사용할 수 있습니다.

오프라인 우선 동기화에서 어느 방식을 사용할 것인지는 제품 요구사항과 사용 가능한 기술 인프라에 따라 달라집니다.

충돌 해결

앱이 오프라인 상태에서 네트워크 데이터 소스와 다른 데이터를 로컬로 쓸 경우 충돌이 발생합니다. 동기화가 이루어지려면 먼저 충돌을 해결해야 합니다.

충돌을 해결하려면 버전 관리가 필요한 경우가 많습니다. 앱이 이제까지 발생한 변경사항을 추적하려면 기록을 유지하고 살펴보아야 합니다. 이를 바탕으로 네트워크 데이터 소스에 메타데이터를 전달할 수 있습니다. 이 시점에서 네트워크 데이터 소스는 절대적인 정보 소스를 제공할 책임을 갖습니다. 애플리케이션의 요구사항에 따라 충돌 해결을 위해 고려할 수 있는 다양한 전략이 있습니다. 모바일 앱의 일반적인 접근 방식은 '마지막 쓰기 적용'입니다.

마지막 쓰기 적용

이 방법에서는 기기가 네트워크에 쓴 데이터에 타임스탬프 메타데이터를 추가합니다. 네트워크 데이터 소스가 데이터를 수신하면 현재 상태보다 오래된 데이터는 삭제하는 동시에 현재 상태보다 최신 데이터는 받습니다.

마지막 쓰기 적용 충돌 해결
그림 9 : '마지막 쓰기 적용': 데이터의 정보 소스는 데이터를 쓴 마지막 항목에 의해 결정됨

위 그림에서는 두 기기 모두 오프라인 상태이며 처음에는 네트워크 데이터 소스와 동기화되어 있습니다. 두 기기 모두 오프라인 상태에서 로컬에 데이터를 쓰고 데이터를 쓴 시간을 기록합니다. 둘 다 온라인으로 전환되어 네트워크 데이터 소스와 동기화되면, 네트워크는 기기 B가 데이터를 나중에 썼으므로 기기 B의 데이터를 유지하여 충돌을 해결합니다.

오프라인 우선 앱의 WorkManager

위에서 설명한 읽기 및 쓰기 전략에서 공통적으로 사용된 두 가지 유틸리티가 있습니다.

    • 읽기: 네트워크 연결을 사용할 수 있을 때까지 읽기를 지연하는 데 사용됩니다.
    • 쓰기: 네트워크 연결을 사용할 수 있을 때까지 쓰기를 지연하고 재시도를 위해 쓰기를 큐에 다시 추가하는 데 사용됩니다.
  • 네트워크 연결 모니터
    • 읽기: 앱이 연결되어 있을 때 또는 동기화를 위해 읽기 큐를 해제하라는 신호로 사용됩니다.
    • 쓰기: 앱이 연결되어 있을 때 또는 동기화를 위해 쓰기 큐를 해제하라는 신호로 사용됩니다.

두 가지 경우 모두 WorkManager가 잘하는 지속적인 작업의 예입니다. 예를 들어, Now in Android 샘플 앱에서 WorkManager는 로컬 데이터 소스를 동기화할 때 읽기 큐이자 네트워크 모니터로 사용됩니다. 앱은 시작되는 시점에 다음 작업을 실행합니다.

  1. 로컬 데이터 소스와 네트워크 데이터 소스 간의 패리티를 위해 읽기 동기화 작업을 큐에 추가합니다.
  2. 앱이 온라인 상태가 되면 읽기 동기화 큐를 해제하고 동기화를 시작합니다.
  3. 지수 백오프를 사용하여 네트워크 데이터 소스에서의 읽기를 실행합니다.
  4. 발생한 충돌을 해결하고 읽기의 결과를 로컬 데이터 소스에 유지합니다.
  5. 앱의 다른 레이어가 사용할 수 있도록 로컬 데이터 소스의 데이터를 노출합니다.

아래 다이어그램에는 위 내용이 정리되어 있습니다.

Now in Android 앱의 데이터 동기화
그림 10: Now in Android 앱의 데이터 동기화

WorkManager를 사용하여 동기화 작업이 큐에 추가되면 이어서 KEEP ExistingWorkPolicy를 사용하여 고유 작업으로 지정됩니다.

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // Queue sync on app startup and ensure only one
           // sync worker runs at any time
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

여기서 SyncWorker.startupSyncWork()는 다음과 같이 정의됩니다.


/**
 Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
 This allows for dependency injection into the SyncWorker in a different
 module than the app module without having to create a custom WorkManager
 configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
    // Run sync as expedited work if the app is able to.
    // If not, it runs as regular work.
   .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
   .setConstraints(SyncConstraints)
    // Delegate to the SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED)
       .build()

구체적으로, SyncConstraints에 의해 정의되는 ConstraintsNetworkTypeNetworkType.CONNECTED일 것을 요구합니다. 즉, 네트워크를 사용할 수 있을 때까지 기다린 후에 실행됩니다.

네트워크를 사용할 수 있게 되면 Worker가 올바른 Repository 인스턴스를 위임하여 SyncWorkName에 의해 지정된 고유 작업 큐를 해제합니다. 동기화가 실패하면 doWork() 메서드가 Result.retry()를 반환합니다. 그러면 WorkManager가 지수 백오프를 사용하여 자동으로 동기화를 다시 시도합니다. 동기화가 성공하면 Result.success()를 반환하여 동기화를 완료합니다.

class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success()
        else Result.retry()
    }
}

샘플

다음 Google 샘플은 오프라인 우선 앱을 보여줍니다. 이러한 샘플을 참고하여 가이드가 실제로 어떻게 적용되는지 살펴보세요.