1인 개발 앱을 위한 로컬 퍼스트 아키텍처 (Local-First Architecture): CRDT가 도움이 되는 경우와 해가 되는 경우
요약
로컬 퍼스트 아키텍처의 가치와 CRDT를 활용한 데이터 동기화 전략을 다룹니다. Yjs, Automerge, ElectricSQL 등 구체적인 도구를 통해 오프라인 환경에서도 지연 시간 없는 사용자 경험을 제공하는 방법을 설명합니다.
핵심 포인트
- 로컬 퍼스트는 체감 지연 시간을 0으로 만들어 사용자 경험을 극대화함
- CRDT는 충돌 없는 데이터 병합을 보장하는 핵심 기술임
- 오프라인 기능이 핵심인 앱에만 로컬 퍼스트 도입을 권장함
- Yjs, Automerge, ElectricSQL은 구현 시간을 단축해주는 유용한 도구임
-
로컬 퍼스트 (Local-first) 방식은 3대의 기기에서 내 노트 앱의 체감 지연 시간 (perceived latency)을 0으로 만들었습니다.
-
Yjs와 Automerge는 실제로 발생하지 않을 수도 있는 동기화 충돌 (sync conflicts)을 해결합니다.
-
ElectricSQL은 CRDT 배관 (plumbing)을 직접 구현하는 대신 6주의 시간을 아껴주었습니다.
-
먼저 일반적인 서버 방식을 출시하고, 오프라인 기능이 핵심 기능일 때만 로컬 퍼스트를 추가하세요.
저는 로컬 퍼스트 아키텍처 (local-first architecture)를 적용한 앱 두 개와 적용하지 않은 앱 하나를 만들었습니다. 로컬 퍼스트가 필요했던 앱은 3대의 기기에서 로딩 스피너 (spinner) 없이 즉각적으로 작동하는 느낌을 주었습니다. 반면, 필요하지 않았던 앱은 많은 추가 노력만 들었을 뿐 사용자에게 아무런 이득도 주지 못했습니다. 그 경계선이 실제로 어디에 있는지 소개합니다.
로컬 퍼스트가 실제로 제공하는 가치
로컬 퍼스트 (Local-first)란 앱이 먼저 로컬 저장소 (local store)에 데이터를 쓰고, 해당 저장소로부터 화면을 렌더링하며, 백그라운드에서 서버와 동기화 (sync)하는 것을 의미합니다. 사용자는 네트워크를 기다릴 필요가 없습니다. 이것이 핵심 제안이며, 상황에 적합할 때는 진정으로 마법 같습니다.
저는 이런 방식으로 노트 필기 앱을 만들었습니다. 모든 키 입력 (keystroke)은 IndexedDB에 기록됩니다. UI는 해당 복사본을 기반으로 렌더링됩니다. 동기화는 백그라운드에서 큐 (queue)를 통해 이루어집니다. 체감 지연 시간 (perceived latency)이 0으로 떨어졌습니다. 신호가 없는 기차 안에서도 앱은 여전히 작동합니다. 휴대폰이 다시 연결되면 변경 사항이 병합되어 1~2초 내에 제 노트북에 나타납니다. 사용자들도 이를 체감했습니다. 오프라인 사용 비중이 높은 코호트 (cohort)의 유지율 (retention)은 제가 처음에 출시했던 온라인 전용 버전보다 약 40% 높았습니다.
제가 사용한 세 가지 도구는 Yjs, Automerge, 그리고 ElectricSQL이었습니다. Yjs는 텍스트와 공유 문서에 최적화된 CRDT 라이브러리입니다. Automerge는 구조화된 JSON에 대해 더 나은 데이터 모델을 가진 보다 일반적인 CRDT입니다. ElectricSQL은 Postgres 위에 구축되어 로컬 SQLite 복제본 (replica)을 동기화하며, 이는 매우 다른 형태의 솔루션입니다.
이 세 가지 도구의 핵심 아이디어는 충돌 없는 복제 데이터 타입 (Conflict-free Replicated Data Type, CRDT)입니다. CRDT는 두 기기가 오프라인 상태에서 동일한 항목을 편집한 후, 서버가 승자를 결정하지 않고도 나중에 병합할 수 있는 데이터 구조입니다. 잠금 (lock)도 없고, 마지막 쓰기 승리 (last-write-wins)로 인한 데이터 덮어쓰기도 없습니다. 수학적으로 두 편집 사항이 합리적인 순서로 모두 생존함을 보장합니다.
그 보장은 정확히 한 가지 유형의 문제에 대해 강력한 힘을 발휘합니다. 바로 여러 기기나 사람들이 때때로 오프라인 상태인 동안 동일한 가변 상태 (mutable state)를 편집하는 경우입니다. 협업 노트 (Collaborative notes), 휴대폰에서 필터를 토글하면 데스크톱에도 적용되기를 기대하는 멀티 디바이스 대시보드, 혹은 상태가 좋지 않은 호텔 네트워크에서 두 사람이 동시에 건드리는 공유 작업 목록 같은 것들 말이죠.
만약 당신의 앱이 이와 같다면, 로컬 퍼스트 (local-first)는 오버헤드가 아닙니다. 그것이 바로 핵심 기능 (feature)입니다. 사용자들은 이를 속도와 신뢰성으로 느끼며, 이는 사용자들이 실제로 주의를 기울이는 두 가지 요소입니다. 만약 제가 이러한 도구를 만들 때 사용하는 더 넓은 의사결정 프레임워크를 알고 싶다면, Claude Blueprint에서 제가 동기화 (sync) 코드를 한 줄도 쓰기 전에 어떻게 빌드 범위를 설정하는지 살펴볼 수 있습니다.
CRDT가 아무런 소득 없이 낭비되는 경우
두 번째 앱은 작은 커머스 대시보드였습니다. 사용자는 한 명이었고, 대부분의 경우 기기도 하나였으며, 데이터는 서버에 존재하고 쓰기보다는 읽기가 훨씬 더 많았습니다. 저는 방금 노트 앱을 마친 상태였고 이것이 현대적인 방식처럼 느껴졌기에 CRDT를 선택했습니다. 그것은 많은 노력을 낭비하게 만든 실수였습니다.
CRDT는 공짜가 아닙니다. 압축 (compact)하지 않으면 문서의 크기가 계속 커집니다. 제가 만든 Automerge 문서들은 히스토리가 누적되었고, 5,000개의 항목이 담긴 리스트는 디스크 상에서 수 메가바이트(MB)로 불어났습니다. 편집 사항을 누락하지 않으면서 제대로 구현하기 위해 상당한 노력이 들어가는 스냅샷 및 절단 (snapshot-and-truncate) 루틴을 추가해야 했습니다. Yjs는 업데이트 인코딩 (update encoding)을 통해 이를 더 잘 처리하지만, 여전히 원하는 것보다 가비지 컬렉션 (garbage collection)에 대해 더 많이 고민해야 합니다.
또한 정신적인 비용 (mental tax)도 따릅니다. 모든 상태 조각이 CRDT 타입이 됩니다. items.push(x)라고 쓰는 대신, 자체 API를 가진 공유 배열에 쓰는 방식을 시작하게 됩니다. 이상한 순서를 만들어낸 병합 (merge)을 디버깅한다는 것은 새벽 1시에 툼스톤 (tombstones)과 램포트 타임스탬프 (Lamport timestamps)에 대해 읽고 있다는 것을 의미합니다. 동시 편집이 전혀 없는 앱에 있어서, 이 모든 과정은 제가 겪지도 않은 문제를 해결하기 위한 것이었습니다.
정직한 테스트 방법은 단 하나의 질문입니다: 한 명의 사용자가 오프라인 상태인 동안, 두 명의 작성자가 동일한 레코드에 접근하는 경우가 실제로 발생하는가? 제 대시보드의 경우 답은 '아니오'였습니다. 낙관적 UI (Optimistic UI)를 적용한 일반적인 서버만으로도 1%의 노력만 들여서 동일한 사용자 경험을 줄 수 있었을 것입니다. 낙관적 UI (Optimistic UI)란 예상되는 결과를 즉시 렌더링하고, 서버의 응답이 오면 이를 조정 (reconcile)하는 것을 의미합니다. 이는 병합 메커니즘 (merge machinery) 없이도 로컬 퍼스트 (local-first)의 절반인 "즉각적인 느낌"을 충족시켜 줍니다.
이제 저만의 규칙이 생겼습니다. 읽기 작업이 지배적이고 쓰기 작업이 단일 작성자(single-writer)인가? 그렇다면 CRDT를 건너뛰세요. 오프라인 지원이 있으면 좋지만 필수 사항은 아닌가? 그렇다면 CRDT를 건너뛰세요. 데이터가 서버 소유이며 서버가 신뢰할 수 있는 단일 원천 (source of truth)인가? 그렇다면 CRDT를 건너뛰고, 캐시 (cache)와 변이 큐 (mutation queue)를 사용하세요.
함정은 CRDT가 지적으로 흥미롭다는 점인데, 흥미로운 것이 반드시 필요하다는 뜻은 아닙니다. 저는 이를 스스로 증명하기 위해 실제 많은 노력을 허비했습니다. 먼저 지루한 버전부터 만드세요. 만약 사용자들이 병합 (merging)만이 해결할 수 있는 벽에 부딪힌다면, 당신은 그것을 알게 될 것이고, 그때 직감이 아닌 실제 근거를 바탕으로 이를 추가하면 됩니다.
Yjs, Automerge, ElectricSQL 중 선택하기
이 세 가지는 하나로 묶여 취급되곤 하지만, 서로 다른 형태의 문제를 해결하며, 잘못된 선택을 하는 것이 바로 노력이 낭비되는 지점입니다.
Yjs는 핵심 객체가 텍스트 문서이거나 공유 편집 영역일 때 제가 선택하는 도구입니다. 매우 작고 빠르며, 주변 생태계가 성숙해 있습니다. 일반적인 에디터들을 위한 준비된 바인딩 (bindings)이 있으며, 직접 실행할 수 있는 WebSocket 동기화용 프로바이더 (provider)도 있습니다. 만약 제가 협업 글쓰기 도구와 유사한 것을 만든다면, Yjs가 기본값이 될 것입니다. 업데이트 형식이 압축되어 있어, 편집 내용이 빈번하더라도 대역폭 (bandwidth)을 낮게 유지할 수 있습니다. 트레이드오프 (tradeoff)는 데이터 모델이 더 낮은 수준 (lower-level)이라는 점입니다. 공유 맵 (maps), 배열 (arrays), 텍스트 타입 (text types)을 다루게 되며, 그 위에 앱 모델을 구축해야 합니다.
상태가 구조화된 JSON 형태이고 CRDT가 일반 객체 (plain object)를 편집하는 것처럼 느껴지기를 원한다면 Automerge가 선택지입니다. 자유 형식의 텍스트 (freeform text)보다는 앱 데이터 (설정, 리스트, 레코드)를 다룰 때 개발자 경험 (developer experience)이 더 좋습니다. 앞서 언급했듯이, 대가로는 문서 크기 (document size)와 대규모 확장 시의 성능 (performance at scale)이 있습니다. Automerge 2가 이 부분을 크게 개선했지만, 저는 여전히 수천 개의 항목이 포함된 데이터의 바이트 수 (byte count)를 주의 깊게 살핍니다.
ElectricSQL은 독특한 위치에 있으며, 1인 개발자에게는 종종 가장 현명한 선택이 됩니다. 모든 것을 CRDT로 모델링하게 만드는 대신, 로컬 SQLite 데이터베이스를 Postgres 서버와 동기화합니다. 로컬에서 일반적인 SQL을 작성하면 백그라운드에서 복제(replicate)가 이루어지며, 충돌 해결 (conflict resolution)은 내부적으로 처리됩니다. 노트 앱의 경우, 구조화된 메타데이터 (폴더, 태그, 타임스탬프)를 ElectricSQL로 옮기고 노트 본문만 Yjs에 남겨두었더니 동기화 코드가 대략 절반으로 줄었습니다. 이 조합 덕분에 모든 것에 대해 CRDT 레이어를 직접 구현하는 것보다 약 6주 정도의 시간을 아낄 수 있었습니다.
저의 의사 결정 트리 (decision tree)는 간단합니다. 자유 형식의 협업 텍스트인가요? Yjs입니다. 오프라인 병합 (offline merge)이 필요하고 객체 모델 (object model)을 원하는 구조화된 앱 상태인가요? Automerge입니다. 대부분 서버 소유이지만 오프라인에서 작동해야 하는 관계형 데이터 (relational data)인가요? ElectricSQL입니다. 이 중 두 가지를 섞어서 사용하는 것은 일반적이며 종종 올바른 방법이기도 합니다. 저는 이를 단 하나의 승자를 뽑는 선택으로 취급하지 않습니다.
무엇을 선택하든, 첫날에 두 대의 실제 기기와 불안정한 네트워크 (flaky network) 환경에서 동기화 경로 (sync path)를 프로토타이핑하십시오. 해피 패스 (happy path)는 항상 괜찮아 보입니다. 재연결 경로 (reconnect path)야말로 당신이 올바른 선택을 했는지 배울 수 있는 지점입니다.
실제로 중요한 UX 디테일
동기화가 작동하기 시작하더라도, 앱의 성패는 CRDT 이론과는 전혀 상관없는 몇 가지 UX 디테일에 달려 있습니다.
첫 번째는 동기화 표시기 (sync indicator)입니다. 사용자는 성가시게 굴지 않으면서도 자신의 데이터가 안전하다는 믿음을 가져야 합니다. 저는 회색(동기화 중)에서 채워진 색(동기화 완료)으로 변하는 조용한 점을 선택했고, 동기화 실패가 30초 이상 지속될 때만 실제 경고를 표시하도록 했습니다. 초기에는 모든 쓰기 작업마다 스피너 (spinner)를 보여주었는데, 이는 앱을 기존의 온라인 버전보다 더 느리게 느껴지게 만들었습니다. 그것은 의도와 정반대되는 결과였습니다.
두 번째는 충돌 제시 (conflict presentation) 방식입니다. CRDT는 자동으로 병합하지만, '자동'이 항상 인간이 원하는 결과는 아닙니다. 만약 두 기기가 오프라인 상태에서 동일한 노트 제목을 편집한다면, Yjs는 문자를 교차 배치 (interleave)하여 아무도 입력하지 않은 쓰레기 데이터를 만들어낼 것입니다. 문자의 교차 배치가 잘못되는 필드(제목, 가격, 단일 선택 항목 등)의 경우, 저는 해당 필드를 CRDT 텍스트 타입 대신 타임스탬프를 이용한 마지막 쓰기 승리 (last-write-wins) 방식으로 처리합니다. 문자 단위 병합 (character-level merging)은 산문(prose)이나 코드처럼 문자 단위 병합이 의미 있는 곳에만 사용해야 하며, 양식 필드 (form fields)에는 사용해서는 안 됩니다.
세 번째는 초기 로딩 (initial load)입니다. 로컬 퍼스트 (local-first) 앱은 렌더링하기 전에 로컬 저장소로부터 데이터를 하이드레이션 (hydrate) 해야 합니다. 대규모 문서가 있는 상태에서 콜드 스타트 (cold start)가 발생하면 눈에 띄는 지연이 생길 수 있습니다. 저는 초기 동기화를 분리하여, 눈에 보이는 뷰포트 (viewport)가 먼저 로드된 후 히스토리 (history)가 스트리밍되도록 했습니다. 그 결과 노트 앱의 상호작용 가능 시간 (Time to interactive)이 약 1.8초에서 400밀리초 미만으로 단축되었습니다.
네 번째는 실제로 약속하는 멀티 디바이스 (multi-device) 경험입니다. 기기 간 동기화는 로컬 퍼스트가 빛을 발하는 지점이기도 하지만, 지연이 발생했을 때 사용자들이 가장 화가 나서 리포트를 제출하는 지점이기도 합니다. 저는 Buffer를 통해 업데이트에 대한 상태 게시물을 예약하여, 동기화 수정 사항이 언제 배포되는지 사용자에게 정확히 알릴 수 있도록 했고, 이를 통해 "고장 난 건가요?"라는 메시지를 눈에 띄게 줄였습니다. 그리고 앱 자체를 판매하는 경우, Shopify에서 스토어프런트 (storefront)를 운영함으로써 결제 로직을 로컬 퍼스트 코드베이스에서 완전히 분리했습니다. 이는 동기화해야 할 항목을 하나 줄이는 결과를 가져왔습니다.
이 중 어려운 것은 하나도 없습니다. 다만 병합 로직 (merge logic)에 깊이 빠져 있을 때 무심코 건너뛰기 쉬운 것들이며, 바로 이것들이 사용자가 판단하는 기준이 됩니다.
결론 (Bottom Line)
로컬 퍼스트 (Local-first)는 CRDT를 사용한다고 해서 얻을 수 있는 훈장이 아닙니다. 그것은 트레이드오프 (tradeoff)입니다. 앱에 실제 오프라인 요구 사항이 있거나 여러 기기에서 공유 상태 (shared state)를 편집해야 하는 경우, 적절한 스택 (텍스트를 위한 Yjs, 구조화된 상태를 위한 Automerge, 관계형 데이터를 위한 ElectricSQL)을 사용하면 앱이 즉각적으로 느껴지며 동기화 (sync) 코드를 획기적으로 줄일 수 있습니다. 하지만 그렇지 않은 경우, CRDT는 사용자가 전혀 알아차리지 못하는 영리한 배관 작업 (plumbing)의 더미일 뿐이며, 일반적인 서버와 낙관적 UI (optimistic UI)를 사용하는 것이 더 빨랐을 것입니다.
두 가지 방식을 모두 구축해 본 후 제가 드리는 조언은 이렇습니다: 먼저 지루한 서버 버전을 출시하세요. 로컬 퍼스트는 그것이 해결할 수 있는 구체적인 오프라인 상황이나 멀티 디바이스 상황을 명시할 수 있을 때만 추가하세요. 확정하기 전에 두 대의 실제 기기에서 재연결 (reconnect) 경로를 프로토타이핑해 보세요. 왜냐하면 해피 패스 (happy path)는 당신에게 거짓말을 하기 때문입니다.
코드를 작성하기 전에 이와 같은 빌드 범위를 설정하기 위해 제가 사용하는 전체 프레임워크를 알고 싶다면, Claude Blueprint에 정리해 두었습니다. 아키텍처 다이어그램에서 읽기 좋은 것이 아니라, 사용자가 체감할 수 있는 것을 만드세요.
이 기사에는 제휴 링크가 포함되어 있습니다. 이 링크를 통해 가입하시면, 귀하에게 추가 비용 없이 저에게 소정의 수수료가 지급될 수 있습니다. (광고)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기