오프라인 우선 동기화는 앱이 로컬 저장소에서 먼저 읽고 쓴 뒤, 온라인이 되면 서버와 차이를 조정하는 아키텍처입니다. 네트워크는 의존성이 아니라 백그라운드 프로세스가 됩니다. 모든 화면은 기기에서 렌더링되고, 모든 탭은 기기에 커밋되며, 동기화 계층은 연결이 허락할 때마다 백엔드와 장부를 맞춥니다. 어려운 부분은 처음부터 데이터를 로컬에 저장하는 일이 아니었습니다. 진실의 두 사본이 서로 어긋날 때 어떻게 되느냐가 어려운 부분입니다.
핵심 요약
| 질문 | 답변 |
|---|---|
| 핵심 동작 | 화면은 기기 로컬 저장소와 통신하고, 네트워크는 백그라운드에서 동기화 |
| 두 가지 태도 | 버퍼로서의 로컬 저장소(진실은 서버 소유) 또는 복제본으로서의 로컬 저장소(진실은 협상) |
| 오프라인 쓰기 | 영속적인 아웃박스 큐 → 재연결 시 순서대로 재생 |
| 충돌 | 마지막 쓰기 우선 · 필드 수준 병합 · CRDT – 앱 단위가 아니라 필드 단위로 선택 |
| 솔직한 비용 | 동기화 장치는 영구적인 복잡성 – 오프라인이 중요한 곳에만 투자 |
고정, 읽기, 큐: 코드로 보는 오프라인 루프
이 패턴의 모바일 SDK 버전입니다. 화면에 필요한 것은 고정하고, 사용자가 바꾸는 것은 큐에 쌓습니다:
// JavaScript — Back4app JS SDK with the Local Datastore enabled
Parse.enableLocalDatastore();
// Read: serve the screen from the local store — instant, works offline
const query = new Parse.Query('Task');
query.fromLocalDatastore();
render(await query.find());
// Write: durable locally now, queued for the server automatically
const task = new Parse.Object('Task');
task.set('title', 'Inspect site 14');
task.set('done', false);
await task.pin(); // survives restarts, feeds local reads
task.saveEventually(); // replays when the network returns // Flutter / Dart — Back4app Flutter SDK with local storage enabled
// Parse().initialize(..., coreStore: await CoreStoreSembastImp.getInstance())
// Read: serve the screen from pinned data — instant, works offline
final pinned = await ParseObject('Task').fromPin('openTasks');
render(pinned);
// Write: durable locally now, synced when the network allows
final task = ParseObject('Task')
..set('title', 'Inspect site 14')
..set('done', false);
await task.pin(); // survives restarts, feeds local reads
final response = await task.save();
if (!response.success) retryLater(task); // replay from the outbox // iOS / Swift — Back4app iOS SDK with the Local Datastore enabled
// (in the app delegate) Parse.enableLocalDatastore()
// Read: serve the screen from the local store — instant, works offline
let query = PFQuery(className: "Task")
query.fromLocalDatastore()
query.findObjectsInBackground { tasks, _ in
render(tasks)
}
// Write: durable locally now, queued for the server automatically
let task = PFObject(className: "Task")
task["title"] = "Inspect site 14"
task["done"] = false
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns // Android / Kotlin — Back4app Android SDK with the Local Datastore enabled
// (before Parse.initialize) Parse.enableLocalDatastore(context)
// Read: serve the screen from the local store — instant, works offline
val query = ParseQuery.getQuery<ParseObject>("Task")
query.fromLocalDatastore()
query.findInBackground { tasks, _ ->
render(tasks)
}
// Write: durable locally now, queued for the server automatically
val task = ParseObject("Task")
task.put("title", "Inspect site 14")
task.put("done", false)
task.pinInBackground() // survives restarts, feeds local reads
task.saveEventually() // replays when the network returns 두 번의 호출이 아키텍처를 떠받칩니다. 고정(pin)은 객체를 기기에서 영속적이고 쿼리 가능하게 만듭니다. 서버와 같은 쿼리 인터페이스를 로컬 스토리지에 겨눈 것입니다(위 SDK에서는 Local Datastore). Save-eventually는 쓰기를 지금 받아들이고, 그 전달은 나중에 책임집니다. 이 글의 나머지는 모두 이 두 호출 사이에서 일어나는 일입니다.
버퍼인가, 진실의 원천인가? 결정 아래의 결정
모든 오프라인 우선 설계는 로컬 저장소에 대해 두 가지 태도 중 하나를 조용히 고르며, 동기화의 고통 대부분은 이를 의도적으로 고르지 않은 데서 옵니다.
버퍼로서의 로컬 저장소. 서버가 권위를 유지하고, 기기는 작업 사본과 의도를 담은 아웃박스를 가집니다. 충돌은 서버에 유리하게 또는 단순한 정책으로 해결되며, 기기는 언제든 다시 가져오기로 복구할 수 있습니다. CRM 체크리스트, 현장 점검, 주문 입력 같은 비즈니스 앱에 맞는 기본값입니다. 진실이 한곳에 있고 기기는 그저 뒤따라갈 뿐이므로 추론이 단순하게 유지되기 때문입니다.
복제본으로서의 로컬 저장소. 진실은 피어 사이에서 협상되며, 서버는 여러 복제본 중 하나일 뿐입니다. 무엇이든 병합해 준다고 약속하는 협업 편집기와 노트 앱의 태도입니다. 이 방식은 복원력과 사용자 신뢰를 얻는 대신 진짜 분산 시스템 장치를 대가로 치릅니다. 동시성을 감지하는 버전 벡터, 데이터 타입별 병합 함수, 그리고 정직하게 내세울 수 있는 가장 강한 보장인 최종 일관성(eventual consistency)입니다.
태도는 앱 단위가 아니라 데이터셋 단위로 정합니다. 같은 현장 서비스 앱이라도 기술자 자신의 작업 지시서는 복제본으로(지하에서 하루 종일 편집할 수 있어야 하므로), 부품 카탈로그는 리드스루(read-through) 버퍼로 다룰 수 있습니다.
동기화 루프, 처음부터 끝까지
루프에는 아웃바운드 절반과 인바운드 절반이 있고, 둘은 충돌 해결에서 만납니다.
아웃바운드: 대기열에 쌓인 쓰기와 재생. 오프라인 쓰기는 로컬에 커밋되고 영속적인 아웃박스에 추가됩니다. 인터페이스는 의도를 즉시 반영하고, 중요한 곳에는 “대기 중”이라고 정직하게 표시합니다. 재연결되면 큐가 순서대로 재생됩니다. 엔지니어링의 핵심은 실패 모드에 있습니다. 일시적인 오류는 백오프와 함께 재시도하고, 권한 및 검증 거부는 끝없이 재시도하지 말고 사용자에게 드러내야 하며, 연산은 멱등적이어야 합니다. 확인 응답이 유실되면 같은 연산이 두 번 도착할 수 있기 때문입니다. “상태를 완료로 설정”을 두 번 재생하는 것은 무해하지만, “재고를 3만큼 증가”를 두 번 재생하는 것은 버그입니다.
인바운드: 델타 가져오기. 재연결할 때마다 전부 다시 가져오면 대역폭과 배터리를 낭비합니다. 프로덕션 동기화는 체크포인트 이후의 변경 사항을 가져옵니다. 단순한 설계에서는 updated-at 워터마크, 더 강한 설계에서는 서버 시퀀스 번호나 변경 피드입니다. 앱이 온라인일 때 라이브 쿼리를 구동하는 바로 그 이벤트가 인바운드 델타 스트림 역할도 하며, 그래서 오프라인 동기화와 실시간 동기화는 두 개의 기능이 아니라 하나의 연속선입니다.
마지막 쓰기 우선 vs. 병합 vs. CRDT
| 전략 | 해결 방식 | 데이터 손실? | 비용 | 적합한 대상 |
|---|---|---|---|---|
| 마지막 쓰기 우선 | 가장 새로운 타임스탬프/버전이 레코드를 차지 | 있음 – 조용히 | 미미함 | 작성자가 하나인 레코드, 토글, 상태 |
| 필드 수준 병합 | 겹치지 않는 필드 편집은 둘 다 유지, 같은 필드 충돌에는 정책 적용 | 같은 필드 충돌 시에만 | 중간 | 소수가 편집하는 폼과 레코드 |
| 수동 해결 | 두 버전을 모두 보관하고 사람이 선택 | 없음 | 인터페이스 + 워크플로 | 위험도가 높은 레코드, 동기화 콘솔 |
| CRDT / CRDT-lite | 연산이 결정론적으로 병합되는 데이터 타입 | 없음 | 모델링 규율, 라이브러리 무게 | 카운터, 집합, 협업 구조 |
이 표에 대한 솔직한 메모 두 가지입니다. 첫째, 타임스탬프는 보기보다 불안정합니다. 기기 시계는 어긋나므로, 제대로 된 마지막 쓰기 우선 구현은 서버 수신 시각이나 논리적 버전을 사용합니다(동시 편집을 추측이 아니라 감지해야 한다면 버전 벡터). 둘째, 모바일 앱의 실용적인 최적점은 CRDT-lite입니다. 완전한 CRDT 라이브러리는 무거운 장치지만, 필드별 아이디어만 빌려 오는 것은 저렴합니다. 수량을 저장된 합계 대신 증가 연산으로, 태그를 배열 대신 추가/삭제 집합으로 모델링하면 그 필드들은 더 이상 충돌하지 않습니다. 전략은 필드 단위로 정합니다. 하나의 전역 정책을 고른 앱은 대개 어떤 필드에 대해서는 잘못 고른 것입니다.
일반적인 사용 사례
- 현장 업무 – 점검, 배송, 유틸리티 작업. 작업 현장이야말로 신호가 끊기는 곳이므로 데이터 수집은 로컬에서, 동기화는 기회가 될 때 이루어져야 합니다.
- 노트 작성과 개인 생산성 – 한 명의 사용자, 여러 기기. 충돌이 드물고 대부분 스스로 만든 것이라 병합 정책을 다루기 쉽습니다.
- POS와 키오스크 앱 – 라우터가 재부팅되는 동안에도 줄은 계속 움직여야 하며, 트랜잭션은 연결이 돌아오면 재생됩니다.
- 여행 관련 앱 – 일정, 지도, 티켓은 비행기, 기차, 로밍처럼 연결이 가장 나쁜 곳에서 정확히 사용됩니다.
- 신흥 시장과 저대역폭 제품 – 간헐적이고 종량제인 연결에서는 플랫폼 구성이 무엇이든 백그라운드 델타 동기화만이 사용자를 존중하는 설계입니다.
오프라인 우선으로 구축해야 할까? 결정 매트릭스
| 오프라인 우선으로 구축할 때… | 온라인 우선을 유지할 때… |
|---|---|
| 사용자가 현장, 이동 중, 지하실처럼 신호가 끊기는 곳에서 일할 때 | 사용자가 사실상 항상 연결되어 있을 때(데스크톱 웹, 사무용 도구) |
| 막힌 탭 하나가 돈이나 신뢰를 잃게 할 때(POS, 현장 데이터 수집) | 정확성에 지금 하나의 권위 있는 상태가 필요할 때 – 결제, 예약, 재고 |
| 데이터가 동시 작성자가 적은 사용자 소유 레코드일 때 | 데이터가 서버에서 계산되거나 공유되며 자주 바뀔 때(피드, 대시보드) – 읽기는 캐시하고 쓰기는 온라인 유지 |
| 쓰기가 나중의 조정을 감당할 수 있을 때 | 두 기기가 오래된 진실로 행동하는 것이 현실의 위험일 때 |
| 동기화 계층의 영구적인 유지 비용을 감당할 수 있을 때 | 로딩 상태로도 솔직히 충분할 때 |
중간 경로도 정당하고 흔합니다. 즉각적인 화면을 위해 읽기를 캐시하고, 정말 필요한 소수의 데이터셋에만 오프라인 쓰기를 허용하며, 나머지는 정직한 연결 상태 표시와 함께 온라인 우선으로 유지하는 방식입니다.
한계와 트레이드오프
- 충돌 해결은 엔지니어링의 옷을 입은 제품 결정입니다. “어떤 편집이 살아남는가?”에는 기술적으로 올바른 답이 없습니다. 누군가 필드별로 정책을 책임져야 하며, “조용히 최신 것을 유지”하는 것도 피해자가 있는 선택입니다.
- 아웃박스는 부채의 큐입니다. 2주 만에 다시 나타난 기기의 오래된 대기 쓰기는 바뀐 세상에 재생될 수 있습니다. 만료 정책과 재생 시 검증은 과민함이 아니라 필수입니다.
- 동기화 버그는 가장 고약한 유형의 버그입니다. 편집과 연결이 특정 순서로 얽힐 때만 재현됩니다. 결정론적인 재생 테스트와 눈에 보이는 동기화 상태 표시에 투자하지 않으면, 영원히 일화에 기대어 디버깅하게 됩니다.
- 로컬의 진실은 낡아 갑니다. 가격, 재고처럼 중요한 곳에서는 화면이 데이터가 오래되었음을 정직하게 알려야 합니다. “2시간 전 기준”은 사과가 아니라 기능입니다.
- 스토리지와 ID가 부담을 더합니다. 기기 저장소도 여느 데이터베이스처럼 마이그레이션이 필요하고, 오프라인에서 생성된 레코드에는 서버까지 가는 동안 중복되지 않고 살아남는 클라이언트 생성 ID가 필요합니다.
Back4app에서의 오프라인 우선 동기화
Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. 모바일 SDK는 버퍼 태도를 바로 쓸 수 있는 형태로 제공합니다. Local Datastore는 모든 쿼리의 결과를 고정해 같은 쿼리 API로 오프라인에서 읽을 수 있게 하고, saveEventually는 모든 쓰기에 순서대로 재생되는 영속적인 아웃박스를 제공하며, Cloud Code beforeSave 트리거는 버전 확인, 병합 규칙, 재생 검증 같은 서버 측 충돌 정책을 모든 쓰기 경로에 적용하기에 자연스러운 자리입니다. 앱이 온라인일 때 인바운드 델타를 위해 Live Query 스트림을 함께 쓰면, 위 다이어그램의 동기화 루프는 직접 만드는 하위 시스템이 아니라 설정과 정책의 문제가 됩니다.
자주 묻는 질문
오프라인 우선 앱이란 무엇입니까?
화면이 기기의 데이터베이스를 읽고 쓰며, 네트워크는 동기화의 세부 사항으로 격하된 앱입니다. 상호작용 경로에서 서버를 기다리는 것이 없으므로 비행기 모드에서도 모든 탭이 작동하고, 백그라운드 동기화 계층이 연결이 허락할 때마다 로컬 저장소와 백엔드의 차이를 조정합니다. 서버가 응답할 때까지 스피너를 보여 주는 온라인 우선 앱과 대비됩니다.
오프라인 데이터 동기화는 어떻게 작동합니까?
두 개의 루프로 작동합니다. 아웃바운드 쪽에서는 로컬 쓰기가 기기 저장소와 영속적인 아웃박스 큐에 기록되고, 네트워크가 돌아오면 큐가 서버를 상대로 순서대로 재생됩니다. 인바운드 쪽에서는 클라이언트가 마지막 동기화 이후의 변경 사항을 타임스탬프, 시퀀스 번호, 변경 피드로 가져와 로컬에 적용합니다. 충돌 해결은 두 루프가 만나는 지점에 있으며, 양쪽이 같은 레코드를 수정했을 때 무엇이 일어날지 결정합니다.
마지막 쓰기 우선(last-write-wins)이란 무엇이며, 언제 안전합니까?
가장 단순한 충돌 정책입니다. 타임스탬프나 버전을 비교해 가장 새로운 쓰기를 남기고, 다른 쓰기는 조용히 버립니다. 편집이 레코드 전체 단위이고 위험도가 낮을 때(설정 토글, 상태 플래그), 또는 레코드마다 쓰는 사람이 한 명인 것이 일반적일 때 안전합니다. 공유 문서와 카운터에는 위험합니다. 그곳에서 "다른 편집을 버린다"는 것은 누군가의 작업을 잃는다는 뜻입니다.
CRDT는 충돌을 어떻게 해결합니까?
충돌을 아예 표현할 수 없게 만듭니다. 충돌 없는 복제 데이터 타입(CRDT)은 각 필드를 도착 순서와 무관하게 결정론적으로 병합되는 연산으로 제한합니다. 증가만 하는 카운터, 추가/삭제 집합, 마지막 작성자 레지스터가 그 예입니다. 상태를 교환하는 두 복제본은 조정 없이도 항상 수렴합니다. 대가는 모델링 규율입니다. 데이터를 CRDT의 어휘로 표현해야 하며, 완전한 CRDT 라이브러리는 일반적인 폼과 목록 중심의 모바일 앱에는 무겁습니다.
오프라인 상태에서 한 쓰기는 어떻게 됩니까?
큐에 쌓입니다. 영속적인 아웃박스가 대기 중인 각 연산을 기록하고, 로컬 저장소는 쓰기를 즉시 반영하므로 인터페이스는 사용자의 의도를 정직하게 보여 줍니다. 재연결되면 큐가 순서대로 재생됩니다. 실패에는 정책이 필요합니다. 일시적인 오류는 재시도하고, 권한 및 검증 거부는 끝없이 재시도하는 대신 인터페이스로 다시 드러내야 합니다. 확인 응답이 유실되어도 연산이 멱등적이면 재생은 안전합니다.
오프라인 우선은 캐싱과 같습니까?
아니요. 차이는 방향입니다. 캐시는 읽기를 가속합니다. 서버가 진실의 원천으로 남고, 오래된 항목은 다시 가져오기만 하면 됩니다. 오프라인 우선은 쓰기까지 로컬에서 받아들이며, 바로 이것이 기기와 서버 사이의 불일치를 만들고 아웃박스 큐, 재생, 충돌 해결 같은 어려운 장치를 강제합니다. 읽기 캐싱은 성능 최적화이고, 오프라인 우선은 데이터 아키텍처에 대한 약속입니다.
어떤 경우에 오프라인 우선으로 구축하지 말아야 합니까?
정확성이 하나의 권위 있는 상태에 달려 있을 때입니다. 결제, 예약, 재고 차감처럼 두 기기가 오래된 데이터로 행동하면 현실 세계의 이중 지출이 생기는 모든 경우가 해당합니다. 데이터가 서버에서 계산되고 대부분 읽기 전용일 때도 마찬가지입니다. 뉴스 피드는 일반 캐시만으로도 연결이 끊겼을 때 무리 없이 버팁니다. 동기화 장치는 영구적인 세금이므로, 현장 조건이나 사용자 기대가 요구하는 곳에만 지불해야 합니다.
모바일 SDK는 데이터를 로컬에 어떻게 고정합니까?
고정(pinning)은 객체를 기기의 영속 스토리지에 두도록 표시합니다. 고정된 레코드는 재시작 후에도 남고, 서버와 같은 쿼리 인터페이스로 오프라인에서도 쿼리할 수 있으며, 서버 측 ID와 연결된 채로 유지되어 이후 동기화에서 제자리에서 갱신됩니다. 큐에 쌓이는 쓰기(save-eventually 방식)와 함께 쓰면, 기기에 데이터베이스 스키마를 직접 만들지 않고도 작동하는 오프라인 계층을 얻습니다.