채널톡이 Drive를 만들며 오프라인 퍼스트(local-first) 구조를 도입하고, 그 과정에서 자란 동기화 계층을 hearth라는 사내 npm 패키지로 독립시킨 이야기다. 화면이 서버 응답이 아니라 로컬 IndexedDB를 그리고 서버 동기화는 뒤에서 따라오게 만들었다. Linear·WatermelonDB·RxDB를 검토했지만 자체 API 서버를 쓰는 이상 서버 쪽 동기화는 어차피 직접 짜야 했고 이미 실전 검증된 코드가 있어 직접 만드는 쪽을 골랐다. LWW 충돌 해소와 낙관적 쓰기 보호에서 실제로 터진 버그 두 종류를 함께 다룬다.
핵심 포인트- 오프라인 퍼스트는 "서버 응답을 화면에 그린다"를 "로컬 DB를 그리고 서버와 DB를 맞춘다"로 바꾸는 구조다. 즉시 렌더, 오프라인 읽기·쓰기, 소켓 이벤트의 자동 반영을 얻는다.
- 기존 라이브러리를 쓰든 안 쓰든 자체 API 서버라면 pull/push 엔드포인트는 직접 구현해야 하고, RxDB의 다중 스토리지 추상화는 저장소가 하나뿐인 상황에서는 군더더기가 된다.
- 충돌은 Linear처럼 LWW로 풀되 서버가 매긴 순번 대신 타임스탬프를 비교한다. 실시간 소켓을 쓰는 순간 규칙 없이는 화면 자체를 못 그린다.
- LWW를 택하면 정합성 책임이 프론트엔드를 넘어 서버 코드까지 확장된다. 서버가 값만 바꾸고 타임스탬프를 안 올리면 그 값은 기존 사용자 화면에 영영 반영되지 않는다.
- 동기화 필요 여부를 과거 기록으로 판단하면 캐시 축출과 만나 영수증만 남고 물건이 사라진다. 판단 근거를 행 자신에 새겨 대상과 수명을 묶었다.
상세 정리- 출발점: Linear가 이슈 목록에서 상세로, 상세에서 보드로 넘어갈 때 스피너가 없는 이유는 서버 응답을 기다리지 않고 화면에 필요한 데이터가 이미 브라우저 안에 있기 때문이다.
- 얻는 것 네 가지: 재방문·새로고침에서 스피너 제거, 지하철·비행기에서도 읽기와 쓰기, 클릭 즉시 반영 후 실패 시 되돌림, 소켓 이벤트를 DB에 쓰면 화면이 자동 갱신. 개발 측면에서는 화면마다 동기화 로직을 다시 짤 필요가 없다.
- 기존 해법 비교: Linear는 IndexedDB에 서버가 매긴 전역 sync 번호 기반 LWW, WatermelonDB는 SQLite 또는 LokiJS 경유 IndexedDB에 컬럼 단위 client-wins, RxDB는 체크포인트 기반 pull/push에 클라이언트 conflict handler다.
- 계열 구분: Replicache를 만든 Aaron Boodman의 분류로 server-authority 계열(Replicache·Zero·PowerSync)과 decentralized·CRDT 계열(Yjs·Automerge)이 있는데, hearth를 쓰는 제품은 공동 편집기가 아니라 문서·파일을 다뤄 동시 편집이 드물어 전자를 골랐다.
- 추출 시점: 사내 프로젝트 하나에서 로컬 퍼스트 구조를 먼저 실험하며 동기화 계층이 자랐고, 성급히 추상화하지 않고 같은 필요가 실제로 반복되는 것을 확인한 뒤 패키지로 떼어냈다.
- 기반 선택: IndexedDB를 직접 다루지 않고 Dexie 위에 올렸다. 반응형 쿼리를 기본 제공하고 애드온으로 확장되기 때문이며, 외부 의존은 사실상 Dexie와 그 주변뿐이다.
- 내부 구조: core와 sync 두 계층으로 나누고 경계 기준은 "이 개념이 서버 없이도 말이 되는가"다. core에는 hooks·eviction·plugin이, 그 위 sync에 서버 fetch와 push가 붙는다.
- hooks: Dexie의 생성·수정 가로채기 훅에 LWW 비교, 낙관적 쓰기 보호, 동기화 시각 기록을 끼워 넣는다. 제품 코드는 테이블 정의 시 옵션만 채우면 해당 훅이 자동 연결된다.
- eviction: 테이블 단위 캐시 수명 정책이며 선택이 아니라 필수 옵션이다. 아무도 안 정하면 브라우저 저장 공간이 감당 못 할 규모로 캐시가 쌓인다.
- plugin: 테이블 하나가 아니라 DB 전체 생애주기에 반응한다. 초기화 성공, 저장 공간 부족, 앱 종료, 오프라인 중 축출 중단처럼 상태 변화 단위 동작을 담당한다.
- 두 개의 시계: "언제 이 데이터를 로컬에 담았나"와 "언제 서버와 마지막으로 확인했나"는 다르다. 낙관적 쓰기 순간에는 전자만 갱신되고 후자는 서버 확인이 올 때까지 그대로이며, sync는 후자를 보고 재요청 여부를 판단한다.
- 실전 버그 1: Drive에서 항목 생성자 이름이 옛 값으로 계속 보였다. 새 브라우저나 시크릿 모드에서는 정상이고 에러 로그도 없었다. 서버가 응답 값만 바꾸고 타임스탬프를 갱신하지 않아 LWW가 새 값을 더 오래된 것으로 판정해 무시한 결과였고, 로컬 DB를 지우고 새로고침해야 정상으로 돌아왔다.
- 낙관적 쓰기 보호: 사용자가 바꾼 직후 그 변경 이전 시점의 서버 응답이 늦게 도착하면 방금 값을 조용히 덮는다. hearth는 이를 필드 단위로 풀어, 로컬에서 바꿨지만 서버 확인을 못 받은 필드를 추적하고 미확인 응답이 그 필드를 되돌리지 못하게 막는다.
- 플래그를 안 쓴 이유: "지금 사용자 입력 처리 중" 같은 별도 상태 플래그는 켜고 끄는 타이밍을 수동 관리해야 해 경쟁 상태를 스스로 만든다. 대신 들어오는 데이터 자체로 서버 응답인지 사용자 변경인지 판별하고, 근거를 행마다 붙여 서버 확인이 오면 자동으로 사라지게 했다.
- 실전 버그 2와 재설계: 데이터가 있어야 할 자리에 없거나 없어야 할 자리에 남는 사고가 났다. 원인은 "다시 받아와야 하는가"라는 현재 상태 질문을 "예전에 동기화한 적 있다"는 과거 기록으로 판단한 범주 오류였고, 캐시 축출이 정확히 그 결과물을 지우는 장치라 기록만 남고 데이터는 사라진 결손이 영구화됐다.
- 고친 방향: "언제 마지막으로 확인했는가"를 별도 장부가 아니라 행 자신에 새겨 행이 지워지면 기록도 함께 지워지게 했고, 판단 기준을 과거 기록 조회에서 "행이 존재하고 신선한가"로 바꿔 도메인 코드가 손댈 수 없게 라이브러리 안으로 회수했다. 오프라인 중 캐시 축출 금지도 전제 조건으로 승격했다.
- 같은 버그 클래스가 PouchDB에도 체크포인트 기록은 남는데 문서가 지워져 재복제가 막히는 공식 이슈로 존재한다. 판단 근거와 대상의 수명을 묶고, 게이트를 진실의 원천으로 삼지 말라는 결론으로 정리했다.
- 남은 과제: 지금은 화면 진입마다 데이터를 통째로 다시 요청해 네트워크 요청 자체는 줄지 않는다. delta sync가 필요한데, "마지막 수신 시각 이후만 달라"는 방식은 타임스탬프가 트랜잭션 시작 시점에 찍히고 가시성은 커밋 이후라 락 대기로 간격이 벌어지면 먼저 시작해 늦게 커밋된 변경이 영원히 누락될 수 있다. 안전한 커서는 DB 커밋 로그이거나 서버가 커밋 직후 순서대로 발급하는 값이어야 해 백엔드와 함께 시작하는 단계다.
왜 읽나로컬 퍼스트 구조를 자체 API 서버 위에 얹으려는 프론트엔드 개발자가 LWW의 서버 측 계약, 낙관적 쓰기 보호, 캐시 축출과 동기화 게이트의 상호작용을 미리 짚을 수 있다.