아티클 목록으로 가기

updateData 실행 중 앱이 크래시되면 DataStore 내부에서는 어떤 일이 벌어질까요?

skydovesJaewoong Eum (skydoves)||9분 소요

updateData 실행 중 앱이 크래시되면 DataStore 내부에서는 어떤 일이 벌어질까요?

dataStore.updateData { it.copy(token = newToken) }로 로그인 토큰을 저장한다고 가정해 봅시다. 사용자가 로그인하고 토큰이 디스크에 기록되기 시작한 바로 그 순간, 메모리 부족으로 안드로이드 시스템이 프로세스를 강제 종료합니다. 사용자가 앱을 다시 열었을 때, 토큰은 남아 있을까요? 파일이 손상되었을까요? 기존 데이터는 온전할까요, 아니면 완전히 사라져 버렸을까요?

이 글에서는 updateData 호출 시 데이터가 거치는 정확한 경로를 추적합니다. 코루틴 Mutex부터 스크래치 파일(scratch file), 원자적 rename(atomic rename)까지의 전체 흐름을 살펴본 뒤, 5가지 크래시 타이밍 시나리오에서 실제로 어떤 데이터가 살아남는지 확인합니다. DataStore의 내부 동작 원리를 정확히 이해하면, 프로덕션 환경에서 데이터 안전성에 대해 훨씬 더 자신 있게 판단하실 수 있습니다.

근본적인 문제: 파일 쓰기는 원자적이지 않습니다

DataStore를 살펴보기 전에, 파일에 단순하게 쓰기를 수행하면 어떤 일이 발생하는지 먼저 확인해 보겠습니다.

val file = File(context.filesDir, "prefs.json")
file.writeText(json.encodeToString(newPrefs))

writeText는 파일을 열고 내용을 0바이트로 자른(truncate) 뒤 쓰기를 시작합니다. 만약 쓰기 도중 프로세스가 종료되면, 파일에는 JSON의 절반만 남게 됩니다. 다음 실행 시 파일은 존재하지만 읽을 수 없는 상태가 되어, 기존 데이터와 새 데이터를 모두 잃게 됩니다.

바로 이 문제를 DataStore가 해결합니다. 재시도 로직이나 에러 핸들링이 아니라, 파일 수준에서 부분 쓰기 자체를 불가능하게 만드는 쓰기 전략을 통해 해결하는 것입니다.

updateData 파이프라인

updateData를 호출하면, 4개의 중첩된 연산으로 구성된 체인이 실행됩니다. 각 계층이 하나씩 안전 보장(guarantee)을 추가하는 구조입니다.

가장 바깥쪽 메서드는 코루틴 Mutex를 획득하여 모든 쓰기 작업을 직렬화합니다.

override suspend fun updateData(transform: suspend (t: T) -> T): T {
    scope.coroutineContext.ensureActive()
    // ...
    writerMutex.withLock {
        val updateMsg = Message.Update(transform, enqueueState, token)
        val result = handleUpdate(updateMsg)
        yield()
        result
    }
}

한 번에 하나의 updateData 호출만 실행될 수 있습니다. 만약 두 개의 Fragment에서 동시에 updateData를 호출하면, 두 번째 호출은 첫 번째가 완료될 때까지 대기합니다. 따라서 두 번째 transform은 항상 첫 번째의 결과를 기반으로 실행되므로, 쓰기가 유실되는 일이 발생하지 않습니다.

Mutex 내부에서 handleUpdatetransformAndWrite에 위임하며, 여기서 coordinator의 파일 잠금(file lock)을 획득합니다.

private suspend fun transformAndWrite(
    transform: suspend (t: T) -> T,
    token: DataStoreTraceToken?,
): T =
    coordinator.lock {
        val curData = readDataOrHandleCorruption(
            hasWriteFileLock = true,
            getVersion = { coordinator.getVersion() },
        )
        val newData = transform(curData.value)
        curData.checkHashCode()
        if (curData.value != newData) {
            writeData(newData, updateCache = true)
        }
        newData
    }

여기서 세 가지 작업이 순차적으로 이루어집니다. 먼저, 캐시에서 현재 데이터를 읽습니다(캐시가 유효하지 않으면 디스크에서 읽습니다). 다음으로, 현재 데이터에 대해 transform 함수를 실행합니다. 마지막으로, checkHashCode()를 통해 transform이 실행되는 동안 현재 데이터 객체가 외부에서 변경되지 않았는지 검증합니다. 데이터가 변경되었으면 쓰기가 수행되고, 변경되지 않았으면 writeData를 완전히 건너뜁니다.

write aside 전략: 스크래치 파일과 원자적 이동

실제 디스크 쓰기가 이루어지는 부분이 바로 크래시 안전성(crash safety)의 핵심입니다. DataStore는 데이터 파일에 직접 쓰기를 수행하지 않습니다. 대신 임시 스크래치 파일에 먼저 기록한 뒤, 스크래치 파일의 이름을 변경(rename)하여 원본 파일을 대체합니다.

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

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

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