Linear는 왜 0ms로 느껴질까: 로컬퍼스트와 CRDT
즉시 반응하는 앱의 밑바닥에는 뭐가 있나. SQLite 값 하나의 일생을 따라가며 로컬퍼스트를 해부하고, 당신의 앱에 CRDT가 필요한지 판단해본다.
Linear에서 이슈 제목을 고치면 지연이 없다. 스피너도, 저장 버튼도 없다. 비행기 모드에서 고쳐도 되고, 다시 연결되면 알아서 서버와 맞춰진다. Figma에서 두 사람이 같은 도형을 동시에 만져도 문서가 깨지지 않는다.
이 경험에는 이름이 있다. 로컬퍼스트(local-first). 그리고 그 밑바닥에는 CRDT라는, 분산 시스템 연구에서 건너온 진짜 컴퓨터과학이 깔려 있다.
이 글은 세 층으로 내려간다. 먼저 이 경험이 흔한 낙관적 업데이트와 뭐가 다른지, 다음으로 SQLite 안에서 값 하나가 어떻게 추적되고 병합되는지, 마지막으로 그 병합이 왜 수학적으로 안전한지. 그리고 끝에서 반대 방향의 질문을 던진다. 당신의 앱에 이게 정말 필요한가.
1. 낙관적 업데이트는 로컬퍼스트가 아니다
프론트엔드 개발자라면 "즉시 반응"을 이미 만들 줄 안다. 서버 응답을 기다리지 않고 UI를 먼저 바꾸는 낙관적 업데이트(optimistic update). React Query의 onMutate로 다들 한 번씩 해봤을 것이다.
하지만 낙관적 업데이트와 로컬퍼스트는 겉만 닮은 다른 물건이다. 차이는 진실이 어디 있느냐다.
낙관적 업데이트에서 진실은 여전히 서버에 있다. 클라이언트는 서버의 응답을 "미리 예측"해서 그려둘 뿐이고, 예측이 틀리면 롤백한다. 네트워크가 끊기면 그냥 실패다. 탭을 닫으면 보내지 못한 변경은 사라진다.
로컬퍼스트에서 진실은 내 기기에 있다. 쓰기는 로컬 데이터베이스에 커밋되는 순간 완료된 것이다. 서버는 진실의 원본이 아니라 기기들 사이의 중계자이고, 동기화는 백그라운드에서 알아서 일어난다. 그래서 0ms인 것이다. 네트워크가 왕복하는 시간이 UX에서 사라진 게 아니라, 애초에 쓰기 경로에 네트워크가 없다.
문제는 여기서 시작된다. 진실이 기기마다 하나씩 있다면, 오프라인에서 서로 다르게 바뀐 진실들을 나중에 어떻게 하나로 합치나? 이게 로컬퍼스트 엔지니어링의 전부라고 해도 과언이 아니다.
2. 값 하나의 일생: SQLite 안에서 벌어지는 일
이 병합 문제를 실제 코드 레벨에서 어떻게 푸는지, Marco Bambini가 The Secret Life of a Local-First Value에서 SQLite 기반으로 해부한 내용이 가장 구체적이다. ToDo 앱의 행 하나가 겪는 일을 따라가 보자.
쓰기는 트리거에 잡힌다
로컬퍼스트 엔진은 사용자 테이블에 INSERT/UPDATE/DELETE 트리거를 걸어 모든 변경을 가로챈다. 핵심은 변경을 행 단위가 아니라 컬럼 단위로 쪼갠다는 것.
INSERT INTO todo VALUES ('ID1', 'Buy groceries', 'in_progress');이 한 줄은 엔진 입장에서 세 개의 컬럼 이벤트다. 각 이벤트에는 인과(causal) 메타데이터가 붙어 숨겨진 메타데이터 테이블에 쌓인다.
site_id— 어느 기기에서 일어난 변경인지 (Alice의 폰, Bob의 노트북)column_version— 그 컬럼이 그 기기에서 몇 번째로 바뀌었는지 세는 논리 시계db_version— 데이터베이스 전체 기준의 Lamport 시계op_type,seq— 연산 종류와 같은 버전 안에서의 순서
왜 컬럼 단위로 쪼갤까. Alice가 할 일의 status를 바꾸고 Bob이 같은 행의 title을 바꿨다면, 이 둘은 사실 충돌이 아니기 때문이다. 행 단위로 추적하면 한쪽이 다른 쪽을 통째로 덮어쓰지만, 컬럼 단위로 추적하면 둘 다 살아남는다. 충돌의 정의를 최대한 좁히는 설계다.
삭제는 삭제가 아니다
DELETE도 물리적 삭제가 아니라 툼스톤(tombstone), 즉 "삭제됐음"이라는 표식이다. 진짜로 지워버리면 "Bob은 이 행이 존재했다는 사실 자체를 모른다"와 "Bob이 이 행을 지웠다"를 구분할 수 없게 되고, 동기화가 무너진다. 삭제 시계가 그 행의 다른 컬럼 시계보다 앞서면 그 행은 지워진 것으로 보인다.
동기화는 "너 어디까지 봤어?"
오프라인이던 두 기기가 다시 만나면 대화는 이렇게 흘러간다. Alice가 Bob에게 묻는다. "내 site_id 기준으로 db_version 몇까지 봤어?" Bob이 답하면, Alice는 그 이후의 연산만 보낸다. 중앙 서버를 두는 경우에도 원리는 같다. 서버가 site별 버전 맵을 관리하는 중계자가 될 뿐이다.
받은 쪽은 연산을 인과 순서대로 재생하고, 컬럼마다 규칙을 적용한다. 내 버전이 낮으면 덮어쓰고, 높으면 무시한다. 같은 컬럼을 양쪽이 동시에 바꾼 진짜 충돌은 시계가 높은 쪽이 이긴다. 어느 기기에서 먼저 재생하든 결과는 같다.
이 마지막 문장이 사실 이 시스템 전체의 심장이다. "어느 순서로 합쳐도 결과가 같다"는 게 왜 보장되는가?
3. 수학은 하나만 제대로: LWW 레지스터
CRDT(Conflict-free Replicated Data Type)는 종류가 많다. 카운터, 집합, 리스트, 텍스트까지. 전부 다루려면 Bartosz Sypytkowski의 시리즈처럼 22편이 필요하다. 여기서는 위 시스템이 쓰는 가장 단순한 것 하나, **LWW 레지스터(Last-Write-Wins Register)**만 바닥까지 파본다.
LWW 레지스터는 값 하나를 담는 상자다. 상태는 (값, 타임스탬프, site_id) 튜플이고, 병합 규칙은 한 줄이다.
merge(a, b) = 타임스탬프가 큰 쪽.
같으면 site_id가 큰 쪽.
시시해 보이지만, 이 규칙은 세 가지 성질을 만족한다.
- 교환법칙:
merge(a, b) = merge(b, a)— 누가 누구와 합치든 같다 - 결합법칙:
merge(merge(a, b), c) = merge(a, merge(b, c))— 어떤 순서로 합치든 같다 - 멱등성:
merge(a, a) = a— 같은 걸 두 번 합쳐도 변하지 않는다
이 셋이 합쳐지면 강력한 보장이 나온다. 기기가 몇 대든, 네트워크가 어떤 순서로 어떤 중복을 만들어 연산을 배달하든, 모든 기기가 같은 연산 집합을 보기만 하면 반드시 같은 상태에 도달한다. 이걸 강한 최종 일관성(strong eventual consistency)이라 부른다. 합의(consensus)가 필요 없다. 투표도, 락도, 조정자도 없이, 자료구조의 성질만으로 수렴이 보장된다.
수학적으로 이 구조는 join semilattice라 불린다. 이름은 몰라도 된다. 중요한 직관은 이것이다. 병합을 "최댓값 고르기"로 정의하면, 최댓값 연산이 원래 갖고 있는 성질들이 분산 시스템의 무질서를 전부 흡수해준다.
물론 공짜는 아니다. "타임스탬프 큰 쪽이 이긴다"는 건 뒤집으면 "진 쪽의 쓰기는 조용히 사라진다"는 뜻이다. 같은 컬럼을 동시에 바꾼 두 쓰기 중 하나는 반드시 유실된다. 그래서 이 방식은 status 같은 독립적인 필드에는 잘 맞지만, 텍스트 공동 편집에는 쓸 수 없다. 문장을 통째로 잃을 수는 없으니까. 텍스트에는 RGA나 Fugue 같은 훨씬 정교한(그리고 훨씬 비싼) CRDT가 따로 있다.
4. 직접 해보며: 이 블로그의 댓글은 로컬퍼스트인가
남의 사례만 보면 감이 안 오니, 내 코드를 해부해보자. 이 블로그의 댓글은 로그인 없이 달 수 있고, 등록 버튼을 누르면 즉시 화면에 나타난다. 즉시 반응하니까 로컬퍼스트인가?
아니다. 정확히 "1막에서 말한 낙관적 업데이트"다.
await addComment(slug, author, body, parentId); // 서버(Supabase)에 먼저 쓰고
setComments((prev) => [...(prev ?? []), { ... }]); // 성공하면 UI에 반영진실은 Supabase에 있다. 로컬 저장소도, 쓰기 큐도, 병합 로직도 없다. 오프라인에서 댓글을 쓰면? 그냥 실패한다. 실패한 댓글을 보관했다가 재시도하는 큐도 없으니 내용은 유실된다. 두 사람이 동시에 댓글을 달면? 충돌 자체가 없다. 댓글은 수정이 불가능한 append-only 데이터라, 서버가 도착 순서대로 쌓기만 하면 된다.
그리고 이게 정답이다. 댓글에 로컬퍼스트를 도입한다면 얻는 것은 "오프라인에서 쓴 댓글이 나중에 올라감" 하나뿐인데, 치르는 비용은 로컬 DB, 변경 추적, 동기화 프로토콜, 툼스톤 관리 전부다. 블로그 댓글이라는 문제의 크기에 비해 기계가 터무니없이 크다.
반면 Linear의 이슈 편집은 계산이 다르다. 사용자가 하루 종일 수백 번 쓰고, 이동 중에 쓰고, 같은 데이터를 여러 기기에서 만진다. 쓰기 경로에서 네트워크를 제거하는 것 자체가 제품의 정체성이 된다. 같은 기술도 문제의 모양에 따라 과잉이 되기도, 본질이 되기도 한다.
5. 반론: CRDT가 필요 없을 수도 있다
여기까지 읽으면 CRDT가 만능처럼 보이지만, 업계에는 정반대의 견해도 탄탄하게 존재한다.
You Might Not Need a CRDT의 논지는 이렇다. 대부분의 앱에는 어차피 권위 있는 서버(authoritative server)가 있다. 서버가 있다면 연산에 전역 순서를 부여할 수 있고, 그러면 CRDT 없이도 **서버 조정(server reconciliation)**으로 충돌을 풀 수 있다. 원격 연산이 도착하면 내 미확정 로컬 연산들을 되감고, 원격 연산을 적용한 뒤, 로컬 연산을 다시 재생하는 방식이다. 게임 넷코드에서 수십 년 검증된 기법이고, Replicache나 Fluid Framework가 이 계열이다. CRDT의 진짜 값어치는 서버 없는 P2P 토폴로지와 긴 오프라인 편집에서 나오는데, 그게 필요한 앱은 생각보다 적다는 것.
실제로 Figma가 좋은 예다. Figma의 멀티플레이어는 흔히 CRDT 사례로 소개되지만, 엄밀히는 아니다. 서버가 중재자로 존재하고, 객체 속성 단위의 LWW로 충돌을 정리한다. CRDT에서 영감을 받았지만 서버 권위를 포기하지 않는, 실용주의적 절충이다.
Tonsky의 Local, First, Forever는 또 다른 각도를 찌른다. 로컬퍼스트의 진짜 약속은 속도가 아니라 소유라는 것. 서비스를 만든 회사가 망해도 내 데이터와 앱은 계속 동작해야 한다. 그래서 그는 전용 동기화 서버 대신, 이미 모두가 쓰는 Dropbox나 iCloud 같은 파일 동기화 위에 CRDT 파일을 얹자고 제안한다. 실시간성은 잃지만, 특정 회사에 의존하지 않는 동기화 백본을 얻는다. 도발적이지만, "로컬퍼스트가 왜 필요한가"라는 질문의 원점을 다시 보게 만드는 글이다.
그래서, 내 앱에는 뭐가 맞나
정리하면 판단 기준은 세 가지다.
- 충돌이 실제로 자주 나는가 — 여러 기기·여러 사람이 같은 데이터를 만지는가. 아니라면 낙관적 업데이트 + 서버 권위로 충분하다
- 오프라인이 기능인가, 엣지 케이스인가 — 이동 중 사용이 제품의 핵심이면 로컬퍼스트가 본질이다. 어쩌다 터널에서 끊기는 정도면 재시도 큐가 정답이다
- 서버를 없앨 이유가 있는가 — P2P, 데이터 소유, 서비스 종료 이후의 생존까지 약속하고 싶다면 그때가 CRDT의 홈그라운드다
그리고 직접 밑바닥부터 구현할 필요는 없다. 텍스트와 범용 문서에는 Yjs와 Automerge가 사실상 표준이고, SQLite 위에는 cr-sqlite 같은 확장이, 동기화 계층에는 Electric이나 Zero 같은 엔진이 이미 나와 있다. 중요한 건 도구 선택이 아니라, 위의 세 질문에 먼저 답하는 것이다.
마무리
로컬퍼스트는 "빠른 앱을 만드는 기법"이 아니라 진실의 위치를 옮기는 아키텍처 결정이다. 진실을 기기로 옮기는 순간 0ms와 오프라인은 공짜로 따라오지만, 대신 병합이라는 분산 시스템의 고전 문제를 프론트엔드가 떠안게 된다. 그 문제를 수학으로 푸는 게 CRDT고, 서버 권위로 푸는 게 조정 계열이고, 문제 자체를 회피하는 게 지금 대부분의 앱이 서 있는 자리다.
세 자리 모두 정당하다. 다만 어디에 서 있는지는 알고 서 있자. Linear의 0ms에 감탄하는 것과, 내 제품에 그 기계가 필요한지 판단하는 것은 다른 일이니까.
참고: The Secret Life of a Local-First Value · Bartosz Sypytkowski의 CRDT 시리즈 · You Might Not Need a CRDT · Local, First, Forever