Firestore 오프라인 퍼시스턴스 정리: 동작 원리와 플랫폼별 설정
Firestore를 쓰다 보면 네트워크가 끊겼을 때도 앱이 이전에 받아온 데이터를 그대로 보여주는 경우가 있습니다.
이건 Firestore의 오프라인 퍼시스턴스(Offline Persistence) 기능 덕분이고, 실제로 모바일 플랫폼에서는 기본으로 켜져 있는 경우가 많습니다.
이 글에서는 오프라인 퍼시스턴스가 정확히 어떻게 동작하는지, 플랫폼별 기본값과 설정 방법, 그리고 주의할 점을 정리합니다.
오프라인 퍼시스턴스란?
앱이 사용하는 Firestore 데이터를 로컬 디스크에 캐싱해두고, 네트워크가 없어도 읽기와 쓰기를 계속 수행할 수 있게 하는 기능입니다.
- 네트워크가 있을 때: 서버에서 받은 데이터를 로컬에도 저장
- 네트워크가 없을 때: 로컬 캐시에서 즉시 읽고, 쓰기는 대기 상태로 보관
- 네트워크가 복구되면: 대기 중이던 쓰기를 서버에 자동으로 전송하고 동기화
즉, 오프라인 상태에서도 앱이 멈추지 않고 정상 동작하는 것처럼 보이게 해줍니다.
핵심 구조: 앱 → 캐시 → 서버
오프라인 퍼시스턴스를 이해할 때 가장 중요한 포인트는 앱이 서버를 직접 읽고 쓰는 것이 아니라, 항상 로컬 캐시를 거친다는 점입니다.
[앱] ⇄ [로컬 캐시] ⇄ [Firestore 서버]
읽기 흐름
- 앱이
get()이나onSnapshot()으로 데이터를 요청 - SDK는 먼저 로컬 캐시를 보여줌
- 서버에서 최신 데이터가 도착하면 캐시를 업데이트하고, 리스너에게 새 스냅샷을 전달
즉, 스냅샷 리스너는 서버에 직접 붙는 것이 아니라 로컬 캐시에 붙습니다. 서버는 변경사항을 캐시로 보내고, 앱은 그 캐시를 통해서만 데이터를 읽습니다.
이 구조 덕분에 네트워크가 끊겨도 앱은 캐시에 있는 마지막 데이터를 끊김 없이 계속 받아볼 수 있습니다.
쓰기 흐름
- 앱이
set(),update(),add()등으로 데이터를 기록 - SDK는 로컬 캐시에 즉시 반영 (
hasPendingWrites: true) - 리스너는 캐시 변경을 바로 감지해 UI를 업데이트
- 백그라운드에서 서버로 전송되고, 확정되면
hasPendingWrites: false로 변경
UI가 즉시 반응하는 것처럼 보이는 이유는 캐시가 먼저 갱신되기 때문입니다. 이를 낙관적 업데이트(optimistic update)라고 부릅니다.
플랫폼별 기본값
| 플랫폼 | 기본 활성화 여부 |
|---|---|
| Android | ✅ 기본 활성화 |
| iOS | ✅ 기본 활성화 |
| Web | ❌ 기본 비활성화 (명시적으로 켜야 함) |
모바일은 앱 저장 공간이 로컬 캐시로 쓰기 적합하기 때문에 기본 활성화, 웹은 브라우저 환경 특성상 사용자가 선택하도록 되어 있습니다.
플랫폼별 설정 방법
Android (Kotlin)
val settings = FirebaseFirestoreSettings.Builder()
.setPersistenceEnabled(true) // 기본값
.setCacheSizeBytes(FirebaseFirestoreSettings.CACHE_SIZE_UNLIMITED)
.build()
FirebaseFirestore.getInstance().firestoreSettings = settings
iOS (Swift)
let settings = Firestore.firestore().settings
settings.cacheSettings = PersistentCacheSettings(
sizeBytes: NSNumber(value: 100 * 1024 * 1024)
)
Firestore.firestore().settings = settings
Web (JavaScript, v9+ 모듈식 API)
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
CACHE_SIZE_UNLIMITED
} from "firebase/firestore";
const db = initializeFirestore(app, {
localCache: persistentLocalCache({
tabManager: persistentMultipleTabManager(),
cacheSizeBytes: CACHE_SIZE_UNLIMITED
})
});
웹에서는 여러 탭이 열려 있어도 동일 캐시를 공유할 수 있게
persistentMultipleTabManager() 사용이 권장됩니다.
캐시 크기
- 기본값: 100 MB (자동 정리 활성화)
- 최소값: 1 MB
- 무제한:
CACHE_SIZE_UNLIMITED설정 시 자동 정리 비활성화
기본 설정은 캐시가 커지면 오래된 데이터부터 자동으로 정리하므로 대부분의 앱에서는 그대로 써도 문제없습니다.
오프라인에서의 읽기/쓰기 동작
읽기
네트워크가 없으면 로컬 캐시에서 즉시 반환합니다. 쿼리 결과도 캐시 기준으로 계산되므로 일부 결과만 보일 수 있습니다.
쓰기
네트워크가 없으면 쓰기 작업이 로컬 큐에 저장됩니다.
이 상태의 문서는 metadata.hasPendingWrites 값이 true로 표시됩니다.
네트워크가 복구되면 큐에 쌓인 쓰기가 자동으로 서버에 전송되고, 충돌이 발생하면 기본적으로 Last-Write-Wins 전략으로 해결됩니다.
실시간 리스너와 오프라인
스냅샷 리스너는 오프라인에서도 계속 동작합니다.
includeMetadataChanges: true 옵션을 사용하면
캐시에서 온 데이터인지, 서버와 동기화된 데이터인지 구분할 수 있습니다.
onSnapshot(query, { includeMetadataChanges: true }, (snapshot) => {
snapshot.docs.forEach(doc => {
console.log("fromCache:", doc.metadata.fromCache);
console.log("hasPendingWrites:", doc.metadata.hasPendingWrites);
});
});
fromCache: true→ 로컬 캐시에서 읽어온 데이터hasPendingWrites: true→ 아직 서버에 전송되지 않은 로컬 쓰기
메모리 캐시 vs 영속 캐시
| 항목 | MemoryLocalCache | PersistentLocalCache |
|---|---|---|
| 저장 위치 | 메모리(RAM) | 디스크(모바일) / IndexedDB(웹) |
| 지속성 | 세션 종료 시 삭제 | 앱/브라우저 재시작 후에도 유지 |
| 사용 시나리오 | 짧은 세션, 민감 데이터 | 일반적인 오프라인 지원 |
일반적인 오프라인 사용 사례에서는 PersistentLocalCache가 권장됩니다.
로컬 쿼리 성능: PersistentCacheIndexManager
최근 SDK부터는 로컬 캐시에도 자동 인덱싱 기능이 추가되어 오프라인 상태에서의 쿼리 속도를 최적화할 수 있습니다.
// Android
FirebaseFirestore.getInstance()
.persistentCacheIndexManager
?.enableIndexAutoCreation()
// iOS
Firestore.firestore()
.persistentCacheIndexManager?
.enableIndexAutoCreation()
자주 쓰이는 쿼리에 대해 로컬 인덱스를 자동 생성해주므로 데이터가 많을 때 특히 효과적입니다.
주의해야 할 제약 사항
1. 트랜잭션은 캐시를 거치지 않는다
runTransaction은 서버와 직접 통신하면서 읽기→쓰기를 원자적으로 보장하는 작업이므로
로컬 캐시에 저장되지 않습니다. 따라서 오프라인 상태에서는 트랜잭션 자체가 실패합니다.
반면 배치 쓰기(WriteBatch)는 일반 쓰기와 동일하게
로컬 캐시에 먼저 저장된 뒤, 네트워크가 복구되면 서버로 전송됩니다.
// 트랜잭션 — 오프라인이면 실패 (캐시 저장 X)
db.runTransaction { transaction ->
val snapshot = transaction.get(docRef)
transaction.update(docRef, "count", snapshot.getLong("count")!! + 1)
}
// 배치 쓰기 — 오프라인이면 캐시에 대기 후 자동 동기화
db.batch().apply {
set(docRef1, data1)
update(docRef2, data2)
}.commit()
정리하면,
| 방식 | 오프라인 동작 | 캐시 저장 |
|---|---|---|
runTransaction |
❌ 실패 | ❌ 저장 안 됨 |
WriteBatch |
✅ 대기 후 자동 동기화 | ✅ 저장됨 |
일반 set / update / delete |
✅ 대기 후 자동 동기화 | ✅ 저장됨 |
오프라인 지원이 필요한 묶음 쓰기에는 반드시 배치 쓰기를 사용해야 합니다.
2. 보안 규칙은 서버에서만 평가
로컬에서는 보안 규칙이 적용되지 않습니다. 서버와 재연결된 시점에 평가되고, 규칙을 위반한 쓰기는 그때 실패합니다.
즉, 오프라인에서 쓰기가 성공한 것처럼 보여도 서버 동기화 단계에서 거부될 수 있다는 점을 감안해야 합니다.
3. 쿼리는 캐시된 문서 범위 안에서만 동작
오프라인에서는 서버에 있는 전체 데이터가 아니라 로컬에 캐시된 문서들에 대해서만 쿼리가 실행됩니다.
한 번도 읽어오지 않은 문서는 오프라인 쿼리에서 빠지므로 초기 동기화가 중요한 앱이라면 이 점을 고려해야 합니다.
네트워크 수동 제어
개발 중에 오프라인 동작을 테스트하거나, 데이터 사용량을 관리하기 위해 네트워크를 일시적으로 끊을 수 있습니다.
// Android
FirebaseFirestore.getInstance().disableNetwork()
.addOnCompleteListener { /* 이후 모든 읽기/쓰기는 캐시에서만 처리 */ }
FirebaseFirestore.getInstance().enableNetwork()
.addOnCompleteListener { /* 다시 서버와 동기화 시작 */ }
disableNetwork()를 호출하면 SDK가 오프라인 상태처럼 동작하므로
에뮬레이터에서 비행기 모드를 켜지 않고도 오프라인 시나리오를 재현할 수 있습니다.
대기 중인 쓰기가 모두 반영되었는지 기다리기
waitForPendingWrites()를 사용하면
로컬 캐시에 쌓인 모든 쓰기가 서버에 반영될 때까지 대기할 수 있습니다.
db.waitForPendingWrites()
.addOnSuccessListener { /* 모든 쓰기가 서버에 반영됨 */ }
로그아웃 직전이나 앱 종료 직전에 사용자 데이터가 유실되지 않도록 동기화를 기다릴 때 유용합니다.
캐시 전체 비우기
로그아웃이나 계정 전환 같은 상황에서 로컬 캐시에 남아 있는 이전 사용자 데이터를 모두 지워야 할 수 있습니다.
// SDK가 초기화되기 전에만 호출 가능
FirebaseFirestore.getInstance().clearPersistence()
.addOnCompleteListener { /* 캐시 삭제 완료 */ }
주의: clearPersistence()는 Firestore 인스턴스가 사용되기 전에만 호출할 수 있습니다.
이미 쿼리나 쓰기가 시작된 뒤에는 실패하므로, 앱 시작 시점에 처리해야 합니다.
언제 유용한가
- 지하철, 비행기처럼 네트워크가 불안정한 환경에서도 앱이 끊기지 않게 해야 할 때
- 사용자가 입력한 데이터를 네트워크 상태와 무관하게 즉시 저장하고 싶을 때
- 실시간 협업 앱에서 여러 탭/창이 같은 캐시를 공유해야 할 때
- 빠른 로컬 읽기를 통해 초기 렌더링 속도를 개선하고 싶을 때
정리
Firestore 오프라인 퍼시스턴스는 네트워크 상태와 무관하게 앱이 자연스럽게 동작하도록 해주는 기능입니다.
모바일(Android/iOS)에서는 기본 활성화 상태이므로 특별한 설정 없이도 동작하고,
웹에서는 persistentLocalCache로 명시적으로 켜야 합니다.
단, 트랜잭션 실패, 보안 규칙 평가 시점, 로컬 쿼리 범위 같은 몇 가지 제약을 알고 쓰는 것이 중요합니다.