본문으로 건너뛰기

© 2026 Molayo

Smashing헤드라인2026. 05. 20. 01:18

로컬 퍼스트 (Local-First) 웹 개발의 아키텍처

요약

저자는 네트워크 불안정 상황에서 기존 클라이언트-서버 아키텍처가 겪는 한계를 경험하며 로컬 퍼스트(Local-First) 아키텍처의 필요성을 역설합니다. 로컬 퍼스트는 단순한 오프라인 지원을 넘어, 로컬 데이터를 신뢰할 수 있는 원천으로 삼아 속도, 협업, 개인정보 보호 등을 실현하는 패러다임임을 설명합니다.

핵심 포인트

  • 로컬 퍼스트는 단순한 오프라인 퍼스트(Offline-first)나 PWA와는 다른 개념임
  • 로컬 퍼스트의 7가지 이상향: 빠름, 멀티 디바이스, 오프라인, 협업, 장수성, 개인정보 보호, 사용자 소유권
  • 기존 클라이언트-서버 모델은 네트워크 지연 및 단절 시 사용자 경험이 급격히 저하됨
  • 2019년 당시와 달리 현재는 로컬 퍼스트를 구현하기 위한 툴링이 성숙해짐

지난 10월, 저는 팀이 4개월 동안 구축한 프로젝트 관리 도구를 데모하기 전날 밤, 리스본의 한 호텔 방에 앉아 있었습니다. 호텔 Wi-Fi는 연결은 되어 있지만 아무것도 로드되지 않는 특유의 상태였습니다. 그리고 제가 진심으로 자랑스러워했던 우리 앱이 빈 화면과 스피너(spinner)를 렌더링하는 것을 지켜보았습니다. 그러다 타임아웃(timeout) 에러가 떴고, 그 후엔 아무것도 나타나지 않았습니다.

저는 휴대폰을 꺼내 셀룰러 데이터에 테더링하여 불안정한 연결을 확보했습니다. 앱은 로드되었지만, 클릭할 때마다 2초씩 기다려야 했습니다. 작업을 생성하나요? 스피너가 돕니다. 작업의 컬럼을 이동하나요? 스피너가 돕니다. 저는 앉아서 생각했습니다. 우리는 React로 프런트엔드(front end)를, Node로 백엔드(back end)를, Postgres 데이터베이스를, Redis 캐시(cache)를, 그리고 작업 보드만을 위한 6개의 리졸버(resolver)를 가진 GraphQL API를 구축했습니다. 그 모든 인프라를 갖추고도, 이 빌어먹을 앱은 3,000마일 떨어진 서버를 거치지 않고는 내 데이터조차 보여주지 못하는군요.

그날 밤 저는 **로컬 퍼스트 아키텍처 (local-first architecture)**를 진지하게 살펴보기 시작했습니다. 블로그 포스트를 읽거나 트윗을 봐서가 아니었습니다. 제가 창피했기 때문입니다.

한 가지 솔직하게 말씀드리고 싶습니다. 저는 첫 1년 정도 로컬 퍼스트를 학술적인 것으로 치부하며 무시했습니다. 2019년에 Ink & Switch의 “Local-First Software” 논문이 나왔을 때 읽고는, *“멋진 연구지만, 실제 앱에는 실용적이지 않다”*라고 생각했습니다. 제가 틀렸습니다. 2019년의 툴링(tooling)은 진정으로 준비되어 있지 않았습니다. 하지만 저는 제가 이미 알고 있는 아키텍처를 기본값으로 선택하며 게으르게 행동하고 있었습니다. 그 논문은 소프트웨어를 위한 7가지 이상향을 제시했습니다: 빠름(fast), 멀티 디바이스(multi-device), 오프라인(offline), 협업(collaboration), 장수성(longevity), 개인정보 보호(privacy), 사용자 소유권(user ownership). 저는 그것들이 엔지니어링 요구사항이 아니라 마치 희망 사항 목록처럼 들린다고 생각했던 기억이 납니다.

7년이 지난 지금, 저는 로컬 퍼스트 패턴을 사용하여 세 개의 프로덕션 앱을 출시했습니다. 또한 로컬 퍼스트가 잘못된 선택이었던 두 개의 프로젝트에서는 로컬 퍼스트를 제거하기도 했습니다. 저에게는 의견이 있습니다. 그중 일부는 아마 틀렸을 수도 있습니다. 하지만 그것들은 경험을 통해 얻은 것입니다.

그래서 여기 2026년에 로컬 퍼스트 (Local-first) 웹 앱을 구축하는 것에 대해 제가 실제로 생각하는 바를 적어둡니다. 이 글은 만능 해결책 (silver bullets)에 대해 회의적일 만큼 충분히 경험을 쌓은 개발자들을 위해 작성되었습니다.

"로컬 퍼스트 (Local-First)"가 실제로 의미하는 것 (그리고 사라지지 않는 혼란)

미트업 (meetups)에서 이 대화를 계속 나누게 되기에 한 가지를 명확히 짚고 넘어가야겠습니다. 로컬 퍼스트 (Local-first)는 오프라인 퍼스트 (offline-first)가 아닙니다. 이는 "서비스 워커 (service worker)를 추가하고 끝내는 것"이 아닙니다. PWA의 동의어도 아닙니다. 컨퍼런스 강연에서 이 모든 것들이 혼용되는 것을 보았는데, 이는 저를 조금 미치게 만듭니다.

오프라인 퍼스트 (Offline-first)는 앱이 네트워크 손실을 우아하게 처리한다는 것을 의미하지만, 서버가 여전히 신뢰할 수 있는 단일 원천 (source of truth)입니다. 네트워크가 복구되면 서버가 승리합니다. 캐시 퍼스트 (Cache-first, 서비스 워커가 응답을 캐싱하는 방식)는 성능 최적화 (performance optimization)입니다. 오래된 데이터 (stale data)를 더 빠르게 제공하는 것이며, 이는 훌륭하지만 데이터의 소유권이 누구에게 있는지는 바꾸지 못합니다. PWA는 전달 메커니즘 (delivery mechanism)입니다: 설치 가능, 캐싱, 푸시 알림 등입니다. 이 중 어느 것도 데이터 아키텍처 (data architecture)가 아닙니다.

로컬 퍼스트 (Local-first)는 데이터 아키텍처 (data architecture)입니다. 사용자의 기기가 데이터의 기본 복사본 (primary copy)을 보유합니다. 앱은 로컬 데이터베이스 (local database)에 읽고 씁니다. 즉각적으로 렌더링 (renders)합니다. 백그라운드에서 서버 또는 다른 기기와 동기화 (syncs)합니다. 서버가 존재한다면, 서버는 특정한 권한(인증, 백업, 액세스 제어)을 가진 동기화 피어 (sync peer)입니다. 하지만 서버는 문지기 (gatekeeper)가 아닙니다.

Ink & Switch의 논문은 7가지 이상적인 모델을 정의했으며, 저는 그것들이 여전히 유효하다고 생각합니다. 하지만 실제 상황에서 가장 중요하며, 모든 것을 구축하는 방식을 바꾸는 것은 바로 이것입니다:

클라이언트는 데이터를 보여주기 위해 권한을 요청하는 얇은 뷰 (thin view)가 아닙니다. 클라이언트는 자체 데이터베이스를 가진 분산 시스템 (distributed system) 내의 한 노드 (node)입니다.

이 차이는 미묘하게 들릴 수 있습니다. 하지만 그렇지 않습니다. 이는 여러분의 전체 스택 (stack)을 변화시킵니다.

초기에 솔직해지기: 이 방식을 사용해서는 안 되는 경우

이 내용을 상단에 배치하는 이유는, 너무 많은 개발자들(저 자신도 한때 그랬습니다)이 새로운 아키텍처 (architecture)에 열광하다가 그것이 어울리지 않는 프로젝트에 억지로 끼워 맞추는 것을 보아왔기 때문입니다. 저는 이전 직장에서 내부 분석 대시보드 (analytics dashboard)에 로컬 퍼스트 (local-first) 방식을 적용하려고 시도하다가 약 6주를 허비했습니다. 동료인 Sarah가 마침내 저를 따로 불러 이렇게 말했습니다. “데이터는 서버에서 생성되잖아요. 클라이언트로 복제할 데이터가 전혀 없는데, 대체 뭘 하고 있는 거예요?” 그녀의 말이 맞았습니다.

데이터가 주로 서버에서 생성되는 경우에는 로컬 퍼스트가 적합하지 않습니다. 분석 대시보드 (analytics dashboards), 소셜 미디어 피드 (social media feeds), 검색 결과 (search results): 서버가 이러한 데이터를 생성하므로, API 요청을 통해 이를 소비하는 클라이언트는 현재 방식대로도 충분히 괜찮습니다.

강력한 트랜잭션 일관성 (transactional consistency)이 필요한 시스템에도 적합하지 않습니다. 뱅킹 (banking), 결제 처리 (payment processing), 재고 관리 (inventory management) 등이 이에 해당합니다. 만약 두 사람이 재고가 하나 남은 마지막 아이템을 구매하려고 한다면, ACID 보장을 통해 결정을 내리는 단일 권위 데이터베이스 (authoritative database)가 필요합니다. 결과적 일관성 (eventual consistency)은 금전적 손실을 초래하거나, 그보다 더 나쁜 상황을 만들 수 있습니다.

오프라인 기능이나 협업 기능이 필요 없는 단순한 CRUD 앱에는 과잉 기술 (overkill)입니다. 인터넷 연결이 원활한 사무실에서 5명이 사용하는 내부 관리자 패널 (admin panel)을 구축하고 있다면, 동기화 엔진 (sync engine)을 추가하는 것은 과잉 엔지니어링 (over-engineering)입니다. 또한, 클라이언트 기기에 담을 수 없는 거대한 데이터 세트 (datasets)의 경우 물리적으로 불가능합니다.

하지만 로컬 퍼스트가 빛을 발하는 지점은 다음과 같습니다. 노트 작성, 문서 편집, 협업 디자인 도구, 프로젝트 관리, 연결이 불안정한 환경에서 사용하는 현장용 앱, 기본적으로 **데이터 프라이버시 (data privacy)가 셀링 포인트 (selling point)**인 모든 것, 그리고 **실시간 협업 (real-time collaboration)**이 필요한 모든 것이 여기에 해당합니다. 다시 말해, 즉각적인 상호작용을 통해 이득을 얻고 서버가 다운되더라도 유지되어야 하는 **사용자 생성 데이터 (user-generated data)**에 매우 적합합니다.

한 가지 더, 제가 좀 더 일찍 누군가에게 들었으면 좋았을 점이 있습니다. 바로 모든 것을 전부 바꿀 필요는 없다는 것입니다. 저는 기존의 전통적인 앱 내에서 *특정 기능 (specific features)*에만 로컬 퍼스트 (local-first) 방식을 적용했을 때 가장 좋은 결과를 얻었습니다. 블로그 에디터의 오프라인 초안 작성 기능이나, 일반적인 REST 방식을 사용하는 프로젝트 관리 도구 내의 실시간 협업 노트 기능 같은 것들 말이죠.

요청 (Requests)이 아닌 복제본 (Replicas)

만약 Git을 사용해 본 적이 있다면, 이미 이 사고 모델 (mental model)을 이해하고 있는 것입니다.

SVN (SVN을 기억하시나요?)은 중앙 집중식 (centralized)이었습니다. 하나의 서버가 존재했고, 파일을 체크아웃 (check out)하여 변경 사항을 만든 뒤 서버에 커밋 (commit)했습니다. 서버가 다운되면? 커밋할 수 없습니다. 히스토리 (history)조차 볼 수 없습니다.

Git은 모든 개발자에게 완전한 클론 (full clone)을 제공했습니다. 로컬에서 커밋하고, 로컬에서 브랜치 (branch)를 나누고, 로컬에서 머지 (merge)합니다. 준비가 되었을 때 푸시 (push)하고 풀 (pull)하면 됩니다. 원격 저장소 (remote repository)는 중요하지만, 그것이 유일한 진실의 복사본 (copy of the truth)은 아닙니다.

로컬 퍼스트 (Local-first) 웹 개발은 애플리케이션 데이터를 위한 Git입니다. 모든 클라이언트 기기는 관련 데이터의 복제본 (replica, 전체 또는 일부)을 보유합니다. 쓰기 (writes) 작업은 로컬에서 발생합니다. 동기화 (sync)는 백그라운드에서 푸시/풀 방식으로 이루어집니다. 충돌 (conflicts)은 정의된 머지 전략 (merge strategies)을 통해 해결됩니다.

이 개념이 실무에서 처음으로 와닿았던 순간이 기억납니다. 저는 태스크 보드 (task board)를 프로토타이핑하고 있었고, 태스크를 추가하는 함수를 작성하고 있었습니다. 기존의 아키텍처 (architecture)에서는 다음과 같았을 것입니다:

  • API로 POST 요청.
  • 응답 대기.
  • 성공 시, 로컬 상태 (local state) 업데이트.
  • 실패 시, 에러 토스트 (error toast)를 표시하고 낙관적 업데이트 (optimistic update)를 롤백 (roll back).

로컬 퍼스트 버전에서는 다음과 같았습니다: 로컬 SQLite에 쓰기, 끝. UI는 동일한 로컬 데이터베이스에서 데이터를 읽어오기 때문에 즉시 업데이트되었습니다. 동기화는 언제든 일어납니다. 로딩 상태 (loading state)도, 쓰기 작업 자체에 대한 에러 처리 (error handling)도, 낙관적 업데이트 로직 (optimistic update logic)도 필요 없었습니다 (왜냐하면 '낙관적'일 필요가 없기 때문입니다. 로컬 쓰기 자체가 곧 상태이기 때문입니다).

이러한 영향은 모든 곳으로 파급됩니다. 데이터를 가져오기(fetching) 위한 React Query나 SWR이 필요하지 않습니다. 왜냐하면 데이터를 가져오는 것이 아니기 때문입니다. 서버 유도 상태 (server-derived state)를 관리하기 위한 Redux나 Zustand도 필요하지 않습니다. 로컬 데이터베이스 (local database) 자체가 곧 상태이기 때문입니다. 라우팅 (routing)이 API 호출을 트리거하지 않습니다. 인증 (Authentication) 방식도 달라집니다. 서버가 모든 읽기 작업에 대해 권한을 확인하지 않기 때문입니다.

공간적으로 생각하는 유형의 사람(저와 같은 사람)에게 도움이 될 만한 시각적 비교를 준비했습니다:

왼쪽에서는 모든 사용자 상호작용이 왕복 (round-trip) 과정을 거칩니다. 클릭하고, 기다리고, 렌더링합니다. 오른쪽에서는 읽기와 쓰기가 로컬 데이터베이스에 직접 수행됩니다. 동기화 서버 (sync server)는 여전히 존재하지만, 백그라운드에서 작업을 수행합니다. 사용자는 이를 위해 기다릴 필요가 없습니다. 이것이 근본적인 변화입니다.

하지만 제가 너무 앞서가고 있군요. 동기화 (sync)와 충돌 (conflicts)에 대해 이야기하기 전에, 클라이언트에서 데이터가 실제로 어디에 저장되는지에 대해 먼저 이야기해야 합니다.

클라이언트의 데이터 저장 위치

localStorage는 잊으세요. 이는 동기적 (synchronous)이며 (메인 스레드를 차단함), 용량이 5~10MB로 제한되고, 오직 문자열만 저장할 수 있습니다. 테마 설정 같은 용도로는 괜찮지만, 데이터베이스는 아닙니다.

IndexedDB는 아무도 좋아하지 않는 일꾼입니다. 모든 브라우저에 들어있고, 비동기적 (asynchronous)이며, 수백 메가바이트를 처리할 수 있지만, 그 API는 다루기가 정말 형편없습니다. 저는 직접 사용해 본 것이 딱 한 번뿐입니다. 지금은 추상화 계층을 통해 사용하거나, 더 자주서는 아예 사용하지 않습니다.

왜냐하면 2026년의 진짜 이야기는 WebAssembly (WASM)를 통해 브라우저에서 실행되는 SQLite이기 때문입니다.

단순한 눈속임처럼 들릴 수도 있겠지만, 그렇지 않습니다. WASM으로 컴파일되어 Origin Private File System (OPFS)에 영구 저장되는 SQLite는 브라우저 내에서 *진정한 관계형 데이터베이스 (relational database)*를 제공합니다. 완전한 SQL 쿼리, 트랜잭션 (Transactions), 인덱스 (Indexes) 등 모든 기능을 갖추고 있습니다.

OPFS는 이를 실용적으로 만들어 주는 최신 API입니다. 이는 웹 앱에 고성능 동기식 액세스(Web Workers 내에서)가 가능한 샌드박스형 파일 시스템 (Sandboxed File System)을 제공하며, 이는 SQLite가 정확히 필요로 하는 기능입니다. OPFS 이전에는 SQLite를 메모리에서 실행하고 IndexedDB에 수동으로 영속화 (Persist)할 수 있었지만, 이는 작동은 하되 느리고 취약했습니다.

실제 프로젝트에서의 초기화 과정은 대략 다음과 같습니다 (여기서는 제가 가장 좋은 결과를 얻었던 라이브러리인 wa-sqlite를 사용합니다):

import { SQLiteAPI } from 'wa-sqlite';
import { OPFSCoopSyncVFS } from 'wa-sqlite/src/examples/OPFSCoopSyncVFS.js';
async function initDatabase() {
...

프로덕션 환경에서는 모든 데이터베이스 액세스를 변이 (Mutations)를 직렬화하는 쓰기 큐 (Write Queue)로 감쌉니다. 또한, 사용자의 브라우저에서 발생하는 데이터베이스 문제를 디버깅하는 것은 이러한 텔레메트리 (Telemetry) 없이는 매우 고통스럽기 때문에, 모든 실패한 쓰기 작업을 전체 SQL 문과 함께 Sentry에 기록합니다 (물론 개인정보(PII)는 제거한 상태로 말이죠).

제가 거의 이틀을 허비했던 주의 사항(Gotcha) 하나는 다음과 같습니다. Safari의 OPFS 구현은 Chrome과 미묘하게 다르게 동작합니다. 구체적으로, Safari 18의 특정 iframe 컨텍스트에서 createSyncAccessHandle()이 조용히 실패하는 버그를 겪었습니다. 에러도, 예외 (Exception)도 발생하지 않습니다. 그냥 작동하지 않을 뿐입니다. 결국 Safari에서는 IndexedDB 기반의 영속화 방식으로 폴백 (Fallback)했는데, 속도는 더 느렸지만 최소한 작동은 했습니다. (Safari 19/26에서 이 문제가 해결되었다고 들었지만, 아직 직접 확인하지는 않았습니다.)

제가 실제로 사용해 본 옵션들의 빠른 비교입니다:

저장소 (Storage)용도 (Good For)주의 사항 (Watch Out For)
IndexedDB폭넓은 호환성, 중간 정도의 데이터최악의 DX (개발자 경험), SQL 미지원, 장황함
...

또한 SQLite 테이블에 CRDT 컬럼 지원을 직접 추가하는 cr-sqlite도 시도해 보았습니다. 영리한 아이디어이지만, 2025년 말에 평가했을 때 프로덕션에서 사용하기에는 너무 초기 단계라고 판단했습니다. 병합 의미론 (Merge Semantics)이 때때로 의외였고, SQLite 내부의 CRDT 상태를 디버깅하는 것이 고통스러웠습니다. 올해 말에 다시 검토해 볼 예정입니다.

실제로 어려운 부분

데이터를 로컬에 저장하는 것은 이미 해결된 문제입니다. 하지만 이를 여러 기기와 사용자 간에 안정적으로 동기화하는 과정에서 머리가 하얗게 세게 됩니다.

여러 복제본(Replica)이 독립적으로 읽기 및 쓰기를 수행할 수 있을 때, 변경 사항을 조정(Reconcile)할 메커니즘이 필요합니다. 기본적으로 네 가지 접근 방식이 있으며, 저는 그중 세 가지를 사용해 보았습니다.

**CRDTs (Conflict-Free Replicated Data Types, 충돌 없는 복제 데이터 타입)**는 동시 편집이 수학적으로 보장된 충돌 없이 항상 병합될 수 있도록 설계된 데이터 구조입니다. Yjs는 JavaScript에서 가장 인기 있는 구현체이며, 실시간 협업 텍스트 편집에 진정으로 탁월합니다. 저는 이전 회사에서 협업 문서 편집기를 구축할 때 이를 사용했으며, 대부분의 경험은 좋았지만 충돌 해결(Conflict resolution) 섹션에서 고충을 다루도록 하겠습니다.

실제로 공유 Yjs 문서를 설정하는 모습은 다음과 같습니다:

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
...

Automerge는 또 다른 주요 CRDT 라이브러리로, Rust를 기반으로 하며 문서 지향 모델(Document-oriented model)을 가지고 있습니다. 저는 사용 빈도가 낮았지만, 이를 강력히 추천하는 팀들을 알고 있습니다. Loro는 더 최신이며 Rust 기반으로, 더 나은 성능을 주장합니다. 저는 아직 Loro를 사용하여 제품을 출시한 적은 없습니다.

**데이터베이스 복제 (Database replication)**는 또 다른 큰 접근 방식이며, 솔직히 Google Docs 스타일의 실시간 텍스트 편집이 필요하지 않은 대부분의 앱에는 이것이 더 나은 선택이라고 생각합니다. 아이디어는 간단합니다. 동기화 엔진(Sync engine)이 배관 작업(Plumbing)을 관리하면서 서버 데이터베이스(Postgres)와 클라이언트 데이터베이스(SQLite) 간에 행(Row)을 복제하는 것입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0