- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 클라우드 피로감이라는 시대 배경
- Local-First의 7가지 이상
- 아키텍처의 전환 — 서버 중심에서 로컬 우선으로
- 동기화 엔진의 내부 구조
- CRDT — 기초만 간결하게, 설계 관점에서
- 충돌 해소 전략 3종 비교
- 동기화 엔진 생태계 비교
- 서버는 사라지지 않는다 — 경계 설계
- 오프라인 UX 패턴
- 로컬 저장소 선택 — 웹의 현실
- 스키마 진화 — local-first의 숨은 난제
- 실전 미니 설계 — 노트 앱 시나리오
- 부분 동기화 설계 심화 — 쿼리 구독 패턴
- 동기화 코드의 테스트 전략
- 도입 결정 프레임워크 — 우리 제품에 맞는가
- 비즈니스 모델 관점
- 한계와 비판적 시각
- 마치며
- 참고 자료
들어가며 — 클라우드 피로감이라는 시대 배경
2026년 상반기의 기술 커뮤니티 분위기를 한 단어로 요약하면 "빅테크 피로감"입니다. Hacker News에서는 Gmail을 떠나 자체 호스팅 메일이나 소규모 유료 서비스로 옮겼다는 글이 주기적으로 1면에 오르고, AI 요약을 끼워 넣지 않는 검색을 원하는 사용자들이 몰리면서 DuckDuckGo의 no-AI 검색 트래픽이 급증했다는 소식도 화제가 됐습니다. GeekNews에서도 "내 데이터는 어디에 있는가"라는 주제의 글이 꾸준히 상위권에 머뭅니다.
구독 서비스가 종료되면 내 문서가 함께 사라지고, 서버 장애가 나면 멀쩡한 내 컴퓨터로도 일을 못 하며, 약관 변경 한 번에 내 데이터가 AI 학습 재료가 되는 경험이 누적된 결과입니다. 이 피로감의 기술적 대답으로 다시 소환되는 것이 Local-First 소프트웨어입니다. 2019년 Ink and Switch 연구소가 에세이로 정리한 이 개념은, 클라우드의 협업 편의성과 로컬 소프트웨어의 소유권을 동시에 잡으려는 설계 철학입니다.
이 글은 선언문 소개에 그치지 않고, 실제로 local-first 제품을 설계할 때 부딪히는 문제들 — 동기화 엔진 선택, 충돌 해소, 서버의 역할 경계, 스키마 진화 — 을 아키텍처 관점에서 정리합니다.
Local-First의 7가지 이상
Ink and Switch 에세이는 local-first 소프트웨어가 충족해야 할 7가지 이상을 제시했습니다.
- 빠른 응답. 스피너 없이 모든 작업이 로컬 데이터로 즉시 처리되어야 합니다.
- 여러 기기. 내 데이터는 내가 쓰는 모든 기기에서 접근 가능해야 합니다.
- 네트워크는 선택 사항. 오프라인이 비정상 상태가 아니라 기본 지원 상태여야 합니다.
- 원활한 협업. 구글 독스 수준의 실시간 공동 편집이 가능해야 합니다.
- 영속성. 서비스 회사가 사라져도 내 데이터와 소프트웨어는 계속 동작해야 합니다.
- 기본 보안과 프라이버시. 종단간 암호화로 서버 운영자도 내용을 볼 수 없어야 합니다.
- 완전한 소유와 통제. 데이터의 법적, 기술적 통제권이 사용자에게 있어야 합니다.
핵심 통찰은 이것입니다. 1번부터 3번은 캐시와 오프라인 모드로 흉내낼 수 있지만, 4번(협업)과 결합하는 순간 난이도가 폭증합니다. 여러 기기에서 동시에 수정된 데이터를 병합해야 하기 때문입니다. local-first 아키텍처의 본질은 결국 "분산 시스템의 병합 문제를 클라이언트로 가져오는 것"입니다.
아키텍처의 전환 — 서버 중심에서 로컬 우선으로
전통적 SaaS와 local-first의 데이터 흐름을 비교하면 차이가 분명해집니다.
[전통적 클라우드 SaaS]
클라이언트 A 서버 (진실의 원천) 클라이언트 B
UI ---- 요청 -----> DB가 유일한 상태 <---- 요청 ---- UI
UI <--- 응답 ------ 모든 읽기쓰기 경유 ---- 응답 ---> UI
* 네트워크가 끊기면 아무것도 못 한다
* 지연시간 = 왕복시간(RTT)이 UI 반응성의 하한
[Local-First]
클라이언트 A 클라이언트 B
UI -- 즉시 읽기쓰기 --> 로컬 DB 로컬 DB <-- 즉시 읽기쓰기 -- UI
| |
+--- 동기화 엔진 ---+
|
서버 = 동기화 중계
(저장, 전달, 백업)
* UI는 항상 로컬 DB만 본다 (지연시간 = 디스크 접근)
* 서버는 변경사항을 중계하고 보관하는 우체국 역할
이 전환이 의미하는 바를 정리하면 다음과 같습니다.
첫째, 진실의 원천이 이동합니다. 서버 DB가 유일한 진실이 아니라, 각 클라이언트의 로컬 복제본이 모두 1급 시민이 됩니다. 서버는 변경 로그의 중계자이자 내구성 있는 보관소로 역할이 줄어듭니다.
둘째, 쓰기 경로가 비동기가 됩니다. 사용자의 쓰기는 로컬에 즉시 커밋되고, 동기화는 백그라운드에서 일어납니다. 따라서 "쓰기 성공"의 의미가 두 단계(로컬 확정, 전파 완료)로 분리되며, UI는 이를 구분해 표현해야 합니다.
셋째, 충돌이 정상 상황이 됩니다. 서버 중심 모델에서 충돌은 트랜잭션과 잠금으로 예방하는 예외였지만, local-first에서는 반드시 일어나는 일상이며 병합 전략이 설계의 중심이 됩니다.
동기화 엔진의 내부 구조
동기화 엔진은 local-first 스택의 심장입니다. 일반적인 구성은 다음과 같습니다.
+------------------- 클라이언트 -------------------+
| UI (React, Swift, ...) |
| | 구독(쿼리) ^ 반응형 갱신 |
| v | |
| 로컬 저장소 (SQLite, IndexedDB, OPFS) |
| | 변경 캡처(oplog) ^ 원격 변경 적용 |
| v | |
| 동기화 클라이언트 (재시도, 백오프, 압축) |
+-----------|---------------^----------------------+
v (push) | (pull / 실시간 스트림)
+-----------|---------------|----------------------+
| 동기화 서버: 변경 로그 저장, 순서화, 팬아웃, |
| 권한 필터링, 스냅샷 압축 |
+--------------------------------------------------+
설계 시 결정해야 할 축은 크게 네 가지입니다.
- 동기화 단위. 문서 전체인가, 행 단위인가, 연산(op) 단위인가. 단위가 작을수록 충돌 해상도가 정밀해지지만 메타데이터 비용이 커집니다.
- 전송 모델. 주기적 pull, 실시간 WebSocket 스트림, 푸시 알림 트리거 중 무엇을 조합할 것인가.
- 부분 동기화. 클라이언트가 전체 데이터셋을 들고 있을 수 없다면, 어떤 부분집합을 어떻게 선언적으로 구독하게 할 것인가. 최근 엔진들이 "쿼리 기반 동기화"로 수렴하는 지점입니다.
- 압축과 스냅샷. 변경 로그는 무한히 자라므로, 언제 스냅샷으로 접고 오래된 로그를 버릴 것인가.
CRDT — 기초만 간결하게, 설계 관점에서
CRDT(Conflict-free Replicated Data Type)의 내부 알고리즘은 이 블로그의 별도 글에서 다뤘으므로, 여기서는 설계자가 알아야 할 최소한만 짚겠습니다.
CRDT는 "어떤 순서로 병합해도 모든 복제본이 같은 상태에 수렴한다"는 수학적 성질을 가진 자료구조입니다. 카운터, 집합, 리스트, 텍스트 등 타입별로 구현이 있고, Automerge와 Yjs가 대표 라이브러리입니다. 중앙 조정자 없이 병합이 끝난다는 점이 매력이지만, 설계 관점에서는 다음 한계를 알아야 합니다.
- 수렴이 곧 의도 보존은 아닙니다. 두 사람이 같은 할 일을 서로 다른 폴더로 옮기면, CRDT는 어떤 결과로든 수렴하지만 그 결과가 두 사람 모두의 의도와 다를 수 있습니다.
- 불변식 보장이 어렵습니다. "잔액은 음수가 될 수 없다" 같은 전역 불변식은 로컬 병합만으로 지킬 수 없습니다. 이런 도메인은 서버 중재가 필요합니다.
- 메타데이터 비용이 있습니다. 삭제 흔적(tombstone)과 인과관계 추적 정보가 누적되며, 텍스트 CRDT는 문서 길이보다 훨씬 큰 내부 상태를 가질 수 있습니다. 최근 구현들이 많이 개선했지만 공짜는 아닙니다.
실무 결론은 단순합니다. 공동 편집 텍스트와 화이트보드처럼 의도 충돌이 잦고 실시간성이 중요한 곳에는 CRDT를, 구조화된 비즈니스 데이터에는 더 단순한 전략을 쓰는 혼합 설계가 대세입니다.
충돌 해소 전략 3종 비교
| 전략 | 동작 방식 | 장점 | 단점 | 적합한 데이터 |
|---|---|---|---|---|
| LWW | 타임스탬프가 최신인 쓰기가 승리 | 구현이 단순, 메타데이터 최소 | 동시 편집 한쪽이 조용히 유실 | 설정값, 단일 필드 갱신 |
| CRDT | 수학적 병합으로 자동 수렴 | 오프라인 병합 무손실, 서버 불요 | 의도 보존 미보장, 상태 비대 | 공동 편집 문서, 그리기 |
| 서버 중재 | 서버가 변경을 받아 규칙으로 재배열 | 불변식 강제 가능, 검증 용이 | 오프라인 확정 불가, 서버 의존 | 재고, 결제, 권한 변경 |
실제 제품은 세 가지를 데이터 종류별로 섞습니다. 노트 본문은 CRDT, 노트 제목과 태그는 LWW, 공유 권한 변경은 서버 중재 — 이런 식의 분할이 표준 패턴입니다.
동기화 엔진 생태계 비교
2026년 현재 선택지가 풍성해졌습니다. 직접 검토해본 범위에서 정리합니다.
| 엔진 | 접근 방식 | 로컬 저장소 | 서버 측 | 특징 요약 |
|---|---|---|---|---|
| ElectricSQL | Postgres 변경분을 클라이언트로 스트리밍 | 클라이언트 선택 | Postgres 앞단 동기화 서비스 | 읽기 경로 동기화에 집중, 쓰기는 자체 API로 |
| Zero | 쿼리 기반 동기화 | 자체 클라이언트 캐시 | 자체 캐시 서버와 Postgres | 쿼리를 구독하면 결과가 반응형으로 유지됨 |
| PowerSync | Postgres 등 기존 DB를 SQLite로 미러링 | SQLite | 동기화 서비스 | 기존 백엔드 유지한 채 점진 도입에 강점 |
| Automerge | CRDT 문서 라이브러리 | 자체 문서 포맷 | 중계 서버는 단순 | Ink and Switch 계열, 문서 모델에 적합 |
| Yjs | CRDT 라이브러리 | 자체 타입 | 중계 서버는 단순 | 에디터 통합 생태계가 가장 풍부 |
| Triplit | 풀스택 동기화 DB | 자체 저장소 | 자체 서버 | 쿼리 구독과 권한을 한 몸으로 제공 |
선택 기준을 한 줄씩으로 압축하면 이렇습니다. 기존 Postgres 백엔드가 있고 점진 도입을 원하면 PowerSync나 ElectricSQL, 공동 편집 에디터가 핵심이면 Yjs나 Automerge, 새 제품을 처음부터 local-first로 만들면 Zero나 Triplit 같은 통합형이 후보가 됩니다.
서버는 사라지지 않는다 — 경계 설계
local-first라는 이름 때문에 오해하기 쉽지만, 서버는 사라지지 않습니다. 역할이 바뀔 뿐입니다. 다음 영역은 구조적으로 서버에 남습니다.
- 인증. 신원 확인은 본질적으로 제3자 검증이 필요합니다. 토큰 발급과 갱신은 서버 몫입니다.
- 권한. "이 문서를 누가 볼 수 있는가"는 동기화 서버가 팬아웃 시점에 강제해야 합니다. 클라이언트 필터링은 보안이 아닙니다.
- 과금. 결제와 구독 상태는 전역 불변식의 대표 사례로, 서버 중재가 필수입니다.
- 무거운 연산. 전문 검색 인덱싱, AI 추론, 대용량 미디어 변환은 서버나 엣지에서 수행하고 결과를 동기화로 내려보내는 편이 합리적입니다.
경계 설계의 실용적 원칙은 "쓰기를 두 부류로 나누라"입니다. 로컬에서 확정 가능한 쓰기(문서 편집, 개인 설정)는 동기화 엔진으로, 서버 확정이 필요한 쓰기(공유, 결제, 초대)는 전통적 API 호출로 처리하되 결과 상태는 다시 동기화로 모든 기기에 전파합니다. 이 두 경로를 처음부터 분리해두면 이후 혼란이 크게 줄어듭니다.
두 갈래 쓰기 경로를 그림으로 정리하면 다음과 같습니다.
사용자 동작
|
+-- 로컬 확정 가능? --- 예 --> 로컬 DB 커밋 --> oplog --> 동기화 엔진
| (즉시 UI 반영) (백그라운드 전파)
|
+-- 서버 확정 필요? --- 예 --> API 호출 --> 서버 검증/커밋
| |
실패 시 오프라인 큐 상태 변경을 동기화로
(재시도 정책 적용) 모든 기기에 팬아웃
오프라인 UX 패턴
아키텍처가 오프라인을 지원해도 UX가 따라가지 못하면 사용자는 불신합니다. 검증된 패턴 몇 가지를 정리합니다.
- 낙관적 UI를 기본으로 하되, 동기화 상태를 한 곳에 조용히 표시합니다. 문서마다 스피너를 붙이는 대신, 상태바에 "마지막 동기화 시각" 하나면 충분합니다.
- 충돌을 사용자에게 떠넘기지 않습니다. 자동 병합을 기본으로 하고, 정말 필요한 경우에만 "두 버전 보기"를 제공합니다. 병합 다이얼로그가 자주 뜨는 앱은 설계가 잘못된 것입니다.
- 서버 확정이 필요한 동작은 오프라인에서 명확히 구분합니다. 예를 들어 공유 초대 버튼은 오프라인에서 "연결되면 전송됨" 상태로 큐잉되는 것을 보여줍니다.
- 첫 동기화(초기 다운로드)는 진행률을 보여주고, 그 이후로는 동기화 과정을 최대한 보이지 않게 만듭니다.
로컬 저장소 선택 — 웹의 현실
네이티브 앱은 SQLite 하나로 정리되지만, 웹은 선택지가 갈립니다.
| 저장소 | 성격 | 강점 | 약점 |
|---|---|---|---|
| IndexedDB | 브라우저 내장 KV 및 인덱스 | 어디서나 동작, 추가 의존성 없음 | API가 불편, 대량 쓰기 성능 한계 |
| SQLite Wasm 더하기 OPFS | 브라우저에서 SQLite 실행 | 진짜 SQL, 네이티브와 코드 공유 | Wasm 번들 크기, 워커 구성 복잡 |
| OPFS 직접 사용 | 오리진 전용 파일시스템 | 빠른 파일 IO | 저수준이라 직접 포맷 설계 필요 |
2026년 시점의 흐름은 분명히 SQLite Wasm과 OPFS 조합으로 기울었습니다. 네이티브 앱, 서버, 웹이 같은 SQL 스키마와 쿼리를 공유할 수 있다는 점이 결정적입니다. 동기화 엔진들도 SQLite를 1급 타깃으로 지원하는 추세입니다.
스키마 진화 — local-first의 숨은 난제
서버 중심 앱의 마이그레이션은 배포 시점에 한 번 실행하면 끝입니다. local-first에서는 구버전 클라이언트가 몇 달 뒤에 오프라인 변경분을 들고 돌아올 수 있습니다. 설계 원칙은 다음과 같습니다.
- 스키마 버전을 데이터에 새깁니다. 모든 변경 로그에 스키마 버전을 포함시켜, 수신 측이 어떤 변환을 적용할지 알 수 있게 합니다.
- 전방 호환을 우선합니다. 필드 추가는 안전하게, 필드 삭제와 의미 변경은 신중하게. 삭제 대신 사용 중단(deprecation) 기간을 둡니다.
- 변환은 읽기 시점에 합니다. 저장된 데이터를 일괄 재작성하는 대신, 읽을 때 구버전 레코드를 새 형태로 끌어올리는 lazy migration이 분산 환경과 궁합이 좋습니다.
- 파괴적 변경은 새 컬렉션으로 합니다. 정말 깨야 한다면 새 테이블을 만들고 이중 기록 기간을 거쳐 옮기는, 분산 시스템의 고전적 방법을 따릅니다.
실전 미니 설계 — 노트 앱 시나리오
지금까지의 원칙을 작은 노트 앱에 적용해보겠습니다. 요구사항은 멀티 디바이스 동기화, 오프라인 편집, 노트 공유입니다.
먼저 데이터 모델입니다. 충돌 전략을 데이터 종류별로 분리합니다.
-- 로컬 SQLite 스키마 (클라이언트마다 동일)
CREATE TABLE notes (
id TEXT PRIMARY KEY, -- UUIDv7: 생성 시각 정렬 가능
title TEXT NOT NULL, -- 전략: LWW
body_crdt BLOB NOT NULL, -- 전략: CRDT (Yjs 문서 바이너리)
folder_id TEXT, -- 전략: LWW
updated_at INTEGER NOT NULL, -- 하이브리드 논리 시계(HLC)
deleted INTEGER DEFAULT 0, -- 소프트 삭제 (tombstone)
schema_v INTEGER DEFAULT 1
);
CREATE TABLE oplog (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
note_id TEXT NOT NULL,
op_type TEXT NOT NULL, -- upsert_meta | crdt_update | delete
payload BLOB NOT NULL,
hlc TEXT NOT NULL, -- 충돌 비교용 하이브리드 시계
synced INTEGER DEFAULT 0
);
동기화 클라이언트의 골격은 다음과 같습니다.
// sync-client.ts — 단순화한 동기화 루프
async function syncLoop(db: LocalDB, server: SyncTransport) {
while (true) {
// 1. push: 미전송 로컬 연산을 서버로
const pending = await db.query(
"SELECT * FROM oplog WHERE synced = 0 ORDER BY seq LIMIT 100"
);
if (pending.length > 0) {
await server.push(pending);
await db.markSynced(pending.map((p) => p.seq));
}
// 2. pull: 서버 커서 이후의 원격 연산 수신
const remote = await server.pull(db.getCursor());
for (const op of remote) {
applyRemoteOp(db, op); // 아래 병합 규칙 적용
}
await db.setCursor(remote.cursor);
await server.waitForChangeOrTimeout(30_000); // 실시간 알림 or 폴백
}
}
function applyRemoteOp(db: LocalDB, op: RemoteOp) {
switch (op.op_type) {
case "crdt_update":
// CRDT 병합: 순서 무관, 항상 안전
db.mergeCrdt(op.note_id, op.payload);
break;
case "upsert_meta":
// LWW 병합: HLC가 더 큰 쪽만 반영
db.applyIfNewer(op.note_id, op.payload, op.hlc);
break;
case "delete":
db.softDelete(op.note_id, op.hlc);
break;
}
}
공유는 서버 확정 경로로 분리합니다.
// 공유 초대: 서버 중재가 필요한 쓰기의 예
async function inviteCollaborator(noteId: string, email: string) {
// 전통적 API 호출 — 서버가 권한 테이블을 갱신하고
// 결과를 동기화 스트림으로 모든 참여자 기기에 전파한다
const res = await api.post("/shares", { noteId, email });
if (!res.ok) {
enqueueRetry({ kind: "invite", noteId, email }); // 오프라인 큐
}
}
이 작은 설계 안에 앞서 말한 원칙이 모두 들어 있습니다. 본문은 CRDT, 메타데이터는 LWW와 HLC, 권한은 서버 중재, 삭제는 tombstone, 스키마 버전은 레코드에 내장. 실제 제품은 여기에 스냅샷 압축과 부분 동기화(폴더 단위 구독)를 더하게 됩니다.
부분 동기화 설계 심화 — 쿼리 구독 패턴
노트가 10만 개인 사용자의 모바일 기기에 전체 데이터를 내려받게 할 수는 없습니다. 최근 동기화 엔진들이 수렴하고 있는 답은 선언적 쿼리 구독입니다. 클라이언트가 "내가 보고 싶은 부분집합"을 쿼리로 선언하면, 엔진이 그 결과 집합을 로컬에 실체화하고 변경분을 계속 반영합니다.
// 쿼리 구독 패턴의 개념적 예시
// "내 폴더 중 최근 30일 내 수정된 노트"만 로컬에 유지한다
const subscription = syncEngine.subscribe({
table: "notes",
where: {
folder_id: { in: myFolderIds },
updated_at: { gte: daysAgo(30) },
},
include: ["attachments_meta"], // 본문 첨부는 메타데이터만
});
// 구독 결과는 반응형 — 원격 변경이 도착하면 UI가 자동 갱신
subscription.onChange((rows) => renderNoteList(rows));
// 화면을 벗어나면 구독 해제 → 로컬 캐시 수명 관리
subscription.unsubscribe();
이 패턴을 설계할 때 주의할 점이 세 가지 있습니다.
첫째, 구독 경계를 넘는 이동입니다. 노트가 구독 범위 밖 폴더로 이동하면 로컬에서 사라져야 하는데, 사용자에게는 삭제처럼 보일 수 있습니다. "내려받지 않음" 상태를 UI에서 구분해야 합니다.
둘째, 권한과 구독의 결합입니다. 서버는 구독 쿼리를 평가할 때 반드시 권한 필터를 함께 적용해야 합니다. 클라이언트가 보낸 쿼리를 신뢰하는 순간 데이터 유출 통로가 됩니다.
셋째, 첨부 파일 같은 대용량 블롭입니다. 블롭은 oplog에 싣지 말고, 메타데이터만 동기화한 뒤 내용은 콘텐츠 주소(해시) 기반으로 지연 다운로드하는 것이 정석입니다.
동기화 코드의 테스트 전략
동기화 버그는 재현이 어렵기로 악명 높습니다. 다행히 local-first 아키텍처는 테스트하기 좋은 성질을 하나 갖고 있습니다. 병합 로직이 순수 함수에 가깝다는 점입니다. 다음 세 층위로 테스트를 쌓는 것을 권합니다.
// 1층: 병합 규칙의 성질 기반 테스트 (property-based)
// "어떤 순서로 적용해도 최종 상태가 같다"를 무작위 연산열로 검증
test("convergence: any order of ops yields identical state", () => {
const ops = genRandomOps(50); // 무작위 연산 50개 생성
const stateA = applyAll(emptyDb(), shuffle(ops));
const stateB = applyAll(emptyDb(), shuffle(ops));
expect(canonical(stateA)).toEqual(canonical(stateB));
});
// 2층: 시나리오 테스트 — 두 가짜 클라이언트와 가짜 서버
test("offline edit on two devices merges without loss", async () => {
const server = new FakeSyncServer();
const alice = new FakeClient(server);
const bob = new FakeClient(server);
await alice.sync();
await bob.sync();
alice.goOffline();
bob.goOffline();
alice.editBody("note1", "앨리스의 문장 추가");
bob.editBody("note1", "밥의 다른 문단 수정");
alice.goOnline();
await alice.sync();
bob.goOnline();
await bob.sync();
await alice.sync(); // 밥의 변경 수신
expect(alice.read("note1")).toEqual(bob.read("note1"));
expect(alice.read("note1")).toContain("앨리스의 문장 추가");
expect(alice.read("note1")).toContain("밥의 다른 문단 수정");
});
3층은 카오스 테스트입니다. 네트워크 단절, 메시지 중복 전달, 순서 뒤바뀜, 클라이언트 강제 종료를 무작위로 주입하면서 수렴성과 데이터 무손실을 장시간 검증합니다. 동기화 엔진을 직접 만들지 않고 기성 엔진을 쓰더라도, 자신의 병합 규칙(LWW 필드 선택, 삭제 정책)에 대해서는 1층과 2층 테스트를 직접 갖추는 것이 좋습니다.
도입 결정 프레임워크 — 우리 제품에 맞는가
마지막으로, local-first 도입 여부를 판단하는 실용적 질문 목록입니다. 정직하게 답해보시기 바랍니다.
- 데이터의 주인이 명확히 개별 사용자(또는 소규모 팀)인가. 그렇다면 적합도가 높습니다. 전사 공유 대시보드라면 낮습니다.
- 오프라인이나 느린 네트워크에서 쓰는 시나리오가 실제로 존재하는가. 이동 중 메모, 비행기, 현장 작업 같은 구체적 장면이 떠오르지 않으면 비용 대비 효과가 낮습니다.
- 전역 불변식이 핵심 가치인가. 좌석 예약처럼 "동시에 한 명만"이 본질이면 서버 중심이 맞습니다.
- 실시간 협업이 로드맵에 있는가. 있다면 처음부터 CRDT 친화적 데이터 모델을 잡는 편이, 나중에 갈아엎는 것보다 압도적으로 쌉니다.
- 팀이 분산 시스템 디버깅을 감당할 수 있는가. HLC, 인과 순서, 멱등성 같은 개념에 익숙한 사람이 적어도 한 명은 필요합니다.
- 데이터 한 사용자분의 크기가 기기에 들어가는가. 수 GB를 넘는다면 부분 동기화 설계가 필수이며 난이도가 한 단계 올라갑니다.
여섯 개 중 1, 2, 4번이 "예"라면 진지하게 검토할 가치가 있고, 3번이 "예"라면 해당 도메인만 서버 중재로 분리하는 혼합 설계를 고려하시기 바랍니다.
비즈니스 모델 관점
local-first는 기술 선택이자 비즈니스 선택입니다. 데이터를 인질로 잡는 잠금 효과가 약해지므로, 다른 가치로 과금해야 합니다. 시장에서 검증된 모델은 세 가지입니다.
- 동기화 서비스 과금. 소프트웨어는 무료지만 멀티 디바이스 동기화와 협업 중계에 구독료를 받습니다. 동기화 서버는 자체 호스팅 옵션을 열어두면서도 편의성으로 차별화합니다.
- 일회성 구매 회귀. 영속성 원칙과 맞물려, 구독 피로 사용자층에게 "한 번 사면 계속 쓰는" 모델이 다시 호소력을 얻고 있습니다.
- 팀과 기업 기능 과금. 개인 사용은 무료로 열고 권한 관리, 감사 로그, SSO처럼 서버가 본질적으로 필요한 기능에 과금합니다. 서버 필수 영역과 과금 영역을 일치시키는 깔끔한 구조입니다.
역설적으로 local-first는 이탈 장벽이 낮기 때문에 제품 품질로 붙잡아야 하고, 이것이 장기적으로는 더 건강한 인센티브 구조라는 주장이 커뮤니티의 중론입니다.
한계와 비판적 시각
균형을 위해 반대편 논거도 정리합니다.
복잡성 비용이 실재합니다. 서버 중심 CRUD는 수십 년간 다듬어진 패턴과 도구가 있지만, local-first는 동기화, 병합, 스키마 진화의 복잡성을 모든 기능 개발에 상수로 추가합니다. 작은 팀이 감당할 수 있는지 냉정하게 평가해야 합니다.
모든 앱에 맞는 것은 아닙니다. 은행, 재고, 예약처럼 전역 불변식이 핵심인 도메인은 서버 중재가 본질이며, local-first의 이득이 적습니다. 반대로 문서, 노트, 디자인, 개인 지식 관리처럼 사용자 소유 데이터 중심 도메인이 최적 적용처입니다.
종단간 암호화와 협업 기능은 긴장 관계입니다. 서버가 내용을 못 보면 서버 측 검색, 스팸 필터링, AI 기능이 어려워집니다. 7원칙을 전부 동시에 만족하는 제품은 아직 드물고, 대부분 일부를 의식적으로 포기합니다.
생태계가 아직 젊습니다. 동기화 엔진들의 API는 빠르게 변하고 있고, 장기 운영 사례가 충분히 쌓이지 않았습니다. 5년 뒤에도 유지될 엔진을 고르는 것 자체가 리스크 관리입니다.
마치며
Local-first는 "클라우드냐 로컬이냐"의 양자택일이 아니라, 진실의 원천을 사용자 쪽으로 옮기고 서버를 중계자로 재배치하는 아키텍처 재설계입니다. 클라우드 피로감이라는 시대 정서가 수요를 만들고, SQLite Wasm과 성숙해진 동기화 엔진들이 공급을 만들면서, 2026년의 local-first는 이상주의 선언문에서 실용 기술 스택으로 내려왔습니다.
시작하는 가장 좋은 방법은 작게 실험하는 것입니다. 기존 제품의 읽기 경로 하나를 PowerSync나 ElectricSQL로 로컬 미러링해보거나, 사이드 프로젝트 노트 앱을 위의 미니 설계대로 만들어보시기 바랍니다. 스피너가 사라진 UI를 한 번 경험하면, 왕복 지연시간 위에 쌓아온 기존 설계가 다르게 보이기 시작할 것입니다.
참고 자료
- Ink and Switch의 Local-First Software 에세이: https://www.inkandswitch.com/local-first/
- Local-First Web 커뮤니티: https://localfirstweb.dev/
- Automerge 공식 문서: https://automerge.org/
- Yjs 공식 문서: https://docs.yjs.dev/
- ElectricSQL 공식 사이트: https://electric-sql.com/
- PowerSync 공식 문서: https://docs.powersync.com/
- Zero (Rocicorp) 공식 사이트: https://zero.rocicorp.dev/
- Triplit 공식 문서: https://www.triplit.dev/docs
- SQLite Wasm 공식 문서: https://sqlite.org/wasm/doc/trunk/index.md
- Hacker News의 local-first 토론 검색: https://news.ycombinator.com/item?id=23985816
- GeekNews 메인: https://news.hada.io/
- CRDT 기술 자료 모음: https://crdt.tech/