아티클 목록으로 가기

오프라인 우선 앱은 바뀐 것만 어떻게 동기화하는가

skydovesJaewoong Eum (skydoves)||16분 소요

오프라인 우선 앱은 바뀐 것만 어떻게 동기화하는가

오프라인 우선(offline-first) 앱을 열면 콘텐츠가 곧바로 나타납니다. 로컬 데이터베이스에서 바로 읽어 오고, 그동안 백그라운드 동기화가 조용히 그 데이터베이스를 서버와 맞춰 나가기 때문입니다. Now in Android가 바로 이런 방식으로 동작합니다. 모든 화면은 Room에서 읽고, 백그라운드 워커가 그 Room 데이터를 뒤에서 갱신합니다. 여기까지는 대부분의 개발자에게 익숙한 그림입니다. 리포지토리가 네트워크에서 데이터를 가져와 로컬 저장소에 씁니다. 더 깊은 질문은, 동기화가 네트워크로 실제로 무엇을 주고받느냐입니다. 동기화할 때마다 모든 토픽과 모든 아티클을 다시 받아 온다면 데이터와 배터리를 낭비하게 됩니다. 그래서 실제 프로덕션의 오프라인 우선 앱은 서버에게 더 좁은 질문을 던집니다. 마지막으로 동기화한 이후로 무엇이 바뀌었나요?

이 글에서는 버전 기반 델타 동기화(versioned delta sync)의 메커니즘을 깊이 있게 파고듭니다. 클라이언트가 모델마다 체인지 리스트(change list) 버전을 어떻게 추적하는지, 그 버전 이후로 바뀐 행만 서버에 요청하는 방법, 그 결과로 받은 삭제와 수정을 적용하는 과정, 그리고 새 버전을 저장하는 방법을 살펴봅니다. SynchronizerSyncable 계약, changeListSync 알고리즘의 단계별 흐름, ChangeListVersions가 Proto DataStore에 저장되는 방식, 각 리포지토리가 자신의 모델을 범용 알고리즘에 끼워 넣는 방법, 그리고 WorkManager 위의 SyncWorker가 두 동기화를 결코 동시에 돌리지 않으면서 전체 과정을 이끄는 방식까지 차례대로 따라가 봅니다.

근본적인 문제: 동기화할 때마다 전체 카탈로그를 다시 받기

로컬 데이터베이스를 최신 상태로 유지하는 가장 단순한 방법은, 그냥 버리고 새로 만드는 것입니다. 순진한 syncWith라면 원격 컬렉션 전체를 내려받아 로컬 테이블을 통째로 덮어쓸 것입니다.

suspend fun syncWith(): Boolean {
    val remoteTopics = network.getTopics()
    topicDao.deleteAllTopics()
    topicDao.upsertTopics(remoteTopics.map(NetworkTopic::asEntity))
    return true
}

이 방법도 동작하기는 하지만, 비용이 실제로 바뀐 양이 아니라 카탈로그 전체 크기에 비례해 커집니다. 아무것도 바뀌지 않았을 때조차 매 동기화가 컬렉션 전체를 실어 나릅니다. 바뀌지 않은 행까지 지웠다 다시 넣으면, 그 테이블을 지켜보던 모든 Room 옵저버가 무효화됩니다. 게다가 서버에서 삭제가 일어났는지 알아내려면, 원격 전체 집합과 로컬 전체 집합을 일일이 비교해야 합니다. 카탈로그가 커질수록 동기화 한 번의 대가도 함께 커집니다. 하루 동안 바뀐 것이 아티클 하나 수정된 것뿐이어도 말입니다.

버전 관리 시스템이 이와 거의 똑같은 문제를 풉니다. Git은 저장소를 갱신하려고 전체를 다시 내려받지 않습니다. 로컬 히스토리가 어디까지 왔는지 기록해 두고, 그 지점 이후의 커밋만 원격에 요청한 뒤 패스트 포워드(fast-forward)합니다. 델타 동기화는 바로 이 발상을 앱 데이터에 적용합니다. 이미 갖고 있는 가장 최신 서버 상태를 가리키는 커서를 하나 두고, 매 동기화마다 그 커서 이후의 행만 가져오는 것입니다.

커서: 모델마다 정수 하나

Now in Android의 커서는 평범한 정수 하나이며, 동기화하는 모델마다 하나씩, 불변 객체 ChangeListVersions에 담겨 있습니다.

data class ChangeListVersions(
    val topicVersion: Int = -1,
    val newsResourceVersion: Int = -1,
)

각 필드는 해당 모델에 대해 이미 적용한 가장 최신 서버 버전을 기록합니다. 기본값 -1은 한 번도 동기화하지 않았다는 뜻입니다. 커서가 둘로 나뉘어 독립적인 이유는, 토픽과 뉴스 리소스가 서버에서 서로 다른 변경 로그를 갖고 저마다 따로 나아가기 때문입니다. 이 값은 '나는 지금 무엇을 갖고 있는가?'라는 질문에 대한, 오래 남는 답입니다.

서버 쪽에서는 모델에 변경이 일어날 때마다 그 모델의 변경 로그에 행이 하나씩 추가됩니다. 클라이언트는 각 행을 NetworkChangeList로 받습니다.

@Serializable
data class NetworkChangeList(
    val id: String,                 // 변경된 모델의 id
    val changeListVersion: Int,     // 고유하고 연속적이며 단조 증가하는 버전
    val isDelete: Boolean,          // 삭제인지 수정인지 (수정은 생성을 포함)
)

세 필드가 변경 하나를 설명합니다. id는 어떤 모델이 바뀌었는지를 가리키고, changeListVersion은 연속적으로 단조 증가하는 카운터로 git 커밋 위치와 같은 역할을 하며, isDelete는 삭제인지 생성·수정인지를 구분합니다. 여기서 버전은 모델이 아니라 변경 로그의 행에 속한다는 점에 주목하세요. 클라이언트의 커서는 그저 자신이 처리한 그런 버전 중 가장 높은 값일 뿐입니다.

계약: Synchronizer와 Syncable

설계는 책임을 두 인터페이스로 나눕니다. 리포지토리는 자신의 테이블을 네트워크와 맞추는 법은 알지만, 커서가 어디에 사는지는 모릅니다. 커서 저장은 다른 무언가가 맡되, 그쪽은 모델의 구체적인 사정을 모릅니다. Syncable은 모든 리포지토리가 구현하는 마커입니다.

/**
 * 클래스가 원격 소스와 동기화됨을 표시합니다. 동기화는 절대 동시에 수행되어서는 안 되며,
 * 이를 보장하는 것은 [Synchronizer]의 책임입니다.
 */
interface Syncable {
    /** 리포지토리를 뒷받침하는 로컬 데이터베이스를 네트워크와 동기화합니다.
     * 동기화 성공 여부를 반환합니다. */
    suspend fun syncWith(synchronizer: Synchronizer): Boolean
}

예외를 던지는 대신 성공·실패를 불리언으로 반환하는 메서드 하나가 전부입니다. 인터페이스 KDoc은 동기화가 절대 동시에 돌아서는 안 된다고도 못 박고, 그 의무를 이 인터페이스가 아니라 Synchronizer에게 지웁니다. 여기서 SynchronizerSyncWorker이며, 그 유일성은 마지막 절에서 보듯 WorkManager가 보장합니다.

Synchronizer는 커서를 소유합니다. ChangeListVersions를 읽고 쓰며, 리포지토리가 자기 자신을 인자로 넘기지 않고도 스스로 동기화를 시작할 수 있도록 작은 편의 문법을 하나 제공합니다.

interface Synchronizer {
    suspend fun getChangeListVersions(): ChangeListVersions
    suspend fun updateChangeListVersions(update: ChangeListVersions.() -> ChangeListVersions)
    /** synchronizer 인자를 생략한 채 [Syncable.syncWith]를 호출하기 위한 편의 문법 */
    suspend fun Syncable.sync() = this@sync.syncWith(this@Synchronizer)
}

getChangeListVersions는 현재 커서들을 읽고, updateChangeListVersions는 현재 버전을 새 버전으로 바꾸는 람다를 받습니다. 읽은 뒤 갱신하는 이 형태는 저장 계층에서 변경을 원자적으로 유지해 줍니다. Syncable.sync() 확장 함수는 Synchronizer 안에 선언되어 있으므로, synchronizer 안에서는 repository.sync()repository.syncWith(this)로 풀립니다. 뒤에서 워커가 인자 없이 topicRepository.sync()라고 쓸 수 있는 이유가 바로 이것입니다.

알고리즘: changeListSync 단계별로

커서와 계약이 갖춰지면, 델타 알고리즘 자체는 Synchronizer의 확장 함수 하나에 담깁니다. 다섯 개의 람다로 매개변수화되어 있어, 모든 리포지토리가 같은 제어 흐름을 재사용하고 모델마다 다른 부분만 채워 넣습니다. 시그니처부터 살펴보겠습니다.

suspend fun Synchronizer.changeListSync(
    versionReader: (ChangeListVersions) -> Int,
    changeListFetcher: suspend (Int) -> List<NetworkChangeList>,
    versionUpdater: ChangeListVersions.(Int) -> ChangeListVersions,
    modelDeleter: suspend (List<String>) -> Unit,
    modelUpdater: suspend (List<String>) -> Unit,
) = suspendRunCatching {

이 다섯 람다는, 어떤 모델이든 해야 하는 다섯 가지 일에 깔끔하게 대응합니다.

  • versionReader: 공유된 ChangeListVersions에서 이 모델의 Int를 꺼냅니다.
  • changeListFetcher: 현재 버전을 받아, 그 이후의 변경 로그 행들을 반환합니다.
  • versionUpdater: 새 버전을 받아, 갱신된 ChangeListVersions를 반환합니다.
  • modelDeleter: id 기준으로 삭제를 적용합니다.
  • modelUpdater: id 기준으로 생성과 수정을 적용합니다.

본문 전체는 suspendRunCatching 안에서 돌아가는데, 이 래퍼는 다음 절에서 살펴봅니다. 그 안에서 처음 두 단계는 커서를 읽고 델타를 가져옵니다.

    // 마지막 동기화 이후의 체인지 리스트를 가져온다 (git fetch에 해당)
    val currentVersion = versionReader(getChangeListVersions())
    val changeList = changeListFetcher(currentVersion)
    if (changeList.isEmpty()) return@suspendRunCatching true

getChangeListVersions()는 저장소에서 모든 커서를 읽고, versionReader가 이 모델의 커서를 골라내며, changeListFetcher가 그 이후의 행을 서버에 요청합니다. 그다음이 빠른 경로입니다. 바뀐 것이 없으면 서버가 빈 목록을 반환하고, 함수는 곧바로 true를 반환합니다. 여기서 중요한 점은, 목록이 비어 있으면 커서를 다시 쓰지 않는다는 것입니다. 아무것도 움직이지 않았는데 저장소를 건드릴 이유가 없기 때문입니다.

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기