[Android] Offline-first-app

D.O·2023년 11월 9일
1

Local Cache 도입 이유

이미지 리스트 스크롤 성능 향상 글에서 로컬 데이터베이스 캐싱 전략에 대해 고민한 적이 있다. 그 때는 주요 기능이 이미지 로드인 앱의 특성상

  1. 이미지 데이터 자체를 캐시할 경우에는 저장 공간과의 트레드오프에서,
  2. 이미지 URL을 캐시할 경우에는 이미지 로딩 시간이나 처리에 직접적인 큰 이점이 없다고

판단하고 도입을 고려하지 않았던 기억이 있다.

하지만 그 때는 이미지 리스트 스크롤 성능 향상이 주 목표였기에 넘어 갔지만 후에 공부를 해보니 로컬 캐시로 얻을 수 있는 장점이 많았다.

로컬 캐싱의 장점

로컬 캐시는 사용자가 인터넷 없이도 정보에 접근할 수 있게 해주고 인터넷 연결이 느리거나 불안정할 때 빠른 데이터 접근을 가능하게 해서 앱의 반응속도를 개선합니다.

이는 앱이 네트워크 요청을 줄이고, 결과적으로 사용자의 데이터 사용료도 아낄 수 있게 도와줍니다. 또한, 캐시는 서버의 부하를 줄여주고, 인터넷 연결에 문제가 생겼을 때도 앱이 계속 작동할 수 있도록 해줍니다. 이런 방식으로, 로컬 캐시는 앱의 신뢰성과 사용성을 높이며, 다양한 네트워크 환경에서도 일관된 사용자 경험을 제공합니다.

도입 결정

이전 글에서 인스타그램을 언급하며 해당 이미지 및 동영상 중심의 서비스가 페이징 기술을 사용하고 로컬 캐싱을 별도로 채택하지 않는 것으로 확인 하였다고 설명하면서 로컬 캐싱의 필요성에 대해 다소 회의적인 글을 썼던 것을 기억한다.

그러나 다시 생각해보니 인스타그램의 경우, 지속적으로 새로운 콘텐츠를 사용자에게 추천해야 하는 서비스 특성상 로컬 캐싱은 상대적으로 덜 중요할 수있지만 'Mineme' 앱은 커플들을 위한 이미지 기반의 다이어리 앱으로, 일단 업로드된 데이터는 자주 변경되지 않으며, 장기간 보관되는 성향이 강하다고 판단했다.

사실 이전에 또 다른 이미지,동영상위주인 인스타그램이 페이징을 사용하고 로컬 캐싱은 딱히 사용하지 않는 것으로 확인 됐다고 설명하면서 로컬 캐싱을 적용하지 않았지만 생각해보니 인스타그램은 추천 알고리즘으로 계속 새로운 데이터를 제공해야하니 로컬캐싱이 큰 의미가 없다고 생각이된다. 하지만 나의 Mineme 앱은 이미지기반 커플 다이어리 앱인데 여기에 저장된 데이터는 추천 알고리즘으로 새로운 데이터를 제공할 필요도없고 한번 작성한 글에 대해서는 수정 및 삭제 등도 적을것으로 판단되어 로컬 캐싱을 사용하면 많은 부분에 이점이 있을 것이라 생각한다.

이러한 점에서 Mineme에 로컬 캐싱을 적용하면 이런 점에서 사용자의 데이터 접근성을 높이고, 네트워크 의존도를 낮추는 데 유리하며, 데이터 사용량과 서버 부하를 줄이는 효과도 기대할 수 있다고 판단하여 도입을 결정하였다.

Room 라이브러리를 활용해 로컬 캐시를 구현하기로 결정했다.

Android 로컬 캐싱에 대해 공부하던 중 offline-first app 이라는 공식문서 섹션에서 굉장히 유용한 지식을 얻은 것 같아 일단 이것 먼저 정리해보려고 한다.

공식 문서는 now in android 오픈소스 기준으로 설명하는 부분이 많기에 먼저 해당 프로젝트를 분석하면 난이도가 있는 해당 문서를 이해하기 쉬울 것이다.

Offline-First App

Offline-First App은 인터넷 연결 여부와 관계없이 작동하도록 설계된 애플리케이션입니다. 이들은 로컬 데이터 저장과 동기화를 통해 사용자에게 일관된 경험을 제공합니다.

여기에 내가 흥미있게 본 부분은 동기화 메커니즘아키텍처 부분이였다.

사실, offline-first 앱의 원리와 구현 방법은 과거에 저장소 패턴을 배우면서 이미 접해본 개념이었다. 당시에는 'offline-first'라는 용어 자체는 알지 못했지만, 그 원리에 대해서는 추상적으로 이해하고 있었지만

이전에 공부할 때도 내가 궁금했던 것은 “원격 서버에서 데이터를 요청할 때 이미 캐싱된 데이터를 제외하고 필요한 정보만을 가져오는 방법이 없을까?” 이였다.

이 섹션을 정독하면서 이 부분에 대해서 어느정도 궁금증을 해소하였다.

Offline-First App 설계

오프라인 우선 앱을 만들 때의 핵심은, 사용자가 인터넷에 연결되어 있지 않을 때도 앱이 잘 작동해야 한다는 것입니다. 이를 위해 앱 설계는 기본적으로 두 가지 주요 활동에 중점을 둬야 합니다:

  1. 읽기 작업: 이는 앱이 필요한 정보를 어디에서든 빠르게 가져와서 사용자에게 보여줄 수 있게 하는 것을 말합니다. 예를 들어, 사용자가 앱을 열 때마다 바로 일정이나 메모를 볼 수 있어야 합니다.
  2. 쓰기 작업: 사용자가 정보를 입력할 때, 이 데이터가 잘 저장되어 인터넷이 없을 때도 접근할 수 있도록 해야 합니다. 예를 들어, 사용자가 새로운 메모를 작성하면, 그 메모는 앱에 바로 저장되어 인터넷 없이도 나중에 볼 수 있어야 합니다.

Data Layer는 앱의 정보를 저장하고 관리하는 부분인데, 여러 출처의 데이터를 모아서 앱이 사용할 수 있게 해주는 역할을 한다.

아키택처 레이어에 대해서는 따로 정리해서 포스팅할 예정!

오프라인 우선 앱에서는 이 Data Layer가 인터넷이 없어도 중요한 정보를 제공할 수 있게, 즉 사용자가 앱을 사용할 때 인터넷 연결 없이도 정보를 읽고 쓸 수 있게 여러 데이터 소스로부터 정보를 조합하고 관리하는 것이 중요하다. (이것이 저장소 패턴의 핵심)

오프라인 우선 앱은 기본적으로 두 가지 유형의 데이터 소스를 가지고 있다

하나는 앱이 사용자의 기기에 직접 저장해 둔 데이터(로컬 데이터 소스)이며, 다른 하나는 인터넷을 통해 서버로부터 받아오는 데이터(네트워크 데이터 소스)입니다.

이렇게 두 가지 데이터 소스를 이용함으로써 앱은 오프라인 상태에서도 기능할 수 있으며, 온라인이 될 때 최신 데이터로 쉽게 업데이트할 수 있습니다.

로컬 데이터 소스

로컬 데이터 소스는 앱이 오프라인 상태일 때도 사용자에게 정보를 제공할 수 있는 기본적인 정보 저장소입니다. 이는 주로 기기 내부의 저장 공간에 데이터를 보관하는 방식으로 운용됩니다.

데이터의 종류에 따라 관계형 데이터베이스(Room), 프로토콜 버퍼(Datastore), 또는 단순 파일 시스템을 사용하여 구조화된 또는 구조화되지 않은 형태로 정보를 저장합니다. 이렇게 정보를 로컬에 저장함으로써 인터넷 연결 유무에 상관없이 데이터 일관성과 접근성을 유지할 수 있습니다.

네트워크 데이터 소스

네트워크 데이터 리소스는 앱의 최신 데이터를 제공하는 원격 서버 입니다 .인터넷에 연결될 때 앱이 새 정보를 가져와 로컬 데이터와 동기화하는 데 사용됩니다. 네트워크가 불가능할 때는 로컬 데이터가 사용되며, 연결이 복원되면 네트워크 데이터를 기반으로 로컬 데이터를 최신 상태로 업데이트합니다.

데이터 동기화와 업데이트는 앱의 'repository'라고 불리는 부분이 관리하며, 앱의 비즈니스 로직(도메인 레이어)과 사용자 인터페이스(UI)는 직접 네트워크 데이터에 접근하지 않고, 'repository'라는 중간자를 통해 데이터를 주고받습니다.

앱 내 데이터 관리 및 표현

앱에서 사용하는 데이터는 로컬 저장소네트워크를 통한 저장소에서 다루며, 이 두 곳에서의 데이터 처리 방식은 서로 다릅니다. 로컬과 네트워크 데이터 소스는 각각 다른 모델을 사용하여 데이터를 관리하고 표현하며, 이를 앱의 구조 내에서 명확히 구분합니다. 예를 들어, AuthorEntity로컬 데이터베이스의 작성자 정보를, NetworkAuthor네트워크를 통해 얻은 작성자 정보를 나타내는 데 사용됩니다.

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

@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

@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,
)

AuthorEntity와 NetworkAuthor를 둘 다 Data Layer 내부에 유지하고 외부 레이어가 사용할 수 있도록 다른 유형을 노출하는 것이 좋습니다

이 구조는 앱의 기본 기능에 영향을 주지 않으면서 데이터베이스나 네트워크에서 발생하는 작은 변화들을 다른 외부 레이어(UI,Domain)가 느끼지 못하게 보호합니다.

data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

이러한 구조는 CleanArchitecture의 기본 입니다.

Read

"읽기" 연산은 사용자가 오프라인 상태일 때에도 앱을 사용할 수 있도록 설계된 '오프라인 우선(Offline-First)' 앱의 중요한 기능입니다.

이런 앱은 로컬에 저장된 데이터를 우선적으로 사용하며, 인터넷 연결이 복원되면 새로운 데이터를 자동으로 확인하고 표시합니다.

이러한 로컬 데이터를 우선적으로 사용하는 것은 사용자 경험 측에서 오프라인 상태에서 앱을 사용할 수 있다는 것 외에도 네트워크에서 데이터를 가져오기전 일단 로컬 데이터로 앱을 빠르게 로드 할 수 있다는 부분에서 사용자 경험을 향상 시키는 효과가 있습니다.

'OfflineFirstTopicRepository' 같은 데이터 저장소(repository)데이터 스트림을 'Flow' 형태로 반환하여 이 반응형 동작을 가능하게 합니다. 이렇게 구현된 읽기 API는 데이터의 최신 상태를 반영하도록 실시간으로 업데이트합니다.

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

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

오류 처리 전략

오프라인 우선 앱에서 발생하는 오류는 오류가 발생한 데이터 소스에 따라 처리 방식이 달라집니다.

1. 로컬 데이터 소스의 오류 처리

로컬 데이터 소스를 통해 데이터를 읽을 때, 발생할 수 있는 오류를 최소화하기 위해 Flow를 이용해 데이터를 수집할 때 catch 연산자를 사용합니다. 이를 통해 데이터 읽기 과정에서 발생할 수 있는 예외 상황들을 효과적으로 관리하고, 읽기 연산을 안정적으로 수행할 수 있습니다.

아래 예는 Flow에서 예외가 발생했을 때, 예외를 잡아내고 대신 Author.empty()을 발생시켜 빈 작가 객체를 스트림에 내보냄으로써 오류를 처리하고 앱의 크래시를 방지하는 부분입니다.

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

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

catch 연산자는 예외로 인한 앱의 비정상 종료를 방지하기만 하며, Flow 자체는 종료됩니다. 예외 발생 후에 흐름에서의 데이터 수집을 재개하려면 [retry](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/retry.html) 메서드를 사용하면 됩니다.

2. 네트워크 데이터 소스의 오류 처리

네트워크 데이터 소스로부터 데이터를 불러올 때 오류가 나면, 앱은 휴리스틱을 활용해 데이터 요청을 재시도합니다.

적용할 수 있는 휴리스틱에는 여러가지가 있는데 대표적으로

  1. 재시도 로직: 앱은 데이터 요청이 실패했을 때, 설정된 일정 시간을 기다린 후 자동으로 데이터를 다시 요청합니다.
  2. 지수 백오프: 초기에는 빠른 간격으로 데이터 요청을 재시도하고, 실패가 지속될 경우 재시도 간격을 지수 함수적으로 늘려서 서버에 대한 부하를 관리하고 재연결 성공 가능성을 높입니다.
  3. 캐시된 데이터 사용: 최신 정보 요청에 실패한 경우 이전에 캐시된 데이터를 사용하여 사용자가 정보 부재의 상태를 경험하지 않도록 합니다.
  4. 사용자 알림: 데이터 로드에 실패하면 사용자에게 이를 알리고, 필요한 경우 수동 새로고침을 유도합니다.
  5. 네트워크 연결 모니터링: 앱은 네트워크 연결 상태를 지속적으로 확인하고, 연결 가능성이 바뀔 때마다 데이터 요청을 자동으로 조정합니다.

공식문서에서는 주로 지수 백오프와 네트워크 연결 모니터링이 사용된다고 합니다.

지수 백오프재시도 간격을 조절하며 연결을 개선하는 데 도움을 주고, 네트워크 연결 모니터링네트워크 상태 변화에 즉각적으로 대응하여 사용자에게 최신 데이터를 제공할 수 있도록 합니다. 이는 최신 데이터 효율적으로 빠르게 가져올 수 있는 방법이라 생각되기 때문에 공식 문서에서 이에 대해서만 소개하는 것 같습니다.

지수 백오프

  1. 시작: 데이터 읽기를 시작합니다.
  2. 네트워크로부터 데이터 읽기 시도: 처음에는 네트워크에서 데이터를 가져오려고 합니다.
  3. 읽기 성공 시: 데이터를 성공적으로 가져오면 로컬 데이터를 업데이트합니다.
  4. 읽기 실패 시: 데이터를 가져오지 못하면, 다시 시도할지 결정해야 합니다.
  5. 재시도 결정: 만약 다시 시도한다면, 데이터 읽기를 다시 시도하고, 그렇지 않다면 절차를 종료합니다.
  6. 완료 : 읽기 프로세스 완료

끊임없는 재시도는 서버에 과도한 부하를 초래할 수 있으므로, 이를 예방하기 위해 재연결 시도 간격을 점진적으로 확장하는 전략입니다.

네트워크 연결 모니터링

네트워크 연결 모니터링에서는 앱이 네트워크 데이터 소스에 연결할 수 있음을 확실히 알게 될 때까지 읽기 요청이 큐에 추가됩니다. 연결이 확인되면 읽기 요청이 큐에서 제거되고 데이터 읽기와 로컬 데이터 소스가 업데이트됩니다.

WorkManager를 활용해 이 큐를 처리하는 작업을 스케줄링하고 실행합니다.

Write

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

1. Online-only writes

데이터를 서버에 쓰는 작업을 바로 시도하고, 성공하면 로컬 데이터를 업데이트합니다.

이는 실시간으로 처리되어야 하며 중요한 데이터 트랜잭션에 사용됩니다. 예를 들어, 은행 송금 같은 경우가 이에 해당 쓰기가 실패할 수 있으므로 사용자에게 쓰기가 실패했음을 알리거나 사용자가 데이터 쓰기를 시도하는 것을 애초에 방지하는 것이 필요할 수 있습니다.

이 시나리오에서는 인터넷 연결이 필요할 때는 사용자에게 쓰기 UI를 표시하지 않거나 쓰기 기능을 비활성화하고, 오프라인임을 알리는 메시지를 제공하는 전략을 사용할 수 있습니다.

2. Queued writes

쓰려는 객체를 큐에 추가하고, 앱이 온라인 상태가 될 때까지 대기합니다. 네트워크가 다시 연결되면, 지수 백오프 전략을 사용하여 큐에서 쓰기 작업 이를 처리하기 위해 WorkManager와 같은 백그라운드 작업 스케줄러를 사용

데이터 쓰기가 중요하지 않고, 트랜잭션이 시간에 민감하지 않으며, 사용자에게 즉각적인 피드백이 필요하지 않은 경우에 적합합니다. 분석 이벤트나 로깅 등이 예가 될 수 있습니다.

3. Lazy writes

먼저 로컬 데이터베이스에 데이터를 쓰고, 그 다음에 네트워크 상태가 좋을 때 쓰기 작업을 서버에 반영, 앱에 중요한 데이터, 예를 들어 할 일 목록 앱에서 사용자가 추가한 작업 등이 오프라인 상태에서도 안전하게 저장되고, 온라인 상태가 되면 서버에 동기화되어야 하는 경우에 적합합니다.

네트워크와 로컬 데이터 소스 간의 데이터 충돌이 발생할 수 있으므로, 이를 해결하기 위한 추가적인 로직이 필요 또한 네트워크 상태가 변할 때마다 적절한 작업을 취하도록 네트워크 모니터링 기능이 필요합니다.

동기화 및 충돌해결

네트워크와 로컬 데이터 소스 간의 충돌은 주로 동기화 문제로 인해 발생합니다.

이러한 충돌은 데이터 불일치가 발생하는 상황을 의미합니다

이러한 충돌은 주로 동기화 문제로 인해 발생하는데 동기화 방법으로는 두 가지가 있습니다.

  • Pull-based synchronization
  • Push-based synchronization

Pull-based synchronization(풀 기반 동기화)

풀 기반 동기화애플리케이션이 필요에 의해 네트워크를 통해 최신의 애플리케이션 데이터를 요청하고 가져오는 방식

이 방식은 애플리케이션이 데이터를 사용자에게 표시하기 바로 전에 데이터를 가져오는 탐색 기반의 접근을 주로 사용합니다. 즉, 사용자가 특정 화면으로 이동할 때마다 해당 화면에 필요한 데이터를 새로고침하며, 사용자가 최신의 정보를 볼 수 있도록 합니다.

애플리케이션에서 네트워크 연결이 짧거나 중간 정도의 기간 동안 끊기는 경우에 풀 기반 동기화가 적합합니다. 만약 연결이 오래 동안 끊어진다면, 사용자는 오래된 데이터나 비어 있는 캐시를 기반으로 앱의 첫 화면을 볼 수 있으므로, 데이터의 새로고침이 중요하게 작용합니다.

예를 들어, 무한 스크롤 목록을 가진 특정 화면에 대한 데이터를 가져오는 경우에는 페이징 토큰을 사용하여 데이터를 로컬 저장소에 캐시하고, 이후에 로컬 저장소에서 정보를 읽어 사용자에게 표시할 수 있습니다. 만약 네트워크 연결이 없는 경우에는 저장소가 로컬 데이터 소스에서만 데이터를 요청하게 됩니다.

풀 기반 동기화의 장단점은 다음과 같습니다.

장점 :

  • 구현이 비교적 간단합니다.
  • 필요하지 않은 데이터를 가져오는 일이 없어 데이터의 관련성이 높습니다.

단점 :

  • 데이터 사용량이 높아질 수 있습니다. 이는 반복적인 탐색에서 이미 가져온 정보를 다시 가져오게 되는 경우를 말합니다. 캐싱 전략을 적절히 사용하면 이 문제를 줄일 수 있습니다.
  • 관계형 데이터를 다룰 때 적합하지 않을 수 있습니다. 하나의 모델이 다른 모델로부터 데이터를 필요로 할 때, 높은 데이터 사용량의 문제가 더 심각해지고, 모델 간의 종속성 문제가 발생할 수 있습니다.

Push-based synchronization(푸쉬 기반 동기화)

푸시 기반 동기화(push-based synchronization)는 데이터의 변경이 발생하면, 이를 적극적으로 클라이언트(예를 들어 모바일 앱이나 웹 앱)에 통보하고, 클라이언트는 해당 변경 사항만을 갱신하는 동기화 방식입니다. 이 방식은 서버에서 클라이언트로 데이터의 변경을 "푸시"하는 메커니즘을 사용합니다.

초기 설정과 변경 감지

푸시 기반 동기화를 구현할 때는 초기에 필요한 데이터를 충분히 로드하여 로컬 데이터베이스를 구축합니다. 이후에는 서버로부터 데이터 변경 알림을 받게 되며, 앱은 이 변경 알림을 기반으로 오래된 데이터를 업데이트하기 위해 서버에 접속합니다.

데이터 동기화의 책임

로컬 데이터 소스는 서버의 데이터를 가능한 한 정확하게 반영하려고 노력합니다. 동기화 작업은 보통 데이터 저장소(Repository)에서 관리됩니다. 데이터 저장소는 네트워크로부터 최신 데이터를 가져와서 로컬 데이터베이스에 저장하고, 앱 내의 다른 컴포넌트들은 이 저장소로부터 데이터를 관찰할 수 있습니다.

장점:

  • 오프라인 유지 가능: 앱이 오프라인 상태에서도 장기간 작동할 수 있습니다.
  • 데이터 사용량 절감: 앱은 변경된 데이터만 가져오기 때문에 전체 데이터를 반복해서 가져오는 것보다 효율적입니다.
  • 관계형 데이터 적합: 각 데이터 모델에 대한 저장소가 변경을 추적하므로, 관계형 데이터와 잘 작동합니다.

단점:

  • 복잡한 데이터 버전 관리: 충돌 해결을 위해 데이터의 버전을 관리하는 것이 복잡할 수 있습니다.
  • 쓰기 문제: 동기화하는 동안 여러 클라이언트에서의 쓰기 연산이 충돌할 수 있으므로, 이를 관리하는 추가적인 로직이 필요합니다.
  • 네트워크 지원 필요: 푸시 기반 동기화를 위해서는 네트워크 서버가 동기화 메커니즘을 지원해야 합니다.

결론적으로 간단히 말하자면 클라이언트와 서버의 관계에서

pull 기반은 “물어보기”
push 기반은 “알려주기” 라고 생각하시면 됩니다.

하이브리드 동기화

하이브리드 동기화 접근 방식은 애플리케이션의 특정 부분이나 데이터 유형에 따라 Push 방식과 Pull 방식을 혼합하여 사용합니다. 이는 각 방식의 이점을 활용하고 단점을 보완하기 위해 선택됩니다.

  1. 소셜 미디어 피드: 사용자의 소셜 미디어 피드는 새로운 콘텐츠로 자주 업데이트되기 때문에, 이 부분은 Pull 기반으로 설정할 수 있습니다. 즉, 사용자가 앱을 열거나 새로고침을 할 때마다 최신 피드 데이터를 서버로부터 불러옵니다.

  2. 사용자 프로필 데이터: 사용자의 개인 데이터(예: 사용자 이름, 프로필 사진 등)는 그다지 자주 변경되지 않을 수 있으며, 이러한 정보가 변경될 때마다 애플리케이션에 푸시 알림을 보내 업데이트할 수 있습니다. 이 경우, 사용자가 앱을 사용하는 동안 데이터가 변경되면, 서버로부터 즉시 업데이트를 받아 로컬 데이터베이스에 반영합니다.

충돌 해결

앱이 오프라인 상태에서 네트워크 데이터 소스와 다른 데이터를 로컬로 쓸 경우 충돌이 발생

동기화가 이루어지려면 먼저 충돌을 해결해야 합니다.

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

Last write wins

충돌 해결을 위한 일반적인 전략 중 하나로, 여러 클라이언트가 동일한 데이터에 대해 동시에 변경을 가했을 때 사용

  1. 각 클라이언트는 자신이 데이터에 변경을 가할 때마다 그 변경 사항에 타임스탬프를 부여합니다.
  2. 이 타임스탬프는 해당 데이터가 언제 마지막으로 수정되었는지를 나타냅니다.
  3. 클라이언트가 오프라인 상태에서 변경을 가한 후 다시 온라인 상태가 되어 서버와 동기화할 때, 서버는 여러 클라이언트로부터 받은 타임스탬프를 비교합니다.
  4. 서버는 가장 최근의 타임스탬프를 가진 데이터를 '마지막으로 쓴 데이터'로 간주하고, 이를 유지합니다.
  5. 서버는 나머지 데이터 중 오래된 타임스탬프를 가진 데이터를 무시하거나 삭제합니다.

  1. 두 기기 (A와 B)가 초기에 오프라인 상태입니다.
  2. 기기 A는 오프라인 상태에서 데이터를 쓰고(변경하고), 그 상태로 기기가 온라인으로 돌아옵니다.
  3. 기기 B도 동일하게 오프라인 상태에서 다른 데이터를 쓰고 온라인으로 돌아옵니다.
  4. 두 기기 모두 온라인 상태가 되면, 동기화 과정을 거쳐 데이터를 네트워크 데이터 소스(중앙 서버나 데이터베이스 등)와 동기화합니다.
  5. 이 동기화 과정에서는 두 기기의 변경 사항을 결합하거나, 일반적으로 '마지막 쓰기 적용' 원칙에 따라 가장 최근에 변경된 데이터를 우선적으로 적용합니다.

이 충돌 해결 전략은 단순하고 직관적이지만, 일부 단점이 있습니다. 예를 들어, 가장 최신의 타임스탬프를 가진 변경 사항이 항상 최선의 선택은 아닐 수 있습니다. 또한, 모든 클라이언트 간에 정확한 시간 동기화가 필요하며, 타임스탬프의 정확도에 의존하기 때문에, 시간 설정에 문제가 있는 클라이언트는 잘못된 데이터 동기화를 일으킬 수 있습니다.

따라서 이 전략은 타임스탬프를 정확히 관리할 수 있고, 단순히 가장 최근의 변경 사항을 적용하는 것만으로 충분한 상황에서 주로 사용됩니다.

이걸 보면서 느낀 거는 “이런 Last write wins가 잘 쓰일까? 보통 순서대로 병합을 하는게 맞지 않나? 데이터 손실을 야기할 것 같은데 “

그래서 좀 찾아보았습니다.

LWW가 유용한 경우:

  • 데이터가 사용자에 의해 빈번히 변경되지 않거나 충돌 가능성이 낮은 경우
  • 데이터 충돌이 비즈니스 로직에 큰 영향을 미치지 않을 때
  • 더 복잡한 충돌 해결 메커니즘이 시스템의 성능에 부담을 줄 때

그러나 대부분의 실제 응용 프로그램에서는 데이터의 정확성과 일관성이 중요합니다. 이런 경우에는 LWW 방식이 문제를 일으킬 수 있습니다. 예를 들어, 동일한 레코드에 대해 두 사용자가 서로 다른 변경사항을 적용했을 때, 중요한 정보가 덮어쓰여질 수 있습니다.

병합 접근법이 선호되는 경우:

  • 복잡한 데이터 모델을 사용할 때
  • 다수의 사용자가 동일한 데이터를 동시에 변경할 수 있는 환경
  • 데이터 손실이나 오버라이드로 인한 비즈니스 로직 문제를 최소화하려고 할 때

결국 애플리케이션의 요구사항, 데이터의 중요성, 그리고 사용자의 경험을 고려하여 가장 적절한 데이터 동기화 및 충돌 해결 전략을 선택해야 합니다.

WorkManager

마지막으로 WorkManager에 대해 알아보자.

WorkManager는 백그라운드 작업을 예약하고 실행하는 안드로이드 API입니다.

오프라인 우선 앱에서는 사용자가 온라인일 때까지 읽기와 쓰기 작업을 지연시키고, 온라인 상태가 되면 이러한 작업을 처리하도록 큐(queue)에 추가합니다.

읽기 및 쓰기 전략에서 공통적인 두 가지 유틸리티

  1. 큐(Queues):
    • 읽기(Reads): 네트워크 연결이 가능할 때까지 읽기 작업을 지연시키는 데 사용됩니다.
    • 쓰기(Writes): 네트워크 연결이 가능할 때까지 쓰기 작업을 지연시키고, 필요한 경우 재시도하기 위해 작업을 큐에 다시 추가합니다.
  2. 네트워크 연결 모니터(Network connectivity monitors):
    • 읽기(Reads): 앱이 연결되었을 때 읽기 큐를 비우고 동기화하기 위한 신호로 사용됩니다.
    • 쓰기(Writes): 앱이 연결되었을 때 쓰기 큐를 비우고 동기화하기 위한 신호로 사용됩니다.

WorkManager의 사용 예시:

  • 앱이 시작될 때, 로컬 데이터 소스와 네트워크 데이터 소스 사이의 일치를 확인하기 위해 동기화 작업을 큐에 추가합니다.
  • 앱이 온라인 상태가 되면, 읽기 동기화 큐를 비우고 동기화를 시작합니다.
  • 지수 백오프(exponential backoff)를 사용하여 네트워크 데이터 소스에서 읽기를 수행합니다.
  • 읽기 결과를 로컬 데이터 소스에 저장하고 발생할 수 있는 충돌을 해결합니다.
  • 로컬 데이터 소스에서 다른 앱 레이어가 사용할 데이터를 노출합니다.

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
   }
}

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()
    }
}
  • SyncInitializer 클래스는 앱이 시작될 때 동기화 작업을 WorkManager 큐에 추가합니다.
  • enqueueUniqueWork 메서드는 고유 작업으로 동기화를 예약하며, ExistingWorkPolicy.KEEP 정책은 이미 예약된 작업이 있는 경우 새 작업을 추가하지 않습니다.
  • SyncWorker는 실제 동기화 작업을 수행하는 Worker 클래스입니다. 네트워크가 연결되면 작업을 수행하고, 실패하면 자동으로 지수 백오프를 사용하여 재시도

해당 코드 부분은 실제 적용을 해보면서 더 자세히 알아보자.

마무리

굉장히 길었는데 이론만으로는 애매한 부분이 많다.
백엔드를 담당하던 친구가 사라져서 nowinandroid 오픈소스를 분석하며 실제코드를 살펴보자.
다음 글에서는 nowinandroid에서 offline-first-app을 어떻게 구현하였는지 살펴보는 글을 이어서 작성하겠다.

profile
Android Developer

0개의 댓글