출처 등급: A = Linear 공식 자료, CTO가 정확성을 공개 확인한 문서, 또는 직접 관측한 네트워크 페이로드 / B = 신뢰할 만한 2차 분석 / C = 단일 2차 출처, 수치로 못 박지 않음


0부. 들어가며 — 이 시장, 그리고 Linear의 자리#

0.1 Linear란#

Linear는 제품 개발 조직의 태스크 추적과 프로젝트 관리를 위한 통합 도구다.

프로젝트 관리를 풀어 쓰면, 정해진 기간·예산·자원 안에서 목표를 달성하기 위해 수행하는 모든 활동이다. 보통은 이를 위한 체계적인 가이드나 방법론을 가리킨다.

제품 개발 관련 책을 읽다 보면 방법론은 다양하지만 핵심은 하나로 모인다. 비즈니스 결정 권한이나 도메인 소유 권한을 누가 갖고 있느냐다. 도구가 방법론을 만드는 것이 아니라 권한 구조가 도구를 결정한다. 이 전제를 깔아 두어야 뒤에 나오는 “왜 Jira를 버리지 못하는가"가 이해된다.

0.2 Jira 이야기부터#

이 시장의 대표 주자는 Atlassian의 Jira다. Atlassian은 Dia 브라우저를 인수한 바로 그 회사다.

Jira는 과도한 설정과 나쁜 UX로 비판을 많이 받는다. 그럼에도 배제하기 어려운 솔루션이 되었다. 이미 너무 많은 기업이 쓰고 있고, 무엇보다 많은 프로젝트 관리자가 선호한다.

여기에는 흥미로운 현상이 하나 있다. PM은 각자 원하는 툴이나 새로 발견한 툴을 골라 쓴다. 그래서 담당자가 바뀔 때마다 새로운 프로젝트 관리 툴이 들어온다. 문제는 그 툴을 쓰던 사람이 떠나면 툴이 낯선 것이 되고, 그 과정에서 맥락이 사라진다는 점이다.

결국 “구관이 명관"이라는 결론으로 전사에 Jira를 강제하게 된다.

이 허들을 넘어선 것이 Notion이다. Notion은 비즈니스 솔루션 시장에 독특하게 안착한 사례다.

필자는 이 툴이 성공하리라 기대한 적이 없다. 끔찍한 혼종처럼 보였기 때문이다. 그런데 성공했다.

Jira를 버리지 못하는 이유는 하나 더 있다. 공신력이다.

국내에서 보안 감사를 받을 때 Jira 기록은 증빙 자료나 근거로 인정될 수 있다. 널리 쓰이는 도구라서 대표성이 있기 때문이다. 그래서 많은 전산 담당자가 안전한 선택으로 Jira를 고른다. Jira가 없으면 승인과 확인을 위한 서류 작업을 따로 하거나, 기관이 인증한 별도 시스템을 도입해야 하는데 그런 시스템은 대개 사용성이 매우 떨어진다.

즉 이 시장에서 도구를 바꾸는 일은 기능 경쟁이 아니라 권한과 증빙 구조와의 싸움이다. 0.1에서 말한 권한 이야기가 여기서 다시 등장한다.

경쟁자는 많다. Jira 외에 Asana, ClickUp, Monday 같은 도구가 소비자 시장에 나왔고, Basecamp 같은 전통적인 플레이어도 있다.

Jira의 UX 평판, PM 교체 시 툴 교체, 국내 보안 감사에서의 증빙 활용은 필자의 현장 경험이며 출처가 있는 통계는 아니다.

시장 위치를 숫자로 보면, 2026년 2~4월 신규 고객은 Linear 1,351개사, Jira 1,524개사다. 전년 동기 대비 Linear는 +67%, Jira는 −32%다. 2025년 같은 기간에는 Jira 2,225 : Linear 807이었다. 2년 만에 역전 직전까지 왔다 [B — bloomberry, 2026-05]

0.3 Linear가 들고 들어온 것#

이 시장에는 도전자가 많지만, Linear가 자리를 잡은 것은 소프트웨어의 성능 덕분이다.

개인적으로는 풍부한 단축키 시스템이 가장 큰 선택 이유였다. 높은 성능을 바탕으로 단축키만으로 빠르게 탐색하고 반복 작업을 할 수 있다. 여기에 최근 AI 에이전트 통합이 더해지면서 인기가 더 높아지고 있다.

그렇다면 성능이 높은 이유는 무엇일까. 클라이언트 중심 설계다.

서버/클라이언트 모델에서 서버 중심 설계는 필연적으로 서버-클라이언트 통신이 가장 큰 병목이 된다. 반면 클라이언트 중심 설계는 데이터 동기화를 신경 써야 한다는 약점이 있다.

이 글은 이 한 줄을 끝까지 파고든다. 통신이 왜 그토록 비싼가(1부), 그리고 동기화라는 약점을 Linear는 어떻게 처리했나(2부).

0.4 Figma 이후 — 웹 애플리케이션의 다음 단계#

필자는 Figma 이후 웹 애플리케이션 시장이 큰 전환점을 맞았다고 본다.

Figma는 Photoshop이나 Illustrator 수준의 품질을 웹 애플리케이션으로 구현할 수 있음을 증명했다. 게다가 공동 작업을 실시간에 가깝게 처리하면서 시장을 뒤흔들었고, Adobe가 200억 달러에 인수하려 할 정도였다. 그리고 그 내부 기술이 공개되면서 웹 애플리케이션도 고성능을 낼 수 있는 시대가 되었다.

Linear도 그 영향을 받았다고 본다. 이는 짐작만이 아니다. Figma가 2019년에 공개한 글과 Linear를 나란히 놓으면 설계가 항목별로 겹친다.

  • 모든 편집이 단일 서버를 통과하므로 CRDT의 복잡도는 대부분 불필요하다 → Linear: 중앙 서버가 전체 순서를 결정한다 (2.3)
  • 충돌은 같은 객체의 같은 속성에서만 생기고, 마지막 값이 이긴다 → Linear: 나중에 쓴 쪽이 이기며, 수정 작업에만 적용된다 (3.2)
  • 동기화는 WebSocket → Linear: wss://sync.linear.app, 직접 확인 (2.4)
  • 순서는 fractional indexing → Linear: sortOrder가 float (부록)
  • 문서는 트리 + 속성 맵 → Linear: 모델 + 프로퍼티 + 참조 (2.2)

“영향을 받았다"는 말은 느낌이 아니라 계보다.

Evan Wallace(Figma 공동창업자·전 CTO), How Figma’s multiplayer technology works, 2019-10 — https://www.figma.com/blog/how-figmas-multiplayer-technology-works/ [A]. OT는 과하다고 판단해 기각했고, CRDT에서 영감을 받았지만 단일 서버를 전제로 단순화했다고 명시한다. 속성 단위 last-writer-wins 충돌 해결, WebSocket 동기화, fractional indexing 순서 모두 본문에 있다.

Adobe는 2022년 9월 Figma를 200억 달러에 인수하겠다고 발표했고, EU·영국 규제 장벽에 막혀 2023년 12월 18일 철회했다. 위약금은 10억 달러였다 [B — Bloomberg, Engadget 등 다수]

0.5 Linear가 가장 먼저 만든 것은 이슈 트래커가 아니었다#

Linear의 첫 번째 이슈, 티켓 번호로 LIN-1은 커맨드 메뉴였다. Cmd+K를 누르면 뜨는 바로 그것이다.

이슈를 만들고 상태를 바꾸는 기능, 즉 이 제품이 존재하는 이유인 기능보다 먼저 만들었다. 제품이 무엇을 하느냐보다 제품을 어떻게 조작하느냐를 먼저 정한 것이다.

이 글에서 살펴볼 모든 것, 즉 데이터를 어디에 두는지, 서버와 어떻게 통신하는지, 심지어 2026년에 데이터베이스 읽기 경로를 갈아엎은 일까지 전부 이 결정에서 나온다.

LIN-1이 커맨드 메뉴였고 핵심 이슈 추적 기능보다 먼저 구현되었다는 것은 Tuomas Artman(Linear 공동창업자·CTO) 본인의 발언이다 — localfirst.fm #15 (2024-10-01) [A]

0.6 논지#

이 글의 주제는 “Linear는 왜 빠른가"가 아니다. 빠른 것은 결과다.

주제는 UX다. UX(User Experience, 사용자 경험)라고 하면 보통 화면 배치나 문구, 클릭 수 줄이기를 떠올리지만, 여기서는 이 단어를 조금 다르게 쓴다.

Linear는 UX를 “개선"한 것이 아니라 “제약조건"으로 걸었다. 그 결과 아키텍처가 강제로 결정되었고, 그 결정의 영향은 디자인팀이 아니라 인프라와 릴리스 담당 부서가 받았다.

요즘 웹 애플리케이션의 고민은 이런 것들이다.

  • 서버가 아직 승인하지 않은 쓰기를 어디에 두는가
  • 앱이 죽었다 깨어나면 무엇을 잃는가
  • 패킷을 놓쳤다는 것을 어떻게 아는가
  • 권한이 회수되면 이미 내려받은 데이터를 어떻게 지우는가

이는 원래 펌웨어 엔지니어의 문제 목록이었다. 부트 시퀀스, 시퀀스 번호를 이용한 유실 검출, 비휘발성 저널, 워치독. 40년 된 문제들이다.

웹 엔지니어링이 그만큼 깊어진 것이고, Linear는 이 목록을 가장 멀리까지 밀고 간 사례다.

글의 순서는 다음과 같다.

  1. 사람 쪽 — 왜 즉각적이어야 하는가 (키보드 이야기)
  2. 기계 쪽 — 그래서 아키텍처가 어떻게 되었는가 (내부 구조)
  3. 대가 — 무엇을 포기했고, 무엇을 판단해야 하는가

1부. 사람 쪽 — 왜 즉각적이어야 하나#

1.1 손이 키보드를 떠나지 않는다#

Linear의 기본 조작은 몇 개의 단축키로 끝난다.

  • Cmd+/: 단축키 목록. 모두 외워야 할 것처럼 보이지만 실제로 쓰는 것은 대여섯 개다
  • Cmd+K: 커맨드 팔레트. 이슈 생성, 상태 변경, 이동을 모두 여기서 한다. LIN-1이 바로 이것이다
  • C: 이슈 생성. 제목을 입력하고 Cmd+Enter
  • J / K: 목록에서 위아래 이동. vim 사용자에게 익숙한 키다
  • A → 담당자, S → 상태, P → 우선순위: 각각 누르고 검색어 몇 글자를 입력한 뒤 엔터
  • G 다음 I: 내 이슈로 이동

이렇게 쓰면 손이 한 번도 마우스로 가지 않고, 키를 눌렀을 때 로딩 스피너도 한 번도 뜨지 않는다. 이 두 가지가 이 글의 전부다.

여기에 한 가지 사실을 더하면 이야기가 흥미로워진다. 필자가 쓰는 워크스페이스의 서버는 미국 중부에 있다. 브라우저 IndexedDB의 _meta에서 cellName: c3-us-central1을 확인할 수 있다. 서울에서 그곳까지 왕복은 대략 160~180밀리초다. J를 열 번 누를 때마다 서버를 다녀왔다면 2초가 걸렸어야 한다. 그런데 기다린 적이 없다. 왜 기다리지 않았는지가 이 글의 이야기다.

1.2 키보드가 마우스를 이기는 자리#

“빠른 것 같다"로는 설득이 되지 않으니 숫자로 만들어 보자.

HCI(Human-Computer Interaction, 인간-컴퓨터 상호작용)는 사람이 기계를 어떻게 조작하는지 연구하는 분야다. 여기에 KLM(Keystroke-Level Model, 키 입력 수준 모델)이 있다. 1980년에 나왔지만 지금도 쓰인다. 작업을 동작 단위로 쪼개고 각 동작에 실측 평균 시간을 매겨 총 시간을 예측한다.

기호는 모두 영어 단어의 첫 글자다.

  • K(Keystroke): 키 하나 누르기. 마우스 클릭도 여기에 포함한다. 0.2초
  • P(Pointing): 마우스를 타깃까지 가져가기. 클릭은 포함하지 않는다. 1.1초
  • H(Homing): 손을 키보드와 마우스 사이로 옮기고 자리 잡기. 0.4초
  • M(Mental preparation): “어디 있더라” 하고 생각하기. 1.35초

이것으로 계산하면 다음과 같다.

마우스로 메뉴 항목 하나 실행하기
  손을 마우스로            0.4
  어느 메뉴더라            1.35
  메뉴까지 이동            1.1
  클릭                     0.2
  항목까지 이동            1.1
  클릭                     0.2
  손을 키보드로 복귀       0.4
                        ────────
                          4.75초

외운 단축키 두 개 (G 다음 I)
  0.2 + 0.2 = 0.4초

대략 10배다.

물론 단축키 쪽을 후하게 잡은 계산이다. 단축키 쪽에도 생각하는 시간 1.35초를 넣어 보수적으로 계산하면 1.75초 대 4.75초, 2.7배다.

어느 쪽이든 자릿수가 다르다. 정확한 배수를 못 박을 수는 없다. KLM은 모집단 평균이고 개인차가 100%까지 난다고 모델 스스로 밝히고 있기 때문이다. 하지만 어떤 값을 넣어도 부호는 바뀌지 않는다.

Card, Moran & Newell, The Keystroke-Level Model for User Performance Time with Interactive Systems (1980). K=0.2s(숙련 55 wpm — words per minute, 분당 타자 수. 90 wpm이면 0.12s, 40 wpm이면 0.28s), P=1.1s(전형 0.8~1.5s, 클릭 불포함), H=0.4s, M=1.35s. 정리본: David Kieras, Using the Keystroke-Level Model to Estimate Execution Times (UMD 교재)

개인차 100%도 모델 문헌에 명시된 한계다.

그런데 — 정직하게#

한 가지는 인정해야 한다. 그렇지 않으면 단축키 예찬으로 끝나 버린다.

키보드가 항상 빠른 것은 아니다. 눈에 보이는 것을 집는 일은 마우스가 이긴다. Photoshop 편집을 키보드만으로 할 수는 없다.

그러나 Photoshop조차 마우스만 쓰는 것보다 단축키를 섞어 쓰는 편이 훨씬 능률적이다. 브러시 크기, 도구 전환, 실행 취소, 레이어 이동은 모두 단축키로 한다. 마우스만 쓰는 사람과는 비교가 되지 않는다.

그리고 보통의 사무 업무는 애초에 대부분 키보드 중심이다. 문서와 메일을 쓰고, 표를 채우고, 검색하고, 티켓 상태를 바꾼다.

따라서 질문은 “키보드냐 마우스냐"가 아니라 “이 일이 어느 쪽 일이냐"다.

이슈 트래커에서 하는 일은 모두 명령을 실행하는 쪽이다. 이슈 열기, 상태 바꾸기, 담당자 지정, 다음 이슈로 이동, 프로젝트 전환, 검색. 어느 것도 공간에서 무언가를 집는 일이 아니다.

Figma는 마우스 중심이고 Linear는 키보드 중심이지만, 둘 다 옳다. 하는 일의 성격이 다르기 때문이다. Figma는 공간을 조작하고, Linear는 명령을 실행한다.

1.3 진짜 비용은 시간이 아니라 흐름이다#

Jakob Nielsen이 정리한 응답 시간의 세 가지 한계가 있다.

  • 0.1초: 시스템이 즉각 반응한다고 느끼는 한계
  • 1초: 사용자의 사고 흐름이 끊기지 않는 한계
  • 10초: 주의가 유지되는 한계

중요한 것은 두 번째, 1초다.

마우스로 무언가를 집으려면 두 가지가 필요하다. 어디 있는지 찾는 것(시각 탐색)과 거기로 포인터를 가져가는 것(운동 조준)이다. 그리고 이 둘은 머릿속에서 문장을 만들 때 쓰는 자원과 같은 자원을 쓴다.

글을 쓰는 사람은 이것을 몸으로 안다.

문장 중간에 손이 마우스로 가면, 그 문장이 날아간다.

원고를 쓰다가 서식을 바꾸려고 마우스를 잡았다가 무슨 말을 하려 했는지 잊어버린 경험이 있을 것이다. 개발자가 터미널에서 마우스를 쓰지 않는 이유도 같다. 명령어를 입력하는 도중에 손이 마우스로 가면 머릿속에 있던 파이프라인이 사라진다.

1.5초를 아끼는 것이 중요한 것이 아니라, 그 1.5초 동안 머릿속에 있던 것이 사라지지 않는 것이 중요하다.

Jakob Nielsen, Response Times: The 3 Important Limits (0.1초 / 1초 / 10초). 원 출처는 Miller(1968), Card et al.(1991)

참고로 60Hz 화면의 한 프레임은 16.7밀리초다. 이를 넘기면 끊긴 것으로 보인다.

1.4 응답은 즉각적이어야 하지만, 물리적으로 어렵다#

그렇다면 빠르게 만들면 되지 않을까. 여기가 이 글에서 가장 중요한 부분이다. 그렇게 되지 않는다. 그 이유를 살펴보자.

계층 구조#

컴퓨터가 데이터를 가져오는 데 걸리는 시간을 계층별로 늘어놓으면 다음과 같다.

  • L1 캐시: 약 1나노초
  • 메모리(DRAM): 약 100나노초
  • NVMe SSD: 수십 마이크로초
  • 하드디스크: 5~10밀리초
  • 같은 도시 서버 왕복: 5~15밀리초
  • 서울 ↔ 도쿄 왕복: 30~35밀리초
  • 서울 ↔ 미국 서부 왕복: 130~160밀리초
  • 서울 ↔ 유럽 왕복: 250~290밀리초

숫자가 너무 작아 감이 오지 않으니, L1 캐시의 1나노초를 1초로 늘려 환산해 보자.

  • L1 캐시: 1초
  • 메모리: 1분 40초
  • SSD: 14시간
  • 하드디스크: 3개월
  • 서울 ↔ 도쿄: 1년 1개월
  • 서울 ↔ 미국 서부: 4년 9개월

메모리에서 1초면 끝나는 일이 태평양을 건너면 5년이 걸린다.

그리고 이것은 앞으로도 나아지지 않는다#

핵심은 여기에 있다.

  • 대역폭은 지난 20년 동안 수백~수천 배 늘었다. ADSL에서 기가 인터넷으로
  • 지연 시간은 거의 줄지 않았다. 하한이 빛의 속도이기 때문이다

광섬유 안에서 빛은 진공보다 느리게, 초속 약 20만 킬로미터로 이동한다. 그리고 해저케이블은 직선이 아니어서 실제 거리의 1.3~1.5배쯤 된다.

  • 서울 ↔ 도쿄: 케이블 거리 약 1,600km, 물리적 하한 16밀리초, 실측 30~35밀리초
  • 서울 ↔ 미국 서부: 케이블 거리 약 12,000km, 물리적 하한 120밀리초, 실측 130~160밀리초

실측값이 이미 물리적 하한에 거의 붙어 있다. 라우터를 개선하고 서버를 최적화해서 짜낼 여지가 별로 없다. 남은 것은 물리다.

대역폭 문제는 돈으로 풀린다. 지연 문제는 왕복을 없애는 것 말고는 방법이 없다.

그래서 100밀리초와 부딪힌다#

Nielsen의 첫 번째 한계는 100밀리초, 즉각 반응한다고 느끼는 한계였다.

서울에서 미국으로 보내면 150밀리초다. 서버가 일을 시작하기도 전에 지연 예산을 넘긴다.

백엔드를 아무리 최적화해도 소용없다. p99(요청 100개 중 가장 느린 1개까지 포함한 응답 시간)를 1밀리초로 만들어도 사용자가 느끼는 것은 151밀리초다.

그리고 화면 하나를 그리는 데 의존적인 요청이 5번 직렬로 걸리면, 국내 서버(15밀리초)로도 75밀리초이고 미국 서부라면 750밀리초다.

1.1에서 J와 K를 눌렀을 때 스피너가 뜨지 않은 것은 서버를 다녀오지 않았기 때문이다. 서버를 다녀왔다면 아무리 빨라도 그런 느낌은 나지 않는다.

CDN이나 엣지로 풀면 되지 않느냐는 반문이 있을 수 있다. 읽기는 그렇게 풀린다. 그러나 쓰기는 결국 원본으로 가야 하고, 이슈 트래커는 쓰기가 많다. 게다가 엣지에 올려도 “내 워크스페이스의 이 이슈"는 캐시할 수 없다. 그래서 Linear는 데이터를 엣지가 아니라 사용자 기기로 내렸다.

이 절의 수치는 Linear 자료가 아니라 일반 공학 상수다. 제조사·세대·경로에 따라 달라지므로 자릿수로 읽어야 한다. 목적은 정밀도가 아니라 계층 간 격차의 크기를 보여 주는 것이다.

광섬유 내 광속: 굴절률 약 1.47 → 약 20만 km/s


2부. 기계 쪽 — 그래서 클라이언트가 이렇게 되었다#

먼저 출처를 밝혀 둔다. 사연이 조금 특이하다.

Linear는 클로즈드 소스다. 그런데 wzhudev라는 개발자가 난독화된 프론트엔드 코드를 통째로 역공학해 문서를 작성했고, Linear CTO가 그 문서를 보고 “내부 문서보다 정확하고 완전하다"고 공개적으로 인정했다.

남의 회사 제품 내부를 작성자가 인증한 수준으로 들여다볼 수 있게 된 셈이다. 흔치 않은 경우다.

wzhudev, Reverse Engineering Linear’s Sync Engine — https://github.com/wzhudev/reverse-linear-sync-engine

Tuomas Artman의 평가는 저장소 README 상단에 링크와 함께 인용되어 있다 (GitHub Discussions, X, Hacker News) [A]

2.1 상태를 세 겹으로 쪼갠다#

대부분의 글은 Linear를 이렇게 설명한다. “데이터를 브라우저의 IndexedDB에 캐시해 둔다.”

틀린 말은 아니지만 가장 중요한 것이 빠졌다. 실제 구조는 다음과 같다.

① 메모리 (Object Pool)          ← UI가 보는 것은 오직 여기
   낙관적. 서버 승인 전 값이 들어 있음. 휘발성.

② IndexedDB (모델 테이블)        ← 서버가 승인한 것만
   서버 데이터의 순수한 부분집합. 미승인 값은 절대 들어가지 않음.

②' IndexedDB _transactions     ← 미커밋 쓰기의 유일한 영속 저장소
   트랜잭션 로그. 재시작하면 여기서 다시 재생됨.

③ 서버 (Postgres)               ← 진실의 원천. 순서를 정하는 유일한 권위.

대부분의 사람이 반대로 알고 있는 것이 하나 있다.

클라이언트가 무언가를 쓸 때, 로컬 데이터베이스의 모델 테이블은 건드리지 않는다.

메모리만 바꾼다. IndexedDB의 실제 테이블은 서버가 승인하고 그 결과가 다시 내려온 다음에야 기록된다.

이렇게 하는 이유는 로컬 DB가 서버 DB의 부분집합이라는 규칙을 지키기 위해서다. 서버가 승인하지 않은 값을 미리 써 두면, 서버가 거부했을 때 되돌리기가 어렵고 오류가 나기 쉽다.

그래서 롤백이 공짜가 된다. 서버가 거부하면 메모리만 되돌리면 되고, 디스크는 건드릴 일이 없다.

이 구조는 Postgres와 똑같이 생겼다.

  • ① Object Pool ↔ 버퍼 풀(shared_buffers)
  • ②’ _transactions ↔ WAL(Write-Ahead Log)
  • ② 모델 테이블 ↔ 힙 / 데이터 파일
  • ③ 서버 ↔ 체크포인트를 원격이 승인한다는 점만 다르다

순서까지 같다.

Postgres:  shared_buffers 수정  →  WAL 기록·커밋 시 fsync  →  힙 파일 반영(체크포인트)
LSE:       Object Pool 수정     →  _transactions 기록      →  모델 테이블 반영(서버 승인 후)
           휘발성, 제일 먼저        여기가 내구성 경계          나중

여기서 “메모리가 먼저 바뀌니 메모리가 선행 기록 아닌가"라는 반문이 나올 수 있다.

Write-Ahead의 ahead는 “모든 것보다 먼저"가 아니라 “데이터 파일보다 먼저, 그리고 디스크에"라는 뜻이다. WAL이 존재하는 이유는 죽었다 깨어났을 때 복구하기 위해서이고, 그러려면 살아남아야 한다. 메모리는 살아남지 못한다.

그리고 Postgres도 똑같이 메모리를 먼저 바꾼다. 더티 페이지는 WAL fsync보다 먼저 shared_buffers에서 바뀐다. 메모리가 먼저 바뀐다고 해서 메모리가 WAL이 되지는 않는다.

결정적인 근거는 재시작 동작이다. LSE는 다시 켜질 때 _transactions를 읽어 메모리 모델에 재생한다. 이것이 정확히 WAL redo, 즉 로그를 읽어 버퍼를 복원하는 동작이다.

위 구조와 “클라이언트 쓰기가 로컬 DB를 수정하지 않는 이유"는 역공학 문서 3장에 명시되어 있다 [A]

메모리 계층은 MobX로 관찰 가능하게 만들어져 있어서, 프로퍼티 하나가 바뀌면 그 프로퍼티를 구독한 컴포넌트만 다시 그린다.

그렇다고 모든 데이터를 메모리에 올리지는 않는다. 다음 절이 그 이야기다.

2.2 처음 열 때 무슨 일이 일어나나#

Linear를 처음 열면 다음 순서로 “부팅"한다.

1. 모델 메타데이터 등록          140개 모델
2. IndexedDB 연결                스키마 해시 비교 → 필요하면 마이그레이션
3. 어떤 부팅을 할지 판정          전체 / 부분 / 로컬
4. 서버에서 데이터 받기           한 번의 요청으로 스트리밍
5. IndexedDB에 기록
6. 메모리로 올리기               "즉시" 등급 모델만
7. 미전송 트랜잭션 재생
8. WebSocket 연결                놓친 것이 있으면 따라잡기

140개 모델의 정체#

필자가 직접 뽑아 분류한 결과다.

  • 조직·권한·계정 18개: Organization, Team, User, ApiKey, IdentityProvider
  • 프로젝트·이니셔티브 17개: Project, ProjectMilestone, Initiative, ProjectUpdate
  • AI·에이전트 16개: AgentSession, AiConversation, AiPromptMemory, McpServerDefinition
  • 이슈 코어 15개: Issue, Comment, IssueHistory, WorkflowState, Cycle
  • 코드·PR 15개: PullRequest, PullRequestFile, CodeRepository, Diff
  • 고객지원·Asks 11개: Customer, CustomerNeed, AsksWebPage
  • 나머지 48개: 릴리스 6, 문서 5, 뷰·UI 7, 알림 4, 과금 7, 자동화 5, 통합 7, 기타 7

여기서 두 가지를 짚을 수 있다.

첫째, 이 목록은 제품의 확장 기록이다. 2024년 역공학 시점에는 80개였다. 늘어난 60개 가운데 코드·PR 15개, AI·에이전트 16개, 릴리스 6개, 고객지원 11개, 합쳐서 48개가 Linear가 2년 동안 밀어붙인 영역이다. 모델 개수가 곧 제품 범위다.

둘째, 이름이 History로 끝나는 모델이 7개 있다. IssueHistory, ProjectHistory, PullRequestHistory, ReleaseHistory 등이다. 이력이 1급 엔티티다. 쓰기 한 번이 sync action 여러 건으로 증폭되는 이유가 여기에 있으며, 2.3에서 다시 나온다.

Draft, IssueDraft, ProjectDraft도 있다. 초안도 동기화 대상이어서, 쓰다 만 이슈가 기기를 옮겨도 살아 있다.

직접 관측 [A, 2026-09-11]. 로그인 세션의 IndexedDB _meta 테이블에서 모델 이름 140개를 전수 추출해 분류했다.

_meta의 persisted 플래그로 보면 73개만 true다. 전량 적재가 끝난 모델은 절반 정도이고, 나머지는 필요할 때 받는다(아래 적재 전략 참고).

Diff·PullRequestFile·CodeRepositoryFile이 있다는 것은 코드 메타데이터까지 클라이언트로 내린다는 뜻이다. 3.2의 “데이터 규모 상한"과 정면으로 부딪히는 확장이다.

모델 하나의 생김새 — Issue#

이것도 직접 뽑아 보았다. 실제 값은 보지 않고 필드 이름과 타입만 확인했다.

메모리에 올라간 Issue 객체 ─ 필드 24개

  id  number  title  priority                          ← 기본
  sortOrder  boardOrder  prioritySortOrder             ← 정렬 필드가 셋, 전부 float
  teamId  projectId  stateId  creatorId  assigneeId    ← 참조는 전부 uuid 하나
  labelIds  subscriberIds                              ← 여럿이면 uuid 배열
  timeInStatus { 상태별 누적 시간 }                    ← 서버가 계산해 넣어 줌
  createdAt  updatedAt                                 ← ISO 문자열

IndexedDB의 Issue 테이블 ─ 주 키는 id, 보조 인덱스 20개
  assigneeId / teamId / projectId / cycleId / stateId / parentId / number / ...
  labelIds, subscriberIds, previousIdentifiers 는 multiEntry (배열 원소마다 항목 하나)

여기서 세 가지가 보인다.

첫째, 완전히 평면 구조다. 담당자 정보가 이슈 안에 들어 있지 않다. assigneeId라는 uuid 하나만 있고, 실제 User는 메모리의 UUID 맵에서 찾아 붙인다. 2.1의 Object Pool이 바로 그 맵이다. 그래서 서버가 이슈 하나만 보내도 참조가 깨지지 않는다.

둘째, 인덱스 20개는 메모리가 아니라 IndexedDB 쪽에 있다. assigneeId, teamId, projectId, cycleId, stateId, parentId, number 등 이슈를 20가지 방향에서 찾을 수 있도록 브라우저 안의 데이터베이스가 인덱스를 들고 있다. 서버 DB 설계를 그대로 복제한 것이다. 2년 전 조사에서는 9개였으니 두 배가 되었다.

일반적인 DB 보조 인덱스와 같다. 키로 정렬된 구조라 범위 질의와 커서 순회가 가능하다. 배열 필드 셋(labelIds 등)은 multiEntry여서 원소마다 항목이 하나씩 생기고, 덕분에 관계 테이블 없이 다대다 관계가 풀린다.

반면 메모리 쪽은 평평하다. Object Pool은 Map<uuid, 객체> 하나뿐이고 보조 인덱스가 없다. “나에게 할당된 이슈"를 찾을 방법이 메모리에는 없으므로 IndexedDB 인덱스에 물어본 다음 결과를 메모리에 올린다. 정렬은 그 뒤에 JS로 한다. sortOrder에는 아예 인덱스가 없다.

메모리에 인덱스를 두면 낙관적 업데이트를 되돌릴 때 인덱스도 되돌려야 한다. 2.1에서 롤백이 공짜라고 한 이유가 이것이다. 되돌릴 것이 객체 필드 하나뿐이다.

셋째, 정렬 필드 셋이 모두 소수점 숫자다. 리스트용, 보드용, 우선순위 뷰용이 따로 있다. 드래그로 순서를 바꿀 때 앞뒤 값의 중간값만 넣으면 다른 행은 건드리지 않는다. 정수였다면 뒤의 행을 전부 다시 써야 하고, 로컬 퍼스트에서 그것은 재앙이다. 드래그 한 번이 sync action 수천 건이 되기 때문이다.

본문은 이 안에 없다. DocumentContent라는 별도 모델이며, 인덱스 12개로 이슈·프로젝트·문서·PR·회의에 모두 붙는다. 무거운 필드를 떼어 내 필요할 때만 받으려는 것이다. 이어지는 적재 전략이 그 이야기다.

방법 [A — 2026-09-11 직접 관측]. 로그인 세션의 IndexedDB에서 스토어마다 레코드 1건을 읽어 필드 이름과 타입만 추출했다. 이슈 제목·본문 같은 실제 값은 읽지 않았다.

IndexedDB 트랜잭션이 await 한 번에 닫혀 버리는 함정 때문에 처음에는 틀린 결과가 나오기도 했다.

모두 메모리에 올리지는 않는다#

핵심은 부팅 순서의 6번이다. 받은 것을 모두 메모리에 올리지 않는다. 모델마다 적재 전략이 정해져 있다.

  • instant: 부팅할 때 올린다 (기본값)
  • lazy: 필요할 때 한꺼번에
  • partial: 필요할 때 일부만
  • explicitlyRequested: 명시적으로 요청할 때만
  • local: 로컬에만 저장하고 서버로 보내지 않는다

흥미로운 것은 마지막 local이다. 이 전략을 쓰는 모델은 하나도 없는데 코드에는 남아 있다.

역공학 문서 작성자는 서버를 건드리지 않고 새 기능을 만들기 위한 장치로 추정한다. 새 모델을 local로 시작해 프론트엔드에서만 돌려 보고, 확정되면 전략만 바꿔 동기화를 켠다는 것이다.

이것이 Artman이 Local-First Conf에서 말한 “로컬 퍼스트의 예상 못 한 이득 = 개발 속도"의 정체다. 3.4에서 다시 다룬다.

적재 전략 5단계와 local 전략을 쓰는 모델이 없다는 관측: 역공학 문서 1장 [A]. local의 용도 해석은 작성자의 추정이다(Artman의 Local-First Conf 발표를 근거로).

부트스트랩 응답은 JSON 배열이 아니라 줄 단위 스트림(NDJSON, Newline-Delimited JSON — 한 줄에 객체 하나씩)이다. 전부 받은 뒤 파싱하는 것이 아니라 도착하는 대로 처리하기 위해서다 [A — 관측 페이로드]

2.3 버전 번호 하나로 굴러간다#

Linear 동기화의 심장은 lastSyncId라는 숫자 하나다. 서버에서 트랜잭션이 성공할 때마다 1씩 증가하는 정수로, 데이터베이스 전체의 버전 번호라고 보면 된다.

그런데 이 숫자는 워크스페이스별이 아니었다#

역공학 문서 작성자가 이를 실측으로 증명했는데, 이 글에서 가장 좋아하는 장면이다.

담당자 한 명을 바꾸자 서버에서 변경 내역 세 개가 내려왔다.

2361610825   Issue         업데이트    ← 내가 바꾼 것
2361610826   IssueHistory  삽입        ← 서버가 만든 이력
2361610854   Activity      삽입        ← 서버가 만든 활동 로그
         ↑
      28이 비어 있다

826과 854 사이의 28건은 그 찰나에 지구 반대편 다른 회사의 워크스페이스에서 일어난 변경이다. 스크린샷 한 장으로 이 카운터를 전 세계가 공유한다는 사실이 증명된다.

관측된 값의 추이도 흥미롭다.

  • 2022~23년경: 6억 1,395만
  • 2024년 7월: 23억 2,671만
  • 2025년 1월: 35억 2,837만

6개월에 약 12억, 하루 650만~700만 건씩 증가했다. 그리고 2024년 7월에 이미 32비트 정수 한계(21억)를 넘었다.

이 숫자는 사이드 채널이기도 하다. Linear에 로그인한 누구나 회사 전체의 누적 쓰기 횟수와 증가율을 읽을 수 있다. 워크로드 규모가 API(Application Programming Interface) 응답에 그대로 노출되는 셈이다.

그리고 지금은 더 이상 전역 카운터가 아니다. 2026-09-11에 로그인 세션에서 직접 확인한 lastSyncId는 3,090,626,647로, 2025년 1월 관측값(35억)보다 작다. 전역 단조 카운터는 뒤로 갈 수 없으니 쪼개졌다고 봐야 한다. 같은 _meta에 cellName: "c3-us-central1"이 있어 셀 단위로 나뉜 것으로 보이며, 2026년 8월 공식 블로그가 로그를 “워크스페이스별 append-only"라고 표현한 것과도 맞아떨어진다. 즉 Linear는 단일 시퀀스의 한계를 실제로 만났고, 그래서 쪼갰다. “전역 카운터는 결국 병목이 된다"는 주장에 살아 있는 증거가 붙는다.

이 숫자로 유실을 검출한다#

클라이언트는 재접속할 때 서버가 알려 준 lastSyncId와 자기 값을 비교한다. 자기 값이 작으면 그 사이를 놓친 것이다. 그러면 /sync/delta?lastSyncId=..&toSyncId=..로 그 구간만 다시 요청한다.

TCP 시퀀스 번호와 같은 원리다. 순번으로 구멍을 찾고 그 구간만 재전송을 요청한다.

남의 데이터는 어떻게 막나#

여러 조직이 한 시퀀스를 공유한다면 권한 격리는 어떻게 할까. sync group이다. 내 사용자 ID, 소속 팀 ID, 역할 ID의 목록이며, 서버는 이것으로 누구에게 어떤 변경을 보낼지 라우팅한다.

여기서 정말 어려운 문제가 나온다. 권한이 바뀌면 어떻게 되는가.

  • 팀에 추가되면: 변경 내역을 처리하던 중에 그 자리에서 추가 요청을 보내 그 팀의 데이터를 받아 온다. 응답이 올 때까지 나머지 처리는 멈춘다
  • 팀에서 제거되면: 그 그룹에 속한 로컬 데이터를 지운다

권한 회수가 곧 로컬 데이터 삭제 명령이다. 로컬 퍼스트에서만 생기는 문제다. 서버 중심 구조라면 조회를 거부하기만 하면 된다.

lastSyncId의 성격, 갭 28 관측, 값 추이: 역공학 문서 2·4장 [A — 관측 페이로드]

현재 값이 2025년보다 작다는 관측: 직접 관측 + 공식 블로그 [A]

변경 유형은 8가지다: 삽입/수정/삭제/보관/보관해제/커버링/그룹변경 2종

2.4 오프라인에서 쓴 것은 어떻게 되나#

다음 두 줄 뒤에서 벌어지는 일이다.

issue.assignee = user;
issue.save();

큐가 네 개 있다. 생성됨 → 대기 → 전송 중 → (재시작 시 복원됨).

생성에서 대기로 넘어갈 때 마이크로태스크로 묶인다. 결과적으로 같은 이벤트 루프 틱에서 만들어진 변경들이 한 배치가 된다.

대기에서 전송으로 넘어갈 때는 백프레셔가 세 겹으로 걸린다.

  1. 개수 제한: 대기가 너무 많으면 아예 보내지 않는다 (서버 보호)
  2. 독립성: 같은 배치이면서 서로 의존하지 않는 것만 묶는다
  3. 크기 제한: 합쳐진 요청 문자열이 임계값을 넘으면 거기서 끊는다

그리고 여러 변경을 o1:, o2: 같은 별칭을 붙여 하나의 요청으로 병합한다. 1부에서 본 왕복 횟수를 줄이기 위해서다.

응답으로 받는 것은 lastSyncId 하나뿐이다. 바뀐 객체를 돌려받지 않고, 실제 데이터는 WebSocket으로 따로 온다. 쓰기 경로와 읽기 경로를 완전히 분리한 것이다. 덕분에 “응답과 푸시가 동시에 도착하면 어느 쪽이 최신인가"라는 고질적인 경쟁 상태가 구조적으로 사라진다.

WebSocket은 직접 확인했다 [A — 2026-09-11 직접 관측]. 로그인된 브라우저에서 새로고침 직후 WebSocket 생성자에 스파이를 걸어 실제 연결을 잡았다: wss://sync.linear.app/?userAccountId=<UUID>&userId=<UUID>

소켓은 하나뿐이고, 페이지 로드 후 약 1.4초에 생성된다. 부트스트랩이 먼저 끝나고 그다음에 연결된다.

URL에 토큰이 없다. 쿠키 인증이다.

번들에 socket.io·sockjs·pusher·ably 문자열이 하나도 없다. 래퍼 없이 네이티브 WebSocket을 쓴다.

같은 번들에 SyncMessage 채널명, /sync/bootstrap, /sync/delta 엔드포인트가 모두 들어 있다.

그리고 정직한 결함 하나#

대기 큐에 들어간 변경은 _transactions 테이블에 저장된다. 앱이 죽어도 재시작하면 다시 보낸다.

그런데 역공학 문서 작성자가 이 결함을 짚는다.

서버에 보냈는데 응답을 받기 전에 창이 닫히면, 그 변경은 로그에 남아 있다. 재시작하면 다시 보낸다. 일부 작업은 멱등하지 않아서 “존재하지 않는 것을 삭제할 수 없습니다” 같은 오류가 난다.

at-least-once 전달인데 중복 제거가 없는 상태다. 해법은 알려져 있다. 요청마다 일련번호를 달아 서버에서 중복을 거르면 된다. Linear는 그렇게 하지 않는다. 빈도가 낮고 사용자 경험에 큰 영향이 없다는 판단이다.

이 결함을 굳이 언급하는 이유가 있다.

로컬 퍼스트는 “데이터를 잃지 않는다"가 아니라 “어디까지 잃어도 되는지를 명시적으로 고르는 것"이다.

4개 큐, 3중 백프레셔, mutation 병합, 재전송 중복 실행 문제: 역공학 문서 3장 [A]. 중복 실행 지적은 작성자 본인의 것이다.

여기까지 본 것은 모두 클라이언트 안쪽 이야기다. 메모리를 어떻게 쪼갰고, 무엇을 언제 받아 오고, 유실을 어떻게 알아채고, 오프라인 쓰기를 어디에 쌓아 두는지. 이제 청구서를 볼 차례다.


3부. 청구서 — 이 UX의 값을 누가 냈나#

2부까지는 사용자가 보는 것이었다. 3부는 사용자가 전혀 모르는 것들이다.

3.1 2026년, 서버가 읽기 경로를 갈아엎었다#

첫 번째 청구서는 인프라 운영으로 갔다.

가장 최신이면서 가장 덜 알려진 자료다. 2026년 8월 18일 Linear 공식 엔지니어링 블로그에 실린 글이다.

문제#

클라이언트가 재접속하면 밀린 변경을 따라잡아야 한다. 그런데 규모가 이렇다.

  • 한 워크스페이스가 하루에 백만 건 가까운 변경을 만든다
  • 돌아온 클라이언트가 수십만 건 뒤처져 있을 수 있다
  • 누적 히스토리는 20테라바이트 이상이다

기존 Postgres 쿼리는 이런 모양이었다.

변경 ID 범위 (내 체크포인트 ~ 현재)
  ∩ 내가 접근 가능한 그룹 집합
  ∩ 내 클라이언트의 구독 집합
  ∩ 기타 접근 제어

점점 넓어지는 ID 범위와 배열 교집합 조건이다.

왜 안 되었나#

  1. 꼬리 지연이 요동친다. 중앙값은 멀쩡한데 p95/p99가 무너진다
  2. 읽기 레플리카가 통하지 않는다. 레플리카를 늘려도 요청당 교집합 계산량은 그대로다. 이것이 핵심이다
  3. 운영 마찰. DB 유지보수나 레플리카 지연이 곧바로 사용자 지연이 된다
  4. 필터를 하나 더할 때마다 비용이 급격히 늘어난다

Postgres에도 GIN(Generalized Inverted Index, 범용 역인덱스)이 있다. 검토했지만 이 규모에서는 쓰기 비용이 맞지 않아 기각했다.

쓰기 비용이 문제가 되는 이유는, 인덱스가 결국 읽을 때 하던 계산을 쓸 때로 옮기는 장치이기 때문이다. 목록을 요청받고 만드는 것이 아니라 변경이 기록될 때마다 미리 쌓아 둔다. GIN도 같은 일을 하는데, 하루 수백만 건 규모에서는 옮겨진 쪽의 비용을 감당할 수 없었다.

해법 — 먼저 역인덱스란#

책 뒤의 찾아보기에 비유하면 이해가 빠르다.

책에서 “로컬 퍼스트"를 찾는다고 하자. 1쪽부터 읽어 나가는 것이 Postgres 방식이다. 찾아보기를 펴서 “로컬 퍼스트 … 47, 102, 153"을 보는 것이 역인덱스다.

그 “47, 102, 153"이 포스팅 리스트다. 이름이 거창할 뿐 정렬된 번호 목록이다.

“로컬 퍼스트"와 “동기화"가 둘 다 나오는 쪽을 찾을 때는 두 목록이 정렬되어 있으니 나란히 놓고 한 번씩만 훑으면 된다. 되돌아갈 일이 없다.

Linear는 여기에 묘수를 하나 더했다. 문서 번호로 sync action ID를 쓴 것이다.

sync action ID는 시간순으로 증가한다. 그러니 목록은 저절로 시간순으로 정렬된다. “내 체크포인트 이후"라는 조건이 그냥 번호 범위 조건이 되고, 시간 인덱스가 따로 필요 없다. 그리고 변경은 불변이므로 목록 끝에만 붙는다.

무엇이 바뀌었나 — 추상화 레이어를 하나 추가했다#

“컴퓨터 과학의 모든 문제는 또 다른 추상화 레이어를 추가함으로써 해결할 수 있다. 단, 너무 많은 레이어가 생기는 문제는 빼고.” — 데이비드 휠러

오해가 잘 생기는 부분이니 정확히 짚어 두자.

Postgres를 버린 것이 아니다. 읽기 경로에서 “무엇을 보낼지 고르는 단계” 하나만 바꿨다.

[이전]  권한이 "행 안의 배열"에 들어 있었다
  id 1047 │ Issue │ {내용} │ 그룹 [A, C]      ← 행을 열어야 그룹을 안다
  질의: id 범위로 후보를 긁고 → 행마다 그룹 배열 대조 → 대부분 탈락

[이후]  권한을 "그룹 → 변경 ID 목록"으로 뒤집었다
  그룹 A → [1047, 1051, 1092, ...]            ← 그룹을 열면 ID가 나온다
  질의: 목록끼리 병합

뒤집힌 것은 권한 쪽이다. ID는 원래 순서대로였고, 그것이 그대로 문서 번호가 되었다.

역할은 이렇게 나뉜다.

  • 로그 저장·보존(20TB): Postgres, 그대로
  • 실제 내용(JSON): Postgres, 그대로
  • 맨 앞 최신 구간: Postgres, 그대로
  • “이 사람에게 보낼 ID 목록”: 새 인덱스, 여기만 신설
  • 새 인덱스가 죽으면: Postgres로 폴백

이 ID 목록은 클라이언트에게 가지 않는다. 서버 내부에서 무엇을 보낼지 고르는 재료일 뿐이고, 클라이언트가 받는 것은 여전히 변경 내용이다. 대신 인덱스를 Postgres와 계속 맞추는 별도 파이프라인이 돌며, 지연은 p50 1초다. 뒤에 나올 안전장치들은 모두 이 지연 때문에 필요하다.

새 인덱스는 ID 목록만 만들고 내용은 들고 있지 않다. 그래서 읽기가 2단계가 된다. 목록을 먼저 받고, 그 ID들의 내용만 Postgres에서 가져온다. 권한 검사에서 탈락할 행의 JSON은 아예 읽지 않기 위해서다.

실제 구조#

역인덱스(포스팅 리스트)로 옮겼다.

  • 문서 하나 = 변경 하나의 메타데이터. 문서 ID = 변경 ID 그 자체
  • 실제 페이로드는 Postgres에 그대로 둔다
  • 그룹마다 “이 그룹으로 가는 모든 변경 ID의 정렬된 목록"이 생긴다
  • 쿼리는 목록들을 합집합하고 ID 범위와 교집합한다. 행을 훑으며 배열을 대조하는 것이 아니라 집합 연산이다

읽기는 2단계다.

  1. 메타데이터만 스캔한다. 정렬된 ID와 라우팅 정보만 받는다
  2. 살아남은 것만 Postgres에서 페이로드를 조회한다

덕분에 권한 검사에서 탈락할 행의 JSON 디코딩 비용을 아예 내지 않는다.

두 가지 의문#

백엔드 개발자라면 여기서 두 가지를 물을 수 있다.

첫째, 히스토리가 20TB인데 목록이 길면 그것을 훑는 비용은 없는가.

없다. 정렬 키가 같기 때문이다. 문서 번호가 sync action ID이고 찾는 조건도 같은 ID 범위이므로, 이진 탐색으로 내 체크포인트 위치까지 건너뛰고 거기서부터 결과 크기만큼만 읽는다. 뒤에 쌓인 수억 건에는 접근조차 하지 않는다.

이것이 이 재설계의 핵심이다. 누적 히스토리의 길이가 “비용"에서 “무관"으로 바뀌었다. p95·p99가 워크스페이스 크기와 무관하게 평평해진 이유가 이것이다.

둘째, 밀린 변경이 수십만 건일 때 같은 이슈의 수정은 마지막 것만 보내면 되지 않는가.

논리적으로는 맞다. 그런데 그렇게 하지 않는다. 전부 보내고 클라이언트가 순서대로 적용한다.

접을 수 없는 변경이 섞여 있기 때문으로 보인다. IssueHistory, Activity 같은 이력 모델은 전부 삽입이고 각각이 고유한 행이라, 접으면 이력이 사라진다. 그리고 동시 수정을 처리하는 rebase(부록 참고)가 도착 순서에 의존한다. 다만 이 부분은 공식 자료에 없는 추정이다.

안전장치#

복제 지연을 신뢰하지 않는 설계다.

  • 로그 맨 앞의 좁은 구간은 Postgres가 권위 있게 서빙한다
  • 더 넓은 과거 구간은 새 인덱스가 서빙한다
  • 두 구간을 일부러 겹쳐 놓고 변경 ID로 중복을 제거한다
  • 새 인덱스가 죽거나 구간을 덮지 못하면 Postgres로 폴백한다
  • 투입 전에 섀도 모드로 모든 요청을 두 경로에 태워 반환된 ID 집합을 대조했다

결과 — 목표는 “빠르게"가 아니었다#

  • 기존: 워크스페이스가 커질수록 p95·p99가 꾸준히 악화
  • 신규: 워크스페이스가 커져도 p95·p99가 거의 평평

목표는 “빠르게"가 아니라 “예측 가능하게"였다. 따라잡기가 느려지면 클라이언트가 포기하고 전체 재다운로드로 떨어지는데, 그것이 사용자 눈에 보이는 절벽이기 때문이다.

그리고 이 글의 결론은 기억해 둘 만하다.

“변경 로그를 저장하는 것과, 변경 로그를 서빙하는 것은 다른 문제다.”

Postgres는 여전히 전자에 맞는 도구이고, 후자는 완전히 다른 자료구조를 요구한다는 것이다.

이 사례에서 가져갈 것#

이 부분을 빼면 “Linear가 특별한 DB를 샀다"는 이야기로 끝나 버린다.

대부분의 팀은 이 문제를 만나지 않는다. Linear도 5~6년 만에 만났다. 하루 백만 건, 20테라바이트는 흔한 규모가 아니다. 그래서 이 사례는 따라 하기 위한 것이 아니라 다음 네 가지를 위해 볼 가치가 있다.

첫째, 진단 신호 하나.

“읽기 레플리카를 늘렸는데 나아지지 않는다.”

이것은 요청 하나가 하는 일의 양이 문제라는 뜻이다. 레플리카는 동시에 처리할 수 있는 요청 수를 늘릴 뿐 요청당 작업량을 줄이지 않는다. 이 신호를 알아채면 서버를 더 붙이는 것이 답이 아니라 자료구조를 바꿔야 하는 문제임을 알 수 있다. 이것은 작은 규모에서도 바로 쓰인다.

둘째, 저장과 서빙은 다른 문제다. 하나의 저장소가 둘 다 잘해 주기를 기대하지 않는다.

셋째, 교체하는 방법. 가장 바로 쓸 수 있는 교훈이다.

  • 구간을 일부러 겹쳐 두고 ID로 중복을 제거한다. 복제 지연을 신뢰하지 않는다
  • 폴백 경로를 먼저 만든다. 새 쪽이 죽으면 옛 경로로 간다
  • 섀도 모드로 두 경로의 결과를 대조한 뒤 전환한다. 빠른 것보다 같은 것이 먼저다

저장소 교체든 API 교체든 그대로 적용된다.

넷째, 성공 지표를 꼬리 지연으로 잡았다는 점이다. 3.4에서 다시 다룬다.

데이비드 휠러(David Wheeler, 케임브리지)의 격언. 원문은 “another level of indirection(간접 참조)“이고, “추상화 레이어"는 통용되는 의역이다. Butler Lampson이 휠러의 말로 전한 것이 널리 알려진 경로다 [B]

Peter Travers, Rebuilding Linear’s delta sync read path, 2026-08-18 — https://linear.app/now/rebuilding-delta-sync-read-path [A]

사용된 저장소는 turbopuffer다. 복제 파이프라인은 Postgres 논리 복제를 읽으며, 복제 지연은 p50 약 1초, p95 수 초다.

변경은 불변이라 인덱스는 append 위주이고, 워크스페이스마다 별도 네임스페이스로 격리한다.

3.2 그리고 이것들을 포기했다#

이 부분을 빼면 이 글은 홍보물이 된다.

CRDT가 아니다#

로컬 퍼스트라고 하면 보통 CRDT(Conflict-free Replicated Data Type, 충돌 없는 복제 자료형)를 떠올린다. 여러 사람이 동시에 고쳐도 알아서 하나로 합쳐지는 자료구조다. Linear는 이것을 쓰지 않는다.

중앙 서버가 전체 순서를 정하므로 오히려 OT(Operational Transformation, 연산 변환)에 가깝다. 충돌 해결은 LWW(Last-Writer-Wins), 즉 나중에 쓴 사람이 이기는 방식이고, 그것도 “수정” 작업에만 적용된다.

CRDT는 최근에야, 그것도 이슈 설명 본문에만 들어갔다.

이것이 가능한 이유는 Linear에서는 충돌이 드물기 때문이다. 같은 이슈의 담당자를 두 사람이 동시에 바꾸는 일은 많지 않다. 도메인의 특성이다. 그래서 이 선택은 다른 제품에 그대로 적용되지 않는다. 동시 편집이 많은 제품이라면 이 선택을 따라 할 수 없다.

멱등성이 없다#

2.4에서 본 그대로다. 재전송이 중복 실행될 수 있다.

로컬 저장소가 영구적이지 않다#

Safari는 사용자 상호작용이 7일 동안 없으면 IndexedDB를 포함한 저장소를 모두 지운다. 2020년(Safari 13.1 / iOS 13.4)부터다. 홈 화면에 추가한 웹 앱은 별도 카운터를 갖지만, 일반 탭에서는 로컬 DB가 시스템 오브 레코드가 될 수 없다.

스키마 변경이 전 클라이언트 마이그레이션이다#

모델과 프로퍼티 메타데이터 전체를 해시한 값이 있는데, 필드 하나만 추가해도 이 해시가 바뀌고 모든 클라이언트가 마이그레이션 경로를 탄다. Linear는 거의 30분마다 배포하는 팀이다.

이것이 로컬 퍼스트의 숨은 세금이다. 마이그레이션이 서버 한 대가 아니라 수만 대의 클라이언트에서 실행된다.

데이터 규모에 상한이 있다#

Artman 본인이 선을 명확히 그었다. sync engine은 “한 시점에 브라우징할 수 있는 범위로 데이터가 제한되는” 애플리케이션에 맞는다. 검색 엔진이나 대용량 분석에는 맞지 않는다.

그리고 초기 로딩#

처음 한 번은 전부 내려받아야 한다. 워크스페이스가 클수록 첫 방문이 느려진다. 실제로 Linear도 초기에는 워크스페이스 전체를 받았다가, 규모가 커지면서 일부를 온디맨드로 옮겼고 그만큼 오프라인 기능이 줄었다.

CRDT 미사용·LWW·rebase 범위: 역공학 문서 4장 [A]. “이슈 설명에만 CRDT"와 “충돌이 드물다"는 2차 분석 [B — fujimon.com]

Safari 7일 정책: WebKit ITP(Intelligent Tracking Prevention, 지능형 추적 방지). 대상은 IndexedDB, LocalStorage, SessionStorage, 서비스워커 등록·캐시. 기준은 달력 날짜가 아니라 브라우저 사용일이다.

스키마 해시와 마이그레이션: 역공학 문서 1·2장 [A]

데이터 규모 상한: Artman, localfirst.fm #15 [A]

부분 오프라인으로의 후퇴: [B]

3.3 나머지 절반 — sync engine만의 일이 아니다#

Linear가 빠른 이유가 sync engine만은 아니다.

2026년 8월, Linear는 CSS 라이브러리를 styled-components에서 StyleX로 교체했다. CSS-in-JS는 렌더링 중에 스타일을 만들어 주입하는 런타임 비용을 내기 때문이다.

실제로 웹 인스펙터로 네트워크를 보면 꽤 오랫동안 에셋이 비동기로 다운로드된다.

  • 결과: 뷰가 무거운 페이지에서 메인 스레드 CPU 20~35% 감소, 중급 사양 기기에서 약 30% 향상
  • 화면을 이동할 때마다 주입하던 CSS 규칙 수백 개가 0이 되었다
  • 규모: 6개월, PR(Pull Request) 1,000건 이상

방법이 흥미롭다. 에이전트에게 “리팩터링해 줘"라고 맡긴 것이 아니다.

  1. 먼저 결정론적인 codemod를 만들었다
  2. 그다음 범위를 좁게 자른 에이전트와 검증 스크립트를 투입했다
  3. 캐스케이드 영향이 적은 말단 컴포넌트부터 시작했다
  4. PR에서 새 styled-components 사용을 막는 봇을 세우고, 개발자 툴바에 남은 개수 카운터를 띄웠다

번들러도 Rolldown으로 옮겨 프로덕션 빌드가 46초에서 6초로 줄었다. 하루 200번 빌드하므로 매일 CI(Continuous Integration, 지속적 통합) 시간 40분이 사라진 셈이다.

Kenneth Skovhus, Styling Linear for the future with StyleX, 2026-08-26 — https://linear.app/now/styling-linear-for-the-future-stylex [A]

Rolldown 46초 → 6초, 하루 200빌드: Vite 8 사례 연구로 여러 곳에서 인용된다 [B]. Linear 공식 포스트로는 확인되지 않았다.

3.4 마무리 — UX를 제약으로 걸면 무슨 일이 일어나나#

이제 0부에서 걸어 둔 논지를 회수할 차례다.

지금까지 본 것은 모두 한 곳에서 나왔다#

키보드 단축키, 100밀리초 예산, 상태를 세 겹으로 쪼갠 구조, 시퀀스 번호, 트랜잭션 로그, 20테라바이트짜리 읽기 경로 재설계.

기술적으로는 완전히 다른 층위지만 출발점은 하나다.

“누르면 바로 나와야 한다.”

왜 성능 작업이 아니라 UX 작업인가#

성능 목표가 원가나 용량이 아니라 인간의 지각이나 감각에서 나온다면, 그것은 성능 작업이 아니라 UX 작업이다.

3.1의 읽기 경로 재설계가 정확히 이 기준에 들어맞는다. 목표는 “빠르게"가 아니라 “p95·p99를 평평하게"였고, 이유는 “느려지면 전체 재다운로드로 떨어져 사용자 눈에 절벽이 보이기 때문"이었다.

데이터베이스 읽기 경로를 갈아엎은 프로젝트의 성공 지표가 UX 속성이었다.

UX 개선이라고 하면 보통 표면을 떠올린다. 레이아웃, 카피, 플로우, 클릭 수 줄이기. Linear가 한 일은 다른 범주다. 차이는 여기에 있다.

  • 요구사항은 충족시키면 된다
  • 제약조건은 아키텍처를 결정한다

“100밀리초 안에 응답한다"를 제약으로 박는 순간 서버 왕복이 있는 모든 설계가 후보에서 빠진다. 남는 것이 로컬 퍼스트뿐이어서 그것을 고른 것이다.

기술을 먼저 고르고 UX 명분을 붙인 것이 아니다. UX가 기술을 강제했다. 그 증거가 LIN-1이다. 조작 방식이 제품 기능보다 먼저 결정되었다.

개선의 책임#

보통의 UX 개선은 비용이 UX 쪽에 청구된다. 카피를 고치고 레이아웃을 바꾸고 클릭 수를 줄이는 데 드는 것은 디자이너와 프론트엔드 개발자의 시간이다. 비용과 효과가 같은 칸에 들어간다.

Linear가 산 것도 UX다. 그런데 청구서는 UX 칸으로 오지 않았다.

  • 즉각적인 내비게이션 → 릴리스: 필드 하나 추가가 수만 대 클라이언트의 마이그레이션이 된다
  • 재접속 후 빠른 따라잡기 → 인프라 운영: 20TB 로그 전용 인덱스 시스템 도입과 CDC 파이프라인 상시 운영
  • 오프라인에서도 쓰기 → 데이터 정합성: 멱등성 포기. 사용자가 “존재하지 않는 것을 삭제할 수 없습니다"를 본다
  • 로컬에 데이터 보유 → 플랫폼 리스크: Apple이 7일 뒤 저장소를 지운다. 남의 정책이 내 내구성 문제가 된다
  • 전체를 로컬에 둠 → 제품 범위: 데이터 규모 상한. 전사 검색과 대용량 분석은 이 구조로 만들 수 없다

화살표 왼쪽은 모두 사용자가 느끼는 것이고, 오른쪽은 사용자가 전혀 모르는 것이다. 그리고 오른쪽 청구서를 받은 부서에 디자인팀은 없다. SRE, 릴리스, 제품 로드맵이 나눠서 냈다.

그래서 이것은 “개선"이 아니라 “트레이드오프"다.

이 구분은 실제 의사결정에 영향을 준다.

“UX를 좋게 하려고요"로는 예산이 나오지 않는다. 그러나 “응답성 100밀리초를 제약으로 걸면 이런 비용이 따라옵니다"는 의사결정의 대화가 된다.

부수입이 하나 있었다#

UX만으로 설명되지 않는 것이 하나 남는다. 개발 속도다.

2.2에서 본 local 전략으로 서버를 건드리지 않고 기능을 만드는 것, sync engine이 있으니 프론트엔드만 작성하면 기능이 끝나는 것. 이것은 사용자 경험이 아니라 조직의 처리량이다.

Artman이 “예상 못 한 이득(unexpected benefit)“이라고 부른 것이 바로 이것이다. 애초의 목표가 아니었다.

UX를 제약으로 걸었더니, 개발 속도라는 부수입이 따라왔다.

사업적으로는 이 부수입이 원래 목표보다 클 수도 있다. 경쟁사보다 빨리 출시하는 능력이기 때문이다.

“예상 못 한 이득”: Artman, Unexpected benefits of going local-first, Local-First Conf 2024 (베를린, 2024-05-30) [A]

채택을 막는 것이 기술이 아니라 문화라는 Artman의 진단도 참고할 만하다. 라이브러리 생태계 전체가 RPC를 전제로 만들어져 있어서, 스스로 떠안은 복잡도가 보이지 않는다는 취지다 [A — localfirst.fm #15]


부록 — 자주 나오는 질문#

이 구조를 다른 제품에도 적용할 수 있나#

세 가지를 먼저 따져 봐야 한다.

  1. 그 제품의 응답성 목표가 사람의 지각에서 나오는가, 아니면 “느리지 않으면 된다” 수준인가. 전자라면 제약이고 후자라면 요구사항이며, 둘은 서로 다른 아키텍처로 이어진다
  2. 데이터가 “한 시점에 브라우징할 수 있는 범위"에 들어오는가. Artman이 직접 그은 선이며, 검색·분석·대용량 데이터에서는 이 거래가 성립하지 않는다
  3. 인프라와 릴리스 조직이 그 청구서를 받을 준비가 되어 있는가. 스키마 마이그레이션, 저장소 내구성, 멱등성, 운영비. 이것은 디자인 안건이 아니라 아키텍처 안건이므로 결재선이 달라야 한다

마우스가 더 빠르다는 연구가 있지 않나#

1989년 Apple의 Bruce Tognazzini가 “사용자들은 키보드가 빠르다고 말하지만 스톱워치는 마우스가 빠르다고 말한다"고 썼다. 30년 넘게 인용된 말이지만, Dan Luu가 출처를 추적해 보니 실험 방법이 공개된 적이 없었다. 표본 크기도, 과제 목록도, 데이터도 없다. Luu가 직접 방법을 공개하고 측정한 결과는 “범주에 따라 다르다"였다. 검색·치환은 키보드가 4.3배 빨랐고, 화면에 보이는 텍스트 블록 선택은 마우스가 2.5배 빨랐으며, 스크롤은 비슷했다. 1.2의 “정직하게"가 바로 이 내용이다 [B]

필요한 데이터만 어떻게 알고 받나#

partial index다. 서버의 인덱스 설계를 클라이언트에 복제한다. Issue의 경우 9개 인덱스 × 17개 의존 관계의 데카르트 곱이며, 최대 3단계 깊이까지 간다.

드래그로 순서를 바꾸면 어떻게 되나#

sortOrder는 문자열이 아니라 float(-84.71)다. 앞뒤 값의 중간값을 주면 나머지 행을 건드리지 않는다. 다만 double의 가수가 52비트여서 같은 자리에 50번쯤 끼워 넣으면 정밀도가 고갈된다. 재균형 방식은 공개되지 않았다.

동시에 고치면 어떻게 되나#

rebase로 처리한다. 내 변경이 서버에서 실행되기 전에 다른 사람의 변경이 도착하면, 내 트랜잭션의 “되돌릴 값"을 다른 사람의 값으로 갱신하고 메모리에는 내 값을 유지한다.

이 내부 구조를 어떻게 파악했나#

역공학 문서와 직접 관측을 바탕으로 했다. 그리고 실제로 사용한 렌즈는 임베디드였다. 부트 시퀀스, CAN(Controller Area Network, 차량용 통신 규격)의 수용 필터, 링 버퍼, NVRAM(Non-Volatile RAM, 전원이 끊겨도 남는 메모리) 저널, 워치독, HIL(Hardware-in-the-Loop, 실제 부품을 연결해 놓고 하는 검증)이 모두 대응되는 개념을 갖고 있다.

AI 에이전트 기능은#

Linear Agent는 자체 하네스를 쓰고, 도구 설계로 제약을 인코딩하며, 비동기 서브에이전트를 사용한다 [A]


출처#

A급 — 1차#

HCI 문헌#

  • Card, Moran & Newell, The Keystroke-Level Model (1980) — K 0.2s / P 1.1s / H 0.4s / M 1.35s
  • Jakob Nielsen, Response Times: The 3 Important Limits (0.1s / 1s / 10s)
  • Bruce Tognazzini, Ask Tog (1989), 『Tog on Interface』 6장

B급 — 2차, 교차 확인#

  • Dan Luu, The widely cited studies on mouse vs. keyboard efficiency are completely bogus — https://danluu.com/keyboard-v-mouse/
  • Yuya Fujimoto, Linear’s sync engine architecture — https://www.fujimon.com/blog/linear-sync-engine
  • bloomberry, The data on Jira vs Linear (2026-05)
  • Adobe의 Figma 인수 시도(2022-09, 200억 달러)와 철회(2023-12-18, 위약금 10억 달러) — Bloomberg·Engadget 등 다수
  • Vite 8 / Rolldown 이전 사례

C급 — 단일 출처, 수치 인용 주의#

  • performance.dev, How’s Linear so fast? A technical breakdown