DEV_BBAK
← 포스트

Linear는 왜 빠를까

·27 min read·로딩중...

Linear를 처음 쓸 때 느끼는 것

Linear를 처음 쓰면 이상한 느낌이 든다. 분명히 브라우저에서 도는 웹 앱인데, 버튼을 누르면 즉시 반응한다. 이슈를 생성하면 스피너가 없다. 상태를 바꾸면 화면이 바로 바뀐다. 필터를 걸고 검색해도 지연이 없다. Jira나 Notion에서 겪던 "요청 보내고 로딩 스피너 돌고 응답 오면 렌더링"이라는 익숙한 사이클이 통째로 빠져 있다.

한마디로: 클라우드 앱인데 로컬 앱처럼 동작한다.

이게 어떻게 가능한지 궁금해졌다. 단순히 자바스크립트 최적화를 열심히 한 걸까? 렌더링 성능을 짜낸 걸까? 아니었다. Linear가 빠른 진짜 이유는 전체 아키텍처를 로컬 퍼스트(Local-First)로 설계했기 때문이다. 이건 최적화 문제가 아니라 설계 철학의 문제다.

이 글에서는 로컬 퍼스트가 무엇인지, 그 핵심 기술 과제인 CRDT(충돌 없는 복제 데이터 구조)가 어떤 문제를 푸는지, 그리고 Linear가 그 모든 것을 어떻게 실용적으로 엮어냈는지를 정리한다.

전통적 웹 앱은 왜 느릴 수밖에 없는가

보통의 웹 앱은 이런 구조로 동작한다.

사용자 조작 → HTTP 요청 → 서버 → DB → 응답 → 클라이언트 렌더링

사용자가 뭘 하든, 그 행동이 화면에 반영되려면 서버를 왕복해야 한다. 이 왕복에 걸리는 시간, 즉 네트워크 RTT(Round Trip Time)가 사용자 경험에 직접 끼어든다. 서울에서 도쿄 리전을 쓰면 편도 약 30ms, 미국 동부라면 편도 150ms 이상이 된다. 사용자가 버튼을 누를 때마다 이 지연이 쌓인다.

이걸 개선하려고 Optimistic UI(낙관적 업데이트)라는 기법이 널리 쓰인다. 버튼을 누르면 서버 응답을 기다리지 않고 일단 UI를 먼저 바꾸는 방식이다. 좋아요 버튼을 누르면 즉시 숫자가 올라가고, 뒤에서 서버와 통신하는 식이다.

하지만 Optimistic UI는 근본적 해결책이 아니다. 서버가 여전히 진실의 원천(Source of Truth)이기 때문이다. 서버가 거절하면 UI를 롤백해야 하고, 오프라인 상태에서는 아예 동작하지 않는다. 읽기도 마찬가지다. 데이터를 보려면 fetch를 해야 하고, 캐시가 있어도 캐시가 신선한지 확인하려면 결국 서버에 물어봐야 한다.

구조 자체가 "서버가 주인이고 클라이언트는 보여주기만 하는 곳"이기 때문에, 지연은 설계상 불가피하다.

로컬 퍼스트: 데이터가 내 기기에 산다

로컬 퍼스트(Local-First)는 이 구조를 뒤집는다.

2019년, Ink & Switch 연구소에서 Local-First Software 선언문을 발표했다. 핵심 주장은 단순하다: 데이터의 진실의 원천을 로컬 기기에 두자. 클라우드는 동기화와 협업을 위한 매개체일 뿐, 데이터의 주인은 아니다.

선언문은 7가지 이상적 원칙을 제시한다:

  1. No spinners — 모든 읽기/쓰기가 로컬에서 즉시 일어나므로 로딩 스피너가 필요 없다
  2. Multi-device — 여러 기기에서 동일한 데이터에 접근하고 동기화된다
  3. Collaboration — 여러 사용자가 동시에 편집해도 충돌 없이 병합된다
  4. The long now — 서버가 사라져도 데이터는 보존된다
  5. Privacy by design — 데이터는 로컬에 저장되고, 전송 시에도 암호화된다
  6. No service interruption — 인터넷이 끊겨도 앱이 정상 작동한다
  7. Ownership — 사용자가 자신의 데이터를 온전히 통제한다

전통적 클라우드 앱에서 로컬 퍼스트 앱으로의 전환은 단순한 기술 교체가 아니다. 데이터가 사는 곳이 바뀌는 것이다.

전통적 클라우드 앱:  클라이언트 → HTTP 요청 → 서버 DB → 응답 → 렌더링
로컬 퍼스트 앱:      클라이언트 → 로컬 DB → 즉시 렌더링
                                 ↓ (비동기, 백그라운드)
                            동기화 엔진 → 서버 → 다른 클라이언트

로컬 DB가 진실의 원천이 되면, 읽기는 로컬 디스크에서 일어난다. 마이크로초 단위다. 쓰기도 로컬에 즉시 커밋된다. 네트워크는 사용자 경험 경로에서 완전히 빠진다. 서버와의 동기화는 백그라운드에서, 별도의 경로로 일어난다.

이게 Linear가 스피너 없이 동작하는 근본 이유다.

CRDT — 순서가 상관없으면 충돌도 없다

하지만 로컬 퍼스트에는 난제가 있다. 여러 기기가 각자 로컬에서 데이터를 수정하면, 나중에 동기화할 때 충돌이 발생한다.

A 사용자가 오프라인 상태에서 이슈 제목을 "버그 수정"으로 바꿨다. 같은 시간 B 사용자도 오프라인에서 같은 이슈 제목을 "긴급 버그"로 바꿨다. 둘이 다시 온라인이 되어 동기화하면, 누구의 변경이 살아야 할까?

이 문제를 푸는 두 가지 접근이 있다.

OT (Operational Transformation)

Google Docs가 쓰는 방식이다. 중앙 서버가 모든 편집 연산을 받아 순서를 정렬한다. 클라이언트가 보낸 연산과 서버에 이미 적용된 연산 사이에 충돌이 있으면, 서버가 연산을 변환(transform)해서 일관성을 맞춘다. 중앙 코디네이터가 반드시 필요하다.

CRDT (Conflict-free Replicated Data Types)

CRDT는 다른 접근을 취한다. 충돌을 해결하는 게 아니라, 애초에 충돌이 불가능한 데이터 구조를 만드는 것이다.

핵심 아이디어는 수학적이다. 병합 연산이 세 가지 성질을 만족하면 된다:

  • 교환법칙 (commutative): A ∘ B = B ∘ A — 병합 순서가 달라도 결과가 같다
  • 결합법칙 (associative): (A ∘ B) ∘ C = A ∘ (B ∘ C) — 그룹핑이 달라도 같다
  • 멱등성 (idempotent): A ∘ A = A — 같은 연산을 두 번 적용해도 같다

이 세 가지가 성립하면, 여러 노드가 어떤 순서로든 연산을 받아 병합해도 항상 동일한 결과로 수렴한다. 중앙 서버가 순서를 정할 필요가 없다. P2P 동기화도 가능하다.

예를 들어보자. 두 사람이 같은 문서를 편집한다. A는 1번 줄에 "hello"를 추가하고, B는 3번 줄에 "world"를 추가했다. CRDT에서 이 두 연산은 서로 다른 위치에 영향을 주므로, 어떤 순서로 적용하든 두 변경이 모두 살아있다. 충돌이 없다.

물론 같은 위치를 동시에 편집하면? 이때는 CRDT 타입마다 정해진 규칙이 있다. 가장 흔한 건 LWW (Last-Write-Wins): 타임스탬프가 늦은 쪽이 이긴다. 중요한 건 "누가 이기는가"가 아니라 **"모든 노드가 같은 결과로 수렴하는가"**다.

CRDT의 주요 타입은 이런 것들이 있다:

타입 설명 쓰임
Counter (G/PN-Counter) 증가/감소 카운터 조회수, 좋아요
LWW-Register 마지막 쓰기가 이기는 단일 값 필드 값
OR-Set 원소 추가/삭제 가능 집합 태그 목록
RGA / LSEQ 순서가 있는 시퀀스 공동 문서 편집

이 중에서 텍스트 편집에 쓰이는 시퀀스 CRDT(RGA, LSEQ 등)가 가장 복잡하고, 동시 편집 기능의 기반이 된다.

실제 구현체로는 Yjs(JavaScript, 가장 널리 쓰임)와 Automerge(Ink & Switch 개발, JSON-like CRDT)가 대표적이다. Notion, Linear, Figma 같은 도구들이 이 영역의 기술적 영감을 주고받았다.

CRDT와 OT의 핵심 차이는 중앙 서버 의존성이다. OT는 중앙 서버가 반드시 있어야 하고, 서버가 다운되면 협업이 멈춘다. CRDT는 서버 없이도 P2P로 동작한다. 로컬 퍼스트의 "서버가 사라져도 데이터가 보존된다"는 원칙과 자연스럽게 맞닿아 있다.

그래서 로컬 퍼스트의 이상향은 CRDT 기반 동기화라는 말이 자연스럽게 나온다. Linear도 이 이상향에 가까이 가 있을까?

Linear의 동기화 엔진을 뜯어보면

Linear의 아키텍처는 공개된 리버스 엔지니어링 문서를 통해 꽤 상세하게 알려져 있다. 놀랍게도 Linear의 CTO Tuomas Artman이 직접 "우리 내부 문서보다 정확하다"고 인정한 문서다1.

Linear의 동기화 엔진(LSE)은 세 계층으로 구성된다.

첫째, Object Graph (객체 그래프). MobX로 관리되는 인메모리 객체 그래프다. 모든 모델(Issue, Project, Cycle 등)은 observable 프로퍼티를 가지며, React 컴포넌트가 이 프로퍼티를 관찰한다. 데이터가 바뀌면 관련 컴포넌트만 자동으로 리렌더링된다.

둘째, Object Pool (객체 풀). 정규화된 데이터 저장소다. 모든 모델 객체가 UUID로 조회된다. 참조 관계(Issue가 가리키는 Project, 작성자 User 등)가 자동으로 해결된다.

셋째, Transaction Queue (트랜잭션 큐). 변경 사항을 트랜잭션으로 포장해 IndexedDB에 영속화하고 서버로 비동기 전송한다. 실패하면 클라이언트에서 되돌릴 수 있다(reversible).

사용자가 이슈 상태를 "Done"으로 바꾼다고 하자. model.save() 한 번의 호출이 다음 사이클을 트리거한다:

Object Graph 즉시 업데이트
  → Object Pool 반영
    → IndexedDB 영속화
      → Transaction Queue → 서버로 비동기 전송

UI는 서버 응답을 기다리지 않는다. save()가 리턴되는 순간 화면은 이미 바뀌어 있다. 네트워크가 끊겨도 앱은 정상 동작한다. 트랜잭션이 큐에 쌓였다가 연결이 복구되면 서버로 전송된다.

읽기 경로도 마찬가지다. Linear는 처음 로드할 때 서버에서 전체 데이터 스냅샷을 받아 IndexedDB에 저장한다. 이후에는 IndexedDB에서 데이터를 hydrate(복원)해서 화면을 그린다. 서버와는 WebSocket으로 "마지막으로 동기화된 시점 이후에 뭐가 바뀌었나?"만 물어본다.

이게 Linear의 콜드 스타트가 1초 미만인 비결이다. IndexedDB가 단순한 응답 캐시가 아니라, **서버 스키마의 타입화된 미러(typed mirror)**다. 디스크에서 그래프를 바로 재구성할 수 있기 때문에, 네트워크를 기다릴 필요가 없다. 대부분의 웹 앱이 못하는 이유가 바로 여기에 있다 — 캐시가 단순 응답 JSON일 뿐, typed object graph가 아니기 때문에 디스크에서 바로 렌더링할 수 없다.

동기화는 Delta Sync라는 방식으로 동작한다. 서버는 모든 데이터 변경에 syncId라는 단조 증가 정수를 부여한다. 클라이언트는 자신의 lastSyncId를 기억하고, 서버에 "이 syncId 이후에 바뀐 것만 보내줘"라고 요청한다. 최소한의 데이터만 전송하는 증분 동기화다.

정리하면, Linear가 빠른 이유

여기까지 읽으면 "Linear가 빠르다"는 게 어떤 의미인지 감이 올 것이다. 한 줄로 요약하면: 네트워크가 사용자 경험 경로에 없다. 구체적으로는 다섯 가지가 겹쳐서 만들어내는 속도다.

1. 읽기와 쓰기가 모두 로컬에서 즉시 일어난다. 사용자가 이슈를 만들거나 상태를 바꾸면, 그 변경은 IndexedDB에 즉시 커밋되고 화면에 즉시 반영된다. 서버 응답을 기다리지 않는다. 이게 "스피너가 없는" 근본 이유다. 일반적인 웹 앱에서 읽기 하나 하려면 네트워크 왕복이 필요하지만, Linear에서는 로컬 디스크 쿼리로 끝난다.

2. 캐시가 단순 JSON이 아니라 타입화된 객체 그래프다. 대부분의 웹 앱도 IndexedDB나 메모리 캐시를 쓴다. 하지만 그 캐시는 서버 응답을 그대로 담은 JSON일 뿐이라, 앱을 다시 켤 때 결국 서버에서 최신 데이터를 받아와야 렌더링할 수 있다. Linear의 IndexedDB는 서버 스키마와 동일한 구조의 객체 그래프를 담고 있다. 디스크에서 그래프를 바로 재구성(hydrate)해서 화면을 그릴 수 있다. 새로고침해도 1초 안에 전체 화면이 뜨는 비결이다.

3. Delta Sync로 최소한의 데이터만 오간다. 서버와 동기화할 때 전체 데이터를 다시 받지 않는다. lastSyncId 이후에 바뀐 것만 증분으로 가져온다. 화면이 이미 그려져 있는 상태에서, 백그라운드로 아주 작은 패킷만 오가며 갱신된다. 사용자는 이 과정을 전혀 느끼지 못한다.

4. MobX의 세밀한 반응성이 불필요한 리렌더링을 없앤다. 데이터가 바뀌었을 때 전체 컴포넌트 트리를 다시 그리지 않는다. MobX가 관찰하는 프로퍼티 단위로 추적해서, 바뀐 부분과 연결된 컴포넌트만 갱신한다. React의 기본 동작인 "상태 바뀌면 자식 컴포넌트 전체 리렌더링"을 구조적으로 회피하는 것이다.

5. 트랜잭션이 실패해도 사용자가 모른다. 낙관적 업데이트를 쓰는 앱은 많다. 하지만 서버가 거절했을 때 UI를 롤백하면 화면이 깜빡이고, 사용자는 "아, 안 됐구나"를 경험하게 된다. Linear는 트랜잭션을 reversible하게 설계해서, 실패 시 자동으로 되돌린다. 사용자는 그냥 계속 쓰면 된다.

이 다섯 가지를 하나로 요약하면: Linear는 로컬 앱의 응답 속도를 가진 웹 앱이다. 각각의 기술이 만들어내는 체감 효과는 "이게 정말 클라우드 앱인가?"라는 의문이 들 정도다.

Linear가 CRDT를 선택하지 않은 이유

앞 절에서 CRDT가 로컬 퍼스트의 이상적 기술이라고 했다. 그렇다면 Linear는 CRDT를 쓸까?

아니다. Linear는 CRDT를 쓰지 않는다 — 적어도 핵심 동기화 엔진에서는.

Linear의 선택은 이랬다. 모든 트랜잭션에 syncId라는 단조 증가 정수를 부여한다. 이 정수는 서버가 발급한다. 서버가 모든 트랜잭션에 **전체 순서(total order)**를 정하는 것이다.

이건 CRDT와 근본적으로 다르다. CRDT는 병합 순서가 상관없도록 데이터 구조를 설계하는 접근이다. 부분 순서(partial order)만 보장하지만, 중앙 서버 없이도 수렴한다. 반면 Linear는 서버가 순서를 결정하는 전체 순서 기반이다. 이는 OT(Operational Transformation)에 더 가깝다 — 중앙 서버가 코디네이터 역할을 한다.

즉, Linear는 로컬 퍼스트의 UX(속도, 오프라인, 즉각적 반응)는 가져가되, 충돌 해결은 서버 중심으로 단순화한 것이다. CRDT의 수학적 우아함 대신, 서버가 권위를 가지는 단순한 모델을 택했다.

CRDT를 안 쓴 이유를 추정해볼 수 있다. CRDT에는 실용적 비용이 따른다. 메모리 오버헤드가 있다 — 모든 연산의 메타데이터(벡터 클락, 툼스톤 등)를 영원히 들고 있어야 한다. 텍스트 편집이 아닌 도메인(이슈 트래커, 프로젝트 관리)에서는 CRDT의 이점이 제한적이다 — 이슈 상태를 "In Progress"에서 "Done"으로 바꾸는 건 텍스트 삽입과 충돌 구조가 다르다. 그리고 Linear는 단일 제품에 최적화된 엔진이기 때문에, 범용 CRDT 라이브러리를 끼워넣는 것보다 자체 프로토콜이 더 효율적이었을 것이다.

흥미로운 점은, Linear가 리치 텍스트 편집(이슈 설명의 마크다운 에디터)에만 Y.js(CRDT)를 추가 도입했다는 것이다. 이게 Linear의 실용주의를 잘 보여준다. 일반 데이터 동기화는 서버 중심 순서 보장으로 단순하게 처리하고, 동시 편집이 실제로 필요한 텍스트 영역에만 CRDT를 적용한다. 한 시스템 안에 두 패러다임이 공존한다.

Linear의 하이브리드 전략:

일반 데이터 (이슈 상태, 담당자, 라벨 등)
  → 서버 중심 syncId 기반 동기화 (OT에 가까움)

리치 텍스트 (이슈 본문, 코멘트)
  → Y.js (CRDT) 기반 동시 편집

이 설계가 시사하는 바가 있다. 로컬 퍼스트와 CRDT는 같은 말이 아니다. 로컬 퍼스트는 "로컬 DB가 진실의 원천이다"라는 아키텍처 원칙이고, CRDT는 "분산 환경에서 충돌을 수학적으로 해결하는 기술"이다. 로컬 퍼스트를 하면서 충돌 해결은 서버에 맡길 수도 있다. Linear가 정확히 그렇게 했다.

우리가 배울 수 있는 것

Linear의 아키텍처를 그대로 복제하는 건 현실적으로 어렵다. CTO가 네 번째로 만든 동기화 엔진이고, 수년의 엔지니어링 투자가 들어있고, 단일 제품에 깊이 결합되어 있다. 동기화 엔진이 이슈, 프로젝트, 사이클이라는 도메인 모델을 알고 있고, 권한 체크가 동기화 프로토콜 내부에서 일어난다. 범용화하기 어려운 구조다.

하지만 핵심 원칙은 빌릴 수 있다.

"사용자 경험 경로에서 네트워크를 빼라." 이게 로컬 퍼스트의 본질이다. 네트워크는 항상 백그라운드에 있어야 한다. 사용자가 무언가를 했을 때, 그 반응이 로컬 디스크 속도로 일어나야 한다.

이 원칙을 100% 적용하지 않아도 된다. 80%의 효과를 주는 도구들이 이미 있다:

  • Replicache — 클라이언트-서버 동기화 엔진. 기존 서버 기반 앱에 점진적 도입이 가능하다.
  • PowerSync — PostgreSQL과 로컬 SQLite를 양방향 동기화. 모바일 앱에 적합하다.
  • Yjs / Automerge — CRDT 라이브러리. 동시 편집 기능이 필요한 곳에 직접 적용할 수 있다.
  • Triplit — 오픈소스 로컬 퍼스트 데이터베이스. 풀스택 솔루션이다.
  • ElectricSQL — Postgres 기반 실시간 로컬 동기화.

CRDT를 직접 써야 할 때와 안 써도 될 때의 기준도 중요하다. 동시 편집이 핵심 기능이면 CRDT가 필요하다 (Google Docs, Figma, Notion 에디터). 하지만 대부분의 데이터 변경이 한 번에 한 사용자에 의해 일어나는 도메인(이슈 트래커, 할일 앱, 설정)이라면, 서버 중심 순서 보장이 훨씬 단순하고 충분하다. Linear가 보여주듯, 섞어 쓰는 것도 가능하다.

로컬 퍼스트는 도구가 아니라 설계 원칙이다. 다음번 프로젝트에서 "이 데이터 요청, 정말 사용자를 기다리게 해야 하나?"라고 한 번 더 물어보는 것부터 시작할 수 있다.


참고 자료

Footnotes

  1. wzhudev/reverse-linear-sync-engine — GitHub 리버스 엔지니어링 프로젝트. CTO가 공식적으로 정확성을 인정했다.