Linear는 왜 이렇게 빠른가? 기술적 분석
TMT원문: How's Linear so fast? A technical breakdown (Dennis Brotzky, performance.dev, 2026-05-03)
Linear의 속도를 뒷받치는 구조를 뜯어봅니다. 로컬 우선 동기화, MobX 옵저버블, 즉시 뜨는 첫 로딩, 키보드 중심 설계를 차례로 살펴봅니다.
Linear에서 이슈 하나를 수정하는 데는 몇 밀리초밖에 걸리지 않습니다. 같은 일을 하는 전통적인 CRUD 앱은 300밀리초쯤 걸립니다. 어떻게 하는 걸까요? 성능에는 비장의 한 방 같은 것이 없습니다. 실제로는 처음부터 올바른 토대 위에 앱을 세우고, 그 위에 수많은 결정을 쌓아 올려 개선한 결과입니다. 이 글에서는 Linear를 지금과 같이 느껴지게 만드는 기법 몇 가지를 짚어 보고, 같은 것을 직접 구현하는 데 도움이 되도록 정리하려 합니다.
이 글에서 다룰 내용
- 브라우저 안의 데이터베이스
- 첫 로딩을 즉시 뜨는 것처럼 만들기
- 동기화 엔진
- 속도를 겨냥한 설계
- 애니메이션
미리 밝혀 둘 점이 있습니다. 저는 Linear에서 일해 본 적이 없고 그들의 코드를 본 적도 없습니다. 여기 쓴 내용은 모두 제 경험과, 앱을 직접 뜯어본 결과, 블로그 글, 콘퍼런스 발표 영상에서 나왔습니다. 저는 그저 웹 앱 만드는 일을 좋아하고, Linear는 베타 때부터 써 왔습니다. 그리고 이 글의 대표 이미지는 Meg Wayne의 영상에서 가져왔는데, Linear를 위해 만든 작업물이 정말 훌륭합니다.
브라우저 안의 데이터베이스
대부분의 웹 앱은 같은 고리 안에서 돌아갑니다. 사용자가 클릭합니다. 브라우저가 HTTP 요청을 보냅니다. 서버가 데이터베이스에 질의하고 결과를 돌려줍니다. 브라우저가 화면을 다시 그립니다. 그래서 사용자가 보는 것은 스피너나 스켈레톤 화면, 아니면 네트워크를 기다리는 몇백 밀리초 동안 멈춰 있는 UI입니다.
Linear는 이 관계를 뒤집습니다. UI가 실제로 읽는 데이터베이스는 브라우저 안, IndexedDB에 있습니다. 변경은 먼저 로컬에 적용되고, 그다음 비동기로 서버에 올라가며, 서버는 WebSocket으로 다른 클라이언트에 변경분(delta)을 뿌립니다.
제가 보기에 이것이 Linear 성능의 가장 결정적인 조각입니다. 빠른 웹 앱을 만들려고 할 때 가장 크게 부딪히는 병목은 네트워크입니다. 클라이언트와 서버 사이를 오가는 데이터는 무엇이든 수백 밀리초를 잡아먹습니다. 가장 좋은 방법은 네트워크 요청 자체를 없애는 것이고, Linear가 하는 일이 정확히 그렇습니다.
이 글에서 여러 번 반복할 이야기인데, 훌륭한 웹 앱을 만드는 비결은 네트워크 요청을 사용자에게 하나도 보이지 않게 감추는 것입니다. 로딩 상태를 더 많이 없앨수록 좋습니다.
Linear의 요청이 얼마나 단순한지 예를 보겠습니다.
// 전통적인 웹 앱이 서버를 갱신하는 방식
async function updateIssue({ issue }) {
showSpinner();
const response = await fetch(`/api/issues/${issue.id}`, {
method: "PATCH",
body: JSON.stringify({ title: issue.title }),
});
const updated = await response.json();
setIssue(updated)
hideSpinner();
}
// Linear는 이렇게 합니다
issue.title = "Faster app launch";
issue.save();첫 줄 issue.title = "Faster app launch"는 메모리 안의 데이터 저장소를 갱신합니다. Linear에서는 MobX 옵저버블입니다. 둘째 줄 issue.save();는 트랜잭션을 큐에 넣고, 동기화 엔진이 이를 묶어서 서버로 흘려보냅니다. 핵심은 UI가 로컬 메모리에서 일어난 갱신을 보고 동기적으로 다시 렌더링된다는 점입니다. 기다릴 것이 없으니 스피너도 없습니다. 데이터는 백그라운드에서 동기화됩니다. 사용자마다 브라우저를 데이터베이스로 취급할 때 생기는 마법입니다.
Linear의 공동 창업자 Tuomas는 2024년 어느 콘퍼런스에서 이렇게 말했습니다. 자기가 맨 처음 쓴 코드가 바로 동기화 엔진이었고, 이는 스타트업에서 보통 하는 일과는 아주 거리가 멀다는 것이었습니다. 첫날부터 Linear는 어떤 길로 갈지, 그 대가가 무엇인지 알고 있었던 셈입니다.
스피너도 지연도 없는 Linear의 이슈 생성
앱을 빠르게 만들려고 Linear처럼 동기화 엔진을 직접 만드는 사람은 많지 않을 것이고, 그럴 필요도 없습니다. 대부분의 경우에는 Tanstack Query나 SWR 같은 라이브러리로 낙관적 업데이트(optimistic update)를 넣는 것만으로도 놀라울 만큼 비슷한 감각까지 갈 수 있습니다. 대부분의 웹 앱이 느리게 느껴지는 이유는 UI가 상태를 바꾸기 전에 네트워크 요청이 끝나기를 기다리기 때문입니다. 그 요청은 대개 성공하니, 그 사실을 활용해 상태를 먼저 낙관적으로 갱신하는 편이 낫습니다.
// SWR로 낙관적 변경
mutate(
`/api/issues/${issue.id}`,
{ ...issue, title: "Faster app launch" },
false
);
// Linear는 이렇게 합니다
issue.title = "Faster app launch";
issue.save();핵심 생각은 단순합니다. UI가 얼마나 잘 반응하는지가 네트워크 지연에 좌우되어서는 안 됩니다. 사용자는 서버가 얼마나 빨리 답하는지가 아니라, 화면이 얼마나 빨리 반응하는지로 속도를 느낍니다.
낙관적 요청은 들인 노력에 비해 효과가 가장 큰 개선 중 하나입니다.
- 불필요한 스피너를 없앤다
- 상태를 즉시 갱신한다
- 검증은 백그라운드에서 한다
- 필요할 때만 되돌린다
Linear의 토대는 바로 이 원칙 위에 서 있고, 그래서 앱이 네이티브처럼 빠르게 느껴집니다.
Linear의 스택 들여다보기
Linear는 찾아볼 수 있는 가장 단순한 스택으로 만들어져 있습니다. React, TypeScript, MobX, Postgres, CDN입니다. 엣지 데이터베이스도, React Server Components도, 화려한 프레임워크도 없습니다.
프런트엔드
React + react-dom (UI 런타임)
MobX (옵저버블 그래프, 세밀한 리렌더링)
TypeScript (앞단부터 뒷단까지 한 언어)
Rolldown-Vite + plugin-react-oxc (2025년 중반부터. 그전에는 Rollup, 그전에는 Parcel)
ProseMirror + y-prosemirror (리치 텍스트 에디터. 실시간 협업용 Yjs CRDT)
Radix UI primitives (팝오버, 메뉴, 포커스 트랩)
Emotion + StyleX (Emotion 런타임 + 아토믹 CSS로 컴파일되는 StyleX)
Comlink (워커 RPC)
idb (로컬 우선 저장소를 받치는 IndexedDB 래퍼)
graphql-request (동기화 서버로 가는 GraphQL 전송)
Sentry (오류 모니터링)
Inter Variable (woff2 하나, font-display: swap)
백엔드
Node.js + TypeScript (서버 코드 전부 한 언어)
Cloud SQL의 PostgreSQL (issues 테이블은 300개로 파티셔닝)
Memorystore Redis (이벤트 버스 + 캐시 + 동기화 커서)
turbopuffer (유사 이슈 탐지, 벡터 DB)
GCP의 Kubernetes (관심사마다 워크로드 하나)
Cloudflare Workers (멀티 리전 엣지 프록시)
그 외 클라이언트
데스크톱: Electron (같은 웹 JS, 네이티브 창)
모바일: Swift(iOS) + Kotlin (완전히 별도로 다시 구현)
마케팅
Next.js (정적)
styled-components
인라인 SVG 스프라이트제가 가장 눈여겨본 것은 클라이언트 사이드 렌더링을 고집한 결정입니다. CSR은 첫 로딩이 느리다는 비판을 자주 받지만, 구조와 설계를 제대로 잡으면 즉시 뜨는 것처럼 느껴질 수 있습니다.
여기서 오는 단순함도 저는 아주 좋아합니다. 앱을 전부 클라이언트에 두면 머릿속 모델이 훨씬 깔끔해지고, 서버 렌더링 앱에 따라붙는 복잡함이 많이 사라집니다. 지금 서버인지 클라이언트인지, window 객체에 접근할 수 있는지, 캐시 헤더를 제대로 설정했는지 계속 신경 쓸 필요가 없습니다. 단순함에는, 그리고 그 단순함이 강제하는 제약에는 아름다움이 있습니다.
그렇다면 Linear는 클라이언트에서 렌더링하는 앱을 어떻게 즉시 뜨는 것처럼 만들까요?
첫 로딩을 즉시 뜨는 것처럼 만들기
제가 유난히 집착하는 것이 첫 로딩인데, Linear도 분명히 그렇습니다. 특히 생산성 도구에서는 실제로 일을 시작할 수 있게 되기까지 걸리는 시간이 가장 중요한 요소에 듭니다. 새 탭이 몇 초씩 뜨기를 기다리고 싶은 사람은 없습니다.
먼저 첫 로딩이 왜 느려지는지 알아야 합니다. 클라이언트 앱은 index.html을 요청하고, 그 문서가 자바스크립트와 CSS를 전부 요청하고, 그다음 어떤 형태로든 인증을 거치고, 마지막으로 화면을 채울 API 요청을 몇 개 보냅니다.
Linear의 번들러 여정: Parcel, Rollup, Vite, Rolldown
앱을 즉시 뜨는 것처럼 만드는 첫 단계는 런타임보다 훨씬 앞선 빌드 시점에 있습니다. 병목은 네트워크라는 점을 기억하세요. 그래서 자바스크립트와 CSS를 최소한으로 내려보내는 것이 빠른 로딩에 결정적입니다.
제가 파악한 바로 Linear는 빌드 파이프라인을 네 번 다시 만들었습니다. Parcel에서 Rollup으로, Vite로, 다시 Rolldown으로 옮겼습니다. 매번 이유는 같았습니다. 자바스크립트와 CSS의 양을 줄이고 개발 경험을 개선하는 것이었습니다.
블로그 글에서 밝힌 성과는 이렇습니다.
- 내려보내는 코드가 50% 줄었다.
- 압축 후 크기가 30% 작아졌다.
- 캐시가 비어 있는 상태의 페이지 로딩이 10~30% 빨라졌다.
- 활성 이슈 화면이 처음 그려지는 시점이 59% 앞당겨졌다(Safari 기준).
- 메모리 사용량이 70~80% 줄었다.
대부분은 최신 브라우저만 겨냥한 결정, 더 나은 죽은 코드 제거, 과감한 코드 분할이 합쳐진 결과입니다. 레거시 지원을 버린 것이 가장 큰 이득입니다. 폴리필과 ES5 트랜스파일, nomodule 대체 경로가 모두 사라집니다. 다만 죽은 코드를 걷어내고 청크를 나눈 작업도 그만큼 중요합니다.
이렇게 최적화를 다 했어도 Linear가 내려보내는 코드 양은 여전히 상당합니다. 압축한 자바스크립트가 대략 21MB입니다. 차이는 이것이 수백 개의 라우트 단위 청크로 잘게 쪼개져, 필요할 때만 받아진다는 점입니다.
// vite.config.ts (재구성한 설정이며, 관찰된 청크 그래프와 일치합니다)
export default defineConfig({
plugins: [react()],
build: {
target: "esnext", // 레거시 문법 없음, 폴리필 없음
cssMinify: "lightningcss",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
// 약 3KB를 넘는 npm 패키지마다 청크 하나. 캐시 무효화가
// 앱 리비전 단위가 아니라 라이브러리 단위로 바뀝니다.
manualChunks(id) {
if (id.includes("node_modules")) {
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg) return `vendor-${pkg}`;
}
},
},
},
},
});교훈은 어떤 번들러를 고르느냐가 아닙니다. 레거시 브라우저를 버리고, 네이티브 ESM으로 가고, 코드를 미친 듯이 분할하는 것이 중요하다는 점입니다. 한 걸음 한 걸음은 작습니다. 그런데 쌓이고 나니 Linear의 첫 로딩 자바스크립트가 절반 가까이 줄고, 빌드 시간은 10분의 1 수준이 됐습니다.
정리하면, 즉시 뜨는 로딩의 첫 비결은 사용자에게 무언가를 그려 주기까지 필요한 자바스크립트와 CSS의 양을 줄이는 것입니다.
첫 로딩이 끝난 뒤의 사전 로딩
자바스크립트를 가능한 한 작은 청크로 쪼갰다면, 이제 백그라운드에서 일을 시작할 수 있습니다.
그런데 번들을 수백 개 청크로 쪼개면 새로운 문제가 생깁니다. 청크마다 다른 청크를 import하는데, 브라우저는 진입 스크립트를 파싱하기 전까지 그게 무엇인지 모릅니다. 그냥 두면 로딩 타임라인이 워터폴이 됩니다. 진입 파일을 받고, 파싱하고, 그 import를 받고, 또 파싱하고, 다시 그 import를 받습니다. 단계마다 네트워크 왕복이 늘어나니 무슨 수를 써서든 피해야 합니다.
Linear는 자바스크립트가 실행되기 전에 브라우저가 전체 목록을 보고 요청을 한꺼번에 병렬로 쏘게 만듭니다. 진입 스크립트가 첫 import에 도달할 때쯤에는 청크가 이미 캐시에 들어 있습니다.
index.html의 <head />는 이런 모양입니다.
<script type=module crossorigin
src="https://static.linear.app/client/assets/html.2_JBQs3Q.js"></script>
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/vendor-mobx.Crhy2qQc.js">
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/SyncWebSocket.Djw6l_Op.js">
<link rel=modulepreload crossorigin
href="https://static.linear.app/client/assets/DatabaseManager.DKssGAN8.js">
<!-- ...이런 줄이 훨씬 더 많이 이어집니다 -->각 preload에 붙은 crossorigin 속성은 진입 스크립트의 crossorigin과 일치합니다. 그래서 브라우저는 preload와 import를 별개 리소스로 보지 않고 캐시된 응답을 재사용합니다. 폰트 preload에서 쓰는 것과 같은 요령을, 중요 경로에 있는 모든 청크에 적용한 것입니다.
이러면 캐시가 비어 있는 상태의 로딩 타임라인이 순차 워터폴에서 한 번의 병렬 묶음으로 접힙니다. 일은 여전히 네트워크가 합니다. 다만 한꺼번에 합니다. 이 기법의 좋은 점은, 사용자가 로그인 화면에 처음 머무는 동안 이 작업을 전부 백그라운드에서 해 둘 수 있다는 것입니다. 몇 초 만에 앱 전체가 캐시에 들어가고, 그다음부터는 즉시 뜹니다.
사람들이 앱을 어떻게 쓰는지 이해하는 일은 대단히 중요합니다. 그걸 알고 나면 Linear처럼 백그라운드에서 스크립트를 미리 받아 두는 식으로 그 이해를 활용할 수 있습니다.
속도와 오프라인 동작을 위한 서비스 워커
나머지, 즉 사용자가 아직 방문하지 않은 화면의 라우트 단위 청크는 서비스 워커가 백그라운드에서 캐시해 둡니다. 이 워커는 소스 안에 사전 캐시 목록을 담고 있는데, 라우트 청크와 아이콘, 폰트를 합쳐 해시가 붙은 자산이 1,200개쯤 됩니다. 첫 페이지 로딩이 끝난 뒤 이것들을 느긋하게 받아 옵니다. 로그인 화면에 닿고 몇 초 안에 앱 전체가 캐시에 들어앉습니다.
쪼개 둔 자바스크립트 파일을 모두 미리 받아 두어 캐시에서 즉시 뜨게 만듭니다
이걸로 두 가지를 얻습니다. 이후 화면 이동은 네트워크를 아예 건너뜁니다. 서비스 워커가 tHTTP 캐시까지 가지도 않고 자기 캐시에서 바로 응답합니다. 그리고 네트워크가 끊겨도 앱이 계속 돌아갑니다. 사용자 데이터를 이미 IndexedDB에 갖고 있는 로컬 우선 동기화 엔진과 합쳐지면서, Linear는 오프라인에서도 쓸 수 있습니다. 이슈를 읽고, 새로 만들고, 제목과 설명을 고치고, 상태를 바꿀 수 있습니다. 모든 변경은 로컬 트랜잭션 저장소에 쌓여 있다가 연결이 돌아오는 순간 서버로 흘러갑니다.
modulepreload는 앱이 지금 필요한 것을 다룹니다. 직렬 import 사슬에서 브라우저가 멈추지 않도록 병렬로 받아 옵니다. 서비스 워커는 앱이 다음에 필요할 것을 다룹니다.
정리하면 Linear가 로딩을 빠르게 만드는 순서는 코드를 최대한 걷어내고, 작은 조각으로 쪼개고, 백그라운드에서 미리 캐시하는 것입니다. 다시 말하지만 이 모든 작업의 목표는 네트워크 요청을 최대한 빠르게 만드는 것, 더 좋게는 아예 없애는 것입니다.
벤더 번들 구성
흥미로웠던 점은 Linear가 쓰는 패키지마다 각자의 청크를 갖고 따로 캐시된다는 것입니다. 전통적인 vendor.js는 의존성 하나만 올려도 그래프 전체가 무효화됩니다. Linear식 청크 분할은 벤더 캐싱을 거대한 파일 하나에서 잘게 나뉜 단위로 바꿉니다. 의존성 하나를 올리면 청크 하나만 무효화되고 나머지는 캐시에 남습니다.
당연해 보이지만, 이것도 빠른 로딩을 위한 또 하나의 디테일입니다.
패키지마다 각자의 js 파일로 쪼개져 있습니다
큰 폰트 파일 불러오기
폰트 로딩은 많은 앱이 잘못하는 대표적인 항목입니다. 실패는 눈에 그대로 보입니다. 0.5초쯤 글자가 안 보이거나, 실제 폰트로 바뀌면서 레이아웃이 흔들리거나, preload가 맞지 않아 같은 리소스를 두 번 받습니다. Linear의 설정은 이 세 가지를 모두 피합니다.
<!-- index.html의 <head> 안 -->
<link rel="preload"
href="https://static.linear.app/fonts/InterVariable.woff2?v=4.1"
as="font" type="font/woff2" crossorigin="anonymous">
<link rel="preconnect" href="https://static.linear.app" crossorigin>@font-face {
font-family: "Inter Variable";
font-weight: 100 900;
font-display: swap;
src: url(https://static.linear.app/fonts/InterVariable.woff2?v=4.1)
format("woff2");
}
/* 이탤릭과 Berkeley Mono도 같은 모양이며, 각각 woff2 하나입니다. */가변 폰트는 100부터 900까지 굵기 축 전체를 woff2 하나에 담기 때문에, 굵기마다 요청을 따로 보내지 않아도 됩니다. font-display: swap은 대체 폰트를 즉시 그리고, Inter가 준비되면 갈아 끼웁니다. 놓치기 쉬운 요령은 preload 태그의 crossorigin="anonymous"입니다. 이게 없으면 브라우저는 폰트를 미리 받아 두고도, 나중에 CSS가 그 폰트를 참조할 때 다시 받아 옵니다. 두 요청의 CORS 모드가 다르기 때문입니다. preload에 crossorigin을 붙이면 브라우저가 캐시된 것을 재사용합니다.
다 단순해 보이지만, 폰트를 잘못 불러오는 앱이 얼마나 많은지 볼 때마다 놀랍니다. Linear는 디테일을 끝까지 따져서 폰트 로딩을 최대한 빠르고 정확하게 만든 좋은 예입니다.
인라인으로 심어 둔 앱 셸
첫 로딩을 빠르게 느껴지게 하는 또 하나의 핵심 기법입니다. <head/> 안에 로딩 화면을 그릴 만큼의 CSS만 인라인으로 넣어 두고, 외부 스타일시트는 받지 않습니다. 병목은 네트워크이고, 앱을 빠르게 만들려면 늘 그것과 싸워야 한다는 점을 기억하세요. 여기서 Linear는 앱 셸을 보여 주는 데 필요한 CSS를 인라인으로 넣어 네트워크 요청 하나를 없앱니다.
<style>
:root {
--bg-color: #f5f5f5;
--bg-base-color: #fcfcfd;
--bg-border-color: #e0e0e0;
--sidebar-width: 244px;
}
html { background: var(--bg-color); height: 100%; }
body { font-family: "Inter Variable", Arial, Helvetica, sans-serif; }
#appBorders {
border: 1px solid var(--bg-border-color);
background: var(--bg-base-color);
margin: 8px 8px 8px var(--sidebar-width);
border-radius: 12px;
}
#logo { transform: translateZ(0); }
@keyframes logoBackgroundPulse {
0% { opacity: 0; transform: scale(0.8); }
70% { opacity: 1; }
100% { opacity: 0; transform: scale(1.0); }
}
</style>
<script>performance.mark("appStart");</script>CSS 말고도 초기 화면에 꼭 필요한 자바스크립트가 인라인으로 들어가 있습니다.
<script>
// Electron 환경 판별. CSS가 네이티브 창 여부에 따라 분기할 수 있게 합니다.
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");
// 로컬 저장소가 없으면 워크스페이스 데이터도 없으니 로그인 레이아웃을 그립니다.
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");
// 화면을 그리기 전에 마지막으로 알던 셸 값(사이드바 배경, 너비, 다크 모드)을 복원합니다.
const c = JSON.parse(localStorage.getItem("splashScreenConfig") || "{}");
if (c.bgSidebarColor) document.documentElement.style.setProperty("--bg-sidebar-color", c.bgSidebarColor);
if (c.sidebarWidth) document.documentElement.style.setProperty("--sidebar-width", c.sidebarWidth + "px");
if (c.darkMode) document.documentElement.classList.add("dark");
// 데스크톱 앱에서 링크를 여는 설정이면 사이드바를 아주 얇게 접습니다.
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");
</script>번들이 파싱되기도 전에 index.html의 자바스크립트가 localStorage.splashScreenConfig를 읽고, sessionStorage에 덮어쓴 값이 있으면 그 위에 얹은 뒤, 사용자가 마지막으로 쓰던 셸 값을 document.documentElement.style에 바로 적용합니다. 사이드바 배경, 기본 색, 테두리 색, 사이드바 너비, 에이전트 툴바 높이가 그 대상입니다. 색 구성 설정과 Electron 환경도 감지합니다. localStorage.ApplicationStore가 있는지 확인해서 없으면 logged-out 클래스를 붙여, 셸을 로그인 레이아웃으로 바꿉니다.
첫 자바스크립트 번들이 네트워크에서 도착할 때쯤에는, 로딩 화면이 이미 테마와 크기와 배치를 사용자 로그인 여부에 맞게 갖춰 놓은 상태입니다.
덕분에 사용자는 주소창에서 엔터를 누른 순간 앱이 이미 준비된 것처럼 느낍니다. 최초 index.html 응답에 앱 셸을 함께 내려보내는 것보다 빠른 방법은 없습니다.
Linear의 첫 로딩이 얼마나 빠른지 보여 주는 예
먼저 그리고, 인증은 나중에
인증은 대부분의 앱이 성능 예산을 내주는 또 하나의 단계입니다. 흔한 흐름은 이렇습니다. HTML을 받고, 번들을 불러오고, 세션을 검증하고, 사용자 정보를 받고, 워크스페이스를 받고, 그다음에 렌더링합니다. 사용자가 무언가를 보기까지 1~3초가 걸립니다.
Linear는 인증을 변경 처리와 똑같이 다룹니다. 잘될 것이라고 가정하고, 검증은 백그라운드에서 합니다. 이 대목은 로딩과 거의 동시에 완성된 화면을 그릴 수 있게 해 주기 때문에, 이 구조에서 제가 특히 좋아하는 부분입니다.
대부분의 CRUD 앱은 실제 세션을 HttpOnly 쿠키에 두고, 프런트엔드가 시작할 때 로그인 여부를 알 수 있도록 자바스크립트로 읽을 수 있는 쿠키를 하나 더 두거나 /me 요청을 보냅니다. Linear는 더 단순하게 갑니다. 인증 신호를 따로 관리하는 대신, 인라인 부팅 스크립트가 localStorage.ApplicationStore가 있는지만 확인합니다.
if (localStorage.getItem("ApplicationStore") === null) {
document.documentElement.classList.add("logged-out");
}값이 있다면 이 브라우저에서 Linear를 써 본 적이 있다는 뜻이고, 그러면 워크스페이스가 이미 IndexedDB에 들어 있습니다. 앞에서 다룬, 데이터베이스가 브라우저에 있다는 이야기로 돌아오는 셈입니다. 값이 없다면 어차피 그릴 것이 없으니, 셸이 로그아웃 레이아웃으로 바뀌고 로그인 흐름이 이어받습니다.
Linear의 초기 흐름이 묻는 것은 "유효한 세션이 있는가"가 아니라 "보여 줄 것이 있는가"입니다. 실제 세션 토큰은 쿠키에 있습니다. 번들은 그걸 두고 똑똑하게 굴려고 하지 않습니다. 가진 것을 그냥 그리고, 세션이 만료됐다면 다음 요청, 즉 WebSocket 핸드셰이크나 동기화 델타, 아무 HTTP 호출이 401로 실패하게 둡니다. 그러면 클라이언트가 로그인 화면으로 보냅니다.
이 방식은 나머지 구조와 결이 같습니다. 클라이언트는 로컬에 있는 것을 믿고, 정확성의 기준은 서버가 쥐고, 둘은 비동기로 맞춰 나갑니다. 변경 처리와 똑같고, 동기화 엔진과 똑같습니다.
인증 세션을 직접 지우고 데스크톱 앱을 새로 고친 모습
이건 제가 Linear에서 특히 좋아하는 디테일이고, 다른 앱들도 이렇게 동작하면 좋겠습니다. 인증은 잘될 것이라고 가정하고, 아니라면 그때 돌아가면 됩니다. 보여 줄 데이터가 있다면 보여 주세요. 그리고 브라우저의 저장소를 활용해 즉시 그리세요.
동기화 엔진
Linear를 빠르게 만드는 요소는 대부분 하나의 결정에서 흘러나옵니다. 서버는 UI에 대한 진실의 원천이 아니라 동기화 대상이라는 결정입니다. 동기화 엔진 내부는 이미 여러 사람이 꼼꼼히 뜯어봤고, Tuomas도 그 구조에 대해 훌륭한 발표를 여러 번 했습니다. 저는 그걸 다시 훑지 않겠습니다. 대신 실제로 속도를 만들어 내는 세 축을 짚어 보려 합니다. 속도는 그중 어느 하나가 아니라, 세 축이 맞물리는 방식에서 나오기 때문입니다.
1. 데이터가 이미 거기 있다
앱이 부팅될 때 워크스페이스를 서버에서 받아 오지 않습니다. IndexedDB에서 메모리 안의 MobX 객체 풀로 올리고, UI의 모든 질의는 먼저 이 풀로 갑니다. 이슈가 이미 사용자 컴퓨터에 있으니 "이슈 불러오는 중" 상태가 존재하지 않습니다.
흥미로웠던 점은, 규모가 커지면서 자바스크립트 번들과 같은 원리로 동기화 엔진의 데이터도 잘게 나눴다는 것입니다. 전부 한꺼번에 받지 않습니다. 가장 무거운 두 테이블인 Issue와 Comment는 필요할 때 느긋하게 올립니다. 데이터 층에서 하는 코드 분할인 셈이고, 이것이 엔진의 확장성을 만듭니다. 시작 비용이 워크스페이스 크기가 아니라 워크스페이스 구조에 따라 정해집니다. 이슈가 1만 개인 워크스페이스가 100개인 워크스페이스와 거의 같은 속도로 부팅됩니다.
프로젝트에 들어가면 이슈가 이미 있습니다. 담당자로 필터를 걸면 인덱스가 이미 만들어져 있습니다. 빠진 것이 없으니 받아 올 것도 없습니다. 브라우저에서 즉시 올라왔거나, 잠시 뒤 분할된 지연 청크로 따라온 것입니다.
IndexedDB, 데이터베이스는 브라우저 안에 있습니다
2. 변경은 네트워크를 기다리지 않는다
이슈 상태를 바꾸면 거의 동시에 세 가지가 일어납니다. MobX 옵저버블이 갱신되어 UI가 변경을 반영하고, 그 변경이 IndexedDB의 영속 트랜잭션 큐에 기록되고, 서버로 보낼 큐에 들어갑니다. 여기까지 네트워크는 아직 건드리지도 않았습니다.
사용자는 자기가 만든 변경을 보려고 기다리는 일이 없습니다. 재시도와 되돌리기, 새로 고쳐도 남아 있게 하는 처리는 모두 백그라운드의 몫입니다. 서버가 거부하면 옵저버블이 되돌아가면서 화면이 잠깐 흔들리지만, 실제로는 거의 일어나지 않습니다. 잘못된 변경은 대부분 트랜잭션이 만들어지기도 전에 걸러지기 때문입니다.
계속 말하지만 네트워크는 적이고, 피하려고 할 수 있는 일은 다 해야 합니다. Linear의 흐름은 로컬 변경에서 시작하고, 서버는 허가를 받는 단계가 아니라 확인을 받는 단계로 취급합니다.
3. 델타 하나에 셀 하나
서버가 변경을 확인하면, 내 변경이든 다른 사람의 변경이든 무엇이 어떻게 바뀌었는지를 담은 작은 JSON 봉투가 돌아옵니다. 클라이언트는 대응하는 MobX 옵저버블에 값을 써서 이를 반영합니다.
Linear에서는 모든 모델의 모든 속성이 각자 옵저버블이고, 그것을 읽는 모든 컴포넌트가 observer()로 감싸여 있습니다. 그래서 MobX는 어떤 컴포넌트가 어떤 필드에 의존하는지 정확히 압니다. 이슈 하나의 필드 하나가 바뀌면 그 필드를 읽는 컴포넌트만 정확히 다시 렌더링됩니다. 부모 목록도, 사이드바도 아니고 셀 하나입니다. 이슈 50개가 갱신되면 목록을 한 번 다시 그리는 것이 아니라 셀 50개를 다시 그립니다. 열 명이 동시에 뭔가를 고치는 바쁜 워크스페이스에서도 화면이 매끄럽게 유지되는 이유가 이것입니다. 업데이트를 받는 비용이 화면에 무엇이 떠 있는지가 아니라 무엇이 바뀌었는지에 비례합니다.
저는 주식 시세와 재무 데이터를 실시간으로 흘려보내는 앱을 만들어 봤는데, 개별 컴포넌트를 원자 단위로 갱신하는 것이 앱을 빠르게 느껴지게 하는 핵심이었습니다. 연쇄적으로 번지는 업데이트는 최대한 피해야 하고, Linear가 정확히 그렇게 하고 있습니다.
목록에서 이슈를 수정하면 해당 이슈 행만 다시 그려집니다
세 축이 함께 맞물리는 이유
셋 중 하나만 빼도 앱이 느려지기 시작합니다. 낙관적 쓰기가 없는 로컬 데이터베이스는 저장할 때 여전히 스피너를 돌립니다. 세밀한 옵저버블이 없는 낙관적 쓰기는 업데이트마다 화면이 걸립니다. 로컬 데이터베이스가 없는 세밀한 옵저버블은 첫 로딩을 여전히 기다립니다. Linear의 속도는 어느 한 층의 성질이 아니라 시스템 전체의 성질입니다.
번들러와 로딩 셸은 첫 화면에서 앱을 빠르게 느껴지게 합니다. 동기화 엔진은 쓰기 시작한 뒤에도 그 감각을 유지시킵니다.
속도를 겨냥한 설계
속도는 엔지니어링만의 문제가 아닙니다. 설계의 문제이기도 합니다. 완벽하게 만든 동기화 엔진도 느린 입력 모델 앞에서는 집니다. 어떤 동작에 이르는 가장 빠른 길이 마우스와 메뉴 세 단계, 그리고 클릭을 요구한다면, 밑에서 엔진이 얼마나 빨리 돌든 그 단계의 비용은 사용자가 치릅니다.
Linear 속도의 또 다른 주춧돌은 키보드를 이동과 작업 처리의 주된 도구로 끌어들인 방식입니다. 자주 쓰는 동작에는 모두 단축키가 있습니다. 커맨드 팔레트는 키 하나면 열립니다. 오른쪽 클릭 메뉴도 직접 만들었습니다. 어느 하나도 우연이 아니고, 첫날부터 고민해서 내린 설계 결정입니다.
모든 동작에 단축키가 있다
한 글자로 지금 보고 있는 이슈를 수정합니다. 두 글자 조합으로 화면을 이동합니다. 조합키는 전역에서 동작합니다.
창업자들이 Linear 초기 이야기를 하는 것을 들어 보면, 단축키가 처음부터 토대였던 것이 분명합니다. 동기화 엔진도 어떤 동작이든 언제든 실행할 수 있게 하려는 목적을 일부 담아 설계됐습니다. 설계와 엔지니어링이 이렇게 맞물리는 방식이 지금도 모든 기능 뒤에 깔려 있는 것처럼 보입니다.
UI를 훑어보면 단축키가 곳곳에 드러나 있습니다. 가장 자주 쓰는 동작은 그만큼 많이 쓰이니 한 글자로 배정되어 있습니다. 게다가 모든 동작은 마우스로도 할 수 있어서, 처음 쓰는 사람을 밀어내지 않습니다.
커맨드 팔레트는 항상 키 하나 거리에 있다
⌘ k를 누르면 커맨드 팔레트가 열려서 Linear의 거의 모든 동작을 검색할 수 있습니다. 이슈, 프로젝트, 라벨, 상태 변경, 화면 이동, 이슈 생성, 설정, 테마 전환까지 들어갑니다. 서버가 아니라 로컬 MobX 객체 풀을 검색하기 때문에 대단히 빠릅니다. 다시 말하지만, 네트워크를 피하세요.
구조적으로 얻는 것은 앱 전체를 창 하나에서 다룰 수 있다는 점입니다. 화면 이동이 검색이고, 이슈 생성도 검색이고, 상태 변경은 상태로 범위를 좁힌 검색입니다. 게다가 이 명령창은 맥락을 읽어서 지금 하는 일에 맞춰 바뀝니다. 어떤 화면에서든 주요 동작과 단축키를 익히게 해 주는 좋은 방법입니다. 하나의 기본 요소를 어디서나 쓰고, 그것이 이미 메모리에 있는 데이터 위에서 돌아갑니다.
빠른 앱에는 훌륭한 엔지니어링과 훌륭한 설계가 모두 필요합니다. 완벽한 동기화 엔진과 흠 없는 렌더링 파이프라인을 만들어도, 설계가 잘못되면 여전히 느리게 느껴지는 제품이 나옵니다. 엔지니어링의 속도는 하나의 상호작용을 빠르게 만들고, 설계의 속도는 각 상호작용에 이르는 길을 짧게 만듭니다.
하루 종일 쓰는 도구에서는 단축키와 2초짜리 마우스 경로의 차이가 동작마다 쌓입니다. 단축키와 전역 커맨드 팔레트를 합치면, 쓰는 속도가 대단히 빠른 앱이 됩니다.
애니메이션
여기까지의 노력은 애니메이션을 잘못 넣는 것만으로 도로 무너질 수 있습니다. 팀들은 앱의 모든 부분을 빠르게 만들려고 엄청난 공을 들입니다. 첫 로딩, 업데이트, 데이터베이스 질의까지 전부입니다. 사용자가 기다리지 않도록 밀리초를 깎아 냅니다. 그러고는 맨 마지막 단계에서 누군가 어떤 요소에 500밀리초짜리 높이 애니메이션을 넣습니다.
애니메이션을 걸어도 되는 속성은 몇 개뿐입니다
브라우저의 속성 변경은 세 층으로 나뉘고, 비용은 그 속성이 렌더링 파이프라인에서 얼마나 위에 있는지에 따라 커집니다. 합성(composited) 속성인 transform과 opacity는 작업을 GPU에 넘겨서 메인 스레드와 무관하게 돌아갑니다. 페인트를 유발하는 color, background-color, border-color, fill은 레이아웃은 건너뛰지만 픽셀은 다시 그립니다. 레이아웃을 유발하는 width, height, top, left, margin, padding은 페이지에서 그 뒤에 오는 모든 요소의 위치를 브라우저가 다시 계산하게 만듭니다. 이런 속성에는 절대 애니메이션을 걸지 마세요. 정말로 절대 안 됩니다.
/* Linear가 하는 방식 */
.row:hover {
background-color: var(--color-bg-hover);
transition: background-color 0.12s;
}
.icon-arrow {
transform: translateX(0);
transition: transform 0.15s;
}
/* 잘 모를 때 쓰게 되는 방식 */
.row:hover {
margin-left: 2px; /* 아래에 있는 모든 행의 레이아웃을 다시 계산하게 만듭니다 */
transition: all 0.2s; /* 게다가 이제 margin에 애니메이션이 걸립니다 */
}margin-left를 쓴 쪽은 마우스가 올라간 행 아래의 모든 행 레이아웃을, 전환이 이어지는 200밀리초 동안 매 프레임마다 다시 계산합니다. 긴 이슈 목록에서는 이것이 부드러움과 버벅임을 가르는 차이가 됩니다.
Linear가 앱에서 애니메이션을 거는 속성을 전부 훑어보면 몇 개로 제한되어 있습니다. 대부분 합성 속성인 transform과 opacity이고, 가끔 background-color나 border-color 같은 속성입니다.
참을 때를 알아야 합니다
제 생각에 합성 속성만 애니메이션한다는 원칙만큼 중요한 것은, 아예 애니메이션하지 않을 때를 아는 것입니다. 애니메이션은 신이 나서 과하게 넣기 쉽습니다. 그런데 매일 쓰는 도구에서는, 마케팅 사이트라면 좋아했을 애니메이션이 방해가 되기 시작합니다. 자리를 잘못 잡은 아주 작은 호버 지연조차 사용자 눈에 걸리는 요소가 됩니다.
Linear는 이 부분을 대체로 잘 해냈습니다. 커맨드 팔레트만은 너무 느리다고 주장하고 싶은데, 저도 나이 들면서 까탈스러운 사람이 되긴 했습니다.
목록 항목에는 전환 효과가 없어서 반응이 즉각적입니다
이들의 애니메이션이 잘 작동하는 이유는 대부분 어디서 나왔는지를 가리키기 때문입니다. 상태 팝오버는 상태 알약(pill)에서 커져 나옵니다. 에이전트 패널은 토글 버튼에서 밀려 들어옵니다. 이 움직임은 장식으로 허공에서 서서히 나타나는 것이 아니라, 새 요소가 어디서 왔는지 알려 주는 공간적 역할을 합니다.
지속 시간은 짧고 경쾌하게
/* Linear 스타일시트의 변수 */
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;대부분의 디자인 시스템은 기본값을 필요보다 길게 잡습니다. Material의 표준 지속 시간은 200밀리초이고, iOS의 스프링은 350밀리초에 가깝습니다. 전환 시간을 기본적으로 짧게 잡는 것은 앱을 더 빠르게 느껴지게 하는 가장 손쉬운 방법 중 하나이고, Linear의 기본값은 업계 통념보다 한참 아래에 있습니다.
Linear는 여기서 한 걸음 더 나가, 나타날 때와 사라질 때의 시간을 다르게 잡습니다. 호버 강조와 팝오버, 에이전트 패널은 불러내면 즉시 나타나고, 닫으면 150밀리초에 걸쳐 흐려집니다.
에이전트 창은 즉시 나타나고, 사라질 때는 macOS와 비슷하게 서서히 흐려집니다
덧붙이면, Linear의 디자인 엔지니어인 Emil Kowalski가 animations.dev에서 훌륭한 강의를 만들었습니다. 앞의 두 절이 흥미로웠다면 한번 보시길 권합니다. 애니메이션 원칙을 예제와 실습 위주로 깊이 파고듭니다.
Linear가 이렇게 빠른 이유
Linear를 빠르게 느껴지게 하는 디테일은 여기서 다룬 것 말고도 훨씬 많습니다. 앱을 빠르게 만드는 단 하나의 요인은 없다는 것이 현실입니다. 제대로 내린 수백 개의 결정이 쌓인 결과입니다.
Linear의 접근에서 제가 좋아하는 것은 대부분이 단순하다는 점입니다. Next도, Tanstack도, 화려한 프레임워크도 없습니다. 어떤 구조가 사용자에게 가장 좋을지 일찍 정하고, 그 결정에 충실했습니다. 그 결과로 서버 렌더링 앱보다 빠른, 클라이언트 렌더링 앱이 나왔습니다. 그만한 복잡함도 없이 말입니다.
전체 모양은 대략 이렇습니다. 서버는 진실의 원천이 아니라 동기화 대상입니다. 데이터베이스는 브라우저 안에 있습니다. 변경은 먼저 로컬에 적용되고 백그라운드에서 맞춰집니다. 첫 로딩은 더 적은 코드를 더 여러 조각으로 내려보내고, 사용자가 아직 로그인 화면에 있는 동안 서비스 워커가 나머지를 미리 캐시합니다. 인증은 로컬 상태를 보고 가정한 뒤 나중에 검증합니다. 동기화 엔진은 IndexedDB에서 속성 단위 MobX 옵저버블로 데이터를 올리기 때문에, 이슈 50개가 갱신되면 목록을 다시 그리는 대신 셀 50개를 다시 그립니다. 입력 모델은 키보드가 먼저입니다. 자주 쓰는 모든 동작에 단축키가 있고, 전역 커맨드 팔레트가 있습니다. 애니메이션은 GPU에서 돌고, 지속 시간은 원인과 결과가 이어져 보이는 100밀리초 아래에 머물며, 레이아웃을 유발하는 속성에는 절대 애니메이션을 걸지 않습니다.
어려운 부분은 구현이 아닙니다. 코드베이스가 자라고 넓어지고 새로운 제약에 부딪히는 몇 년 동안 그 장인 정신을 놓지 않는 것이 어렵습니다.
아직 안 써 봤다면 Linear를 직접 열어서 이 모든 것이 어떻게 돌아가는지 보시길 권합니다.