Linear가 체감 성능을 만들어내는 핵심 기법들을 분석합니다. 브라우저 내 데이터베이스, 첫 로드 최적화, 동기화 엔진, 키보드 중심 설계, 애니메이션 원칙까지 살펴봅니다.
Dennis Brotzky2026년 5월 3일
Linear에서 이슈를 업데이트하는 데는 몇 밀리초면 충분합니다. 같은 작업을 하는 전통적인 CRUD 앱은 대략 300ms가 걸립니다. 이들은 어떻게 이런 속도를 낼까요? 성능에는 비밀스러운 만능 해결책이 없습니다. 현실은 올바른 기반 위에 처음부터 구축한 다음, 셀 수 없이 많은 결정으로 다듬어졌다는 것입니다. 이 글의 목표는 Linear가 지금과 같은 느낌을 주는 몇 가지 기법을 따라가 보고, 여러분이 같은 방식을 구현하는 데 도움을 주는 것입니다.
브라우저 안의 데이터베이스
첫 로드를 즉시 느껴지게 만들기
동기화 엔진
속도를 고려한 설계
애니메이션
짧게 면책을 하자면, 저는 Linear에서 일한 적이 없고 그들의 코드를 본 적도 없습니다. 여기서 공유하는 모든 내용은 제 개인적인 경험, 앱을 분석한 결과, 그들의 블로그 글, 그리고 컨퍼런스 발표를 바탕으로 합니다. 저는 그저 웹 앱 만드는 일을 정말 좋아하고, Linear를 베타 출시 때부터 사용해 왔습니다. 그리고 이 글의 히어로 이미지는 Meg Wayne의 영상에서 가져온 것으로, Linear를 위한 그의 작업은 정말 대단합니다.
대부분의 웹 앱은 같은 반복 속에 살아갑니다. 사용자가 클릭합니다. 브라우저가 HTTP 요청을 보냅니다. 서버가 데이터베이스를 조회해 결과를 돌려줍니다. 브라우저가 다시 그립니다. 그 결과는 스피너, 스켈레톤, 혹은 네트워크를 기다리는 동안 몇백 밀리초 동안 멈춘 UI입니다.
Linear는 이 전통적인 관계를 뒤집습니다. UI가 읽는 실제 데이터베이스는 브라우저 안, IndexedDB에 있습니다. 변경은 먼저 로컬에 적용되고, 그 뒤 비동기로 서버에 푸시되며, 서버는 WebSocket을 통해 다른 클라이언트에 델타를 다시 브로드캐스트합니다.
제 생각에 이것이 Linear 성능에서 가장 중요한 요소입니다. 빠른 웹 앱을 만드는 것이 목표라면, 가장 큰 병목은 네트워크입니다. 클라이언트와 서버 사이를 오가는 모든 데이터는 수백 밀리초의 비용을 만듭니다. 가장 좋은 접근은 네트워크 요청 자체가 필요 없게 만드는 것입니다. 그리고 Linear는 정확히 그렇게 합니다.
이 말은 여러 번 반복하게 될 텐데, 놀라운 웹 앱을 만드는 비결은 모든 네트워크 요청을 사용자에게서 숨기는 데 있습니다. 로딩 상태를 더 많이 피할수록 더 좋습니다.
다음은 Linear의 요청이 얼마나 단순한지 보여주는 예시입니다.
// A traditional web app updating the server
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();
}
// vs Linear
issue.title = "Faster app launch";
issue.save();
첫 번째 줄인 issue.title = "Faster app launch"는 메모리 내 데이터 저장소를 업데이트합니다. Linear의 경우 MobX observable입니다. 두 번째 줄인 issue.save();는 그들의 동기화 엔진이 배치하고 서버로 플러시하는 트랜잭션을 큐에 넣습니다. 여기서 핵심은 UI가 로컬의 메모리 내 업데이트를 기반으로 동기적으로 다시 렌더링된다는 점입니다. 기다릴 것이 없으므로 스피너가 없습니다. 데이터는 백그라운드에서 동기화되기 때문입니다. 이것이 각 사용자에게 브라우저를 데이터베이스로 취급하는 마법입니다.
Linear 공동 창업자 중 한 명인 Tuomas는 2024년 한 컨퍼런스에서 이렇게 말했습니다. “정말 문자 그대로 제가 처음 쓴 코드가 동기화 엔진이었어요. 스타트업에서 보통 하는 방식과는 아주 다르죠.” 첫날부터 Linear는 자신들이 어떤 접근을 취하고 싶은지, 그리고 그에 어떤 트레이드오프가 따르는지 알고 있었습니다.
스피너나 지연 없이 이루어지는 Linear의 이슈 생성
대부분의 사람들이 앱을 빠르게 느끼게 하려고 Linear처럼 커스텀 동기화 엔진을 만들지는 않을 것이고, 그럴 필요도 없다는 점을 압니다. 대부분의 경우 Tanstack Query나 SWR 같은 라이브러리의 낙관적 업데이트만으로도 놀라울 만큼 비슷한 수준까지 갈 수 있습니다. 대부분의 웹 앱이 느리게 느껴지는 이유는 UI가 각 네트워크 요청이 끝날 때까지 기다린 후 상태를 업데이트하기 때문입니다. 대부분의 경우 네트워크 요청은 성공하므로, 그 점을 활용해 상태를 낙관적으로 업데이트해야 합니다.
// optimistic mutation with SWR
mutate(
`/api/issues/${issue.id}`,
{ ...issue, title: "Faster app launch" },
false
);
// vs Linear
issue.title = "Faster app launch";
issue.save();
핵심 아이디어는 단순합니다. UI의 반응성은 네트워크 지연 시간에 의존해서는 안 됩니다. 사용자가 체감하는 속도는 서버가 얼마나 빨리 응답하느냐가 아니라, 인터페이스가 얼마나 빨리 반응하느냐에 달려 있습니다.
낙관적 요청은 여러분이 할 수 있는 가장 효과적인 개선 중 하나입니다.
불필요한 스피너 제거
즉시 상태 업데이트
백그라운드에서 검증
필요할 때만 롤백
Linear의 기반은 정확히 이 원칙 위에 세워져 있고, 그래서 앱이 네이티브 같고 빠르게 느껴집니다.
Linear는 찾을 수 있는 가장 단순한 스택 위에 만들어졌습니다. React, TypeScript, MobX, Postgres, CDN. 엣지 데이터베이스도 없고, React Server Components도 없고, 화려한 프레임워크도 없습니다.
Frontend
React + react-dom (UI runtime)
MobX (observable graph, granular re-renders)
TypeScript (single language end-to-end)
Rolldown-Vite + plugin-react-oxc(mid-2025; previously Rollup; previously Parcel)
ProseMirror + y-prosemirror (rich text editor; Yjs CRDT for live collab)
Radix UI primitives (popovers, menus, focus traps)
Emotion + StyleX (Emotion runtime + StyleX compiled to atomic CSS)
Comlink (Worker RPC)
idb (IndexedDB wrapper backing the local-first store)
graphql-request (GraphQL transport to the sync server)
Sentry (error monitoring)
Inter Variable (single woff2, font-display: swap)
Backend
Node.js + TypeScript (single language for all server code)
PostgreSQL on Cloud SQL (issues table partitioned 300 ways)
Memorystore Redis (event bus + cache + sync cursors)
turbopuffer (similar-issue detection, vector db)
Kubernetes on GCP (one workload per concern)
Cloudflare Workers (multi-region edge proxy)
Other clients
Desktop: Electron (same web JS, native chrome)
Mobile: Swift (iOS) + Kotlin (a separate full reimplementation)
Marketing
Next.js (static)
styled-components
Inline SVG sprite
제게 가장 눈에 띄는 점은 클라이언트 사이드 렌더링을 고수하기로 한 결정입니다. CSR은 종종 느린 초기 로드 때문에 비판받지만, 올바른 아키텍처와 설계를 갖추면 즉시 뜨는 것처럼 느껴질 수 있습니다.
저는 이 방식이 가져오는 단순함도 정말 좋아합니다. 앱을 완전히 클라이언트 사이드로 유지하면 훨씬 더 깔끔한 멘탈 모델이 생기고, 서버 렌더링 앱이 수반하는 많은 복잡성이 사라집니다. 지금 내가 서버에 있는지 클라이언트에 있는지 계속 생각할 필요가 없습니다. window 객체에 접근 가능한지 아닌지 따질 필요도 없습니다. 올바른 캐시 헤더를 설정하고 있는지 고민할 필요도 없습니다. 단순함과, 그 단순함이 강제하는 제약에는 아름다움이 있습니다.
그렇다면 Linear는 어떻게 클라이언트 사이드 렌더링 앱을 즉시 뜨는 것처럼 느끼게 만들까요?
제가 집착하는 것 중 하나가 첫 로드인데, Linear도 분명히 그렇습니다. 특히 생산성 도구에서는 실제로 작업을 시작할 수 있기까지 걸리는 시간이 가장 중요한 디테일 중 하나입니다. 새 탭이 몇 초씩 로드되기를 원하는 사람은 없습니다.
먼저 초기 로드를 느리게 만드는 원인을 이해해야 합니다. 클라이언트 사이드 앱에서는 index.html을 요청하고, 그것이 다시 모든 JavaScript와 CSS를 요청하며, 그 다음 일종의 인증을 수행하고, 마지막으로 앱을 보여주기 위한 API 요청을 합니다.
앱을 즉시 느껴지게 만드는 첫 단계는 런타임보다 훨씬 이전에 시작됩니다. 빌드 타임입니다. 기억하세요. 병목은 네트워크이므로, 가능한 한 적은 양의 JavaScript와 CSS를 전달하는 것이 빠른 로드 시간에 결정적입니다.
제가 파악한 바로는 Linear는 빌드 파이프라인을 네 번 다시 썼습니다. Parcel → Rollup → Vite → Rolldown. 각 마이그레이션은 같은 목표에서 출발했습니다. JavaScript와 CSS 양을 줄이고 개발자 경험을 개선하는 것입니다.
그들의 블로그 글에 따르면 다음과 같은 결과가 있었습니다.
전달되는 코드 50% 감소
압축 후 30% 더 작아짐
콜드 캐시 페이지 로드 10~30% 빨라짐
활성 이슈 뷰의 first paint 시간 59% 감소(Safari에서)
메모리 사용량 70~80% 감소
이 대부분은 현대 브라우저만 타깃팅하는 결정, 더 나은 dead-code elimination, 공격적인 코드 분할의 조합에서 나왔습니다. 레거시 지원 제거가 가장 큰 승리입니다. 폴리필도 없고, ES5 트랜스파일링도 없고, nomodule 폴백도 없습니다. 하지만 dead-code 제거와 청킹 작업도 그만큼 중요합니다.
이 모든 최적화에도 불구하고 Linear는 여전히 상당한 양의 코드를 전달합니다. 축소된 JavaScript 기준으로 대략 21 MB 정도입니다. 차이는 이것이 수백 개의 라우트 수준 청크로 공격적으로 분할되어 필요할 때만 가져와진다는 점입니다.
// vite.config.ts (reconstruction; matches observed chunk graph)
export default defineConfig({
plugins: [react()],
build: {
target: "esnext", // no legacy syntax, no polyfills
cssMinify: "lightningcss",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
// One chunk per npm package > ~3 KB. Cache invalidation
// becomes per-library instead of per-app-revision.
manualChunks(id) {
if (id.includes("node_modules")) {
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg) return `vendor-${pkg}`;
}
},
},
},
},
});
교훈은 어떤 번들러를 고를지가 아니라, 레거시 브라우저를 버리고, 네이티브 ESM으로 가고, 미친 듯이 코드 분할하는 것의 중요성입니다. 각각의 단계는 작습니다. 하지만 쌓이면 Linear의 첫 로드 JavaScript를 대략 절반으로 줄이고, 빌드 시간도 한 자릿수 배수 수준으로 줄였습니다.
즉, 즉시 느껴지는 로드 시간의 첫 번째 비결은 사용자에게 무언가를 렌더링하는 데 필요한 JavaScript와 CSS 양을 줄이는 것입니다.
JavaScript를 가능한 한 작은 청크로 분할하고 나면, 이제 백그라운드에서 일을 시작할 수 있습니다.
그런데 잠깐, 번들을 수백 개의 청크로 나누면 새로운 문제가 생깁니다. 각 청크는 다른 청크를 import하고, 브라우저는 엔트리 스크립트를 파싱하기 전까지 그것들이 무엇인지 모릅니다. 도움이 없으면 로드 타임라인은 워터폴이 됩니다. 엔트리를 가져오고, 파싱하고, 그 import를 가져오고, 다시 파싱하고, 또 그 import를 가져오는 식입니다. 단계가 하나씩 늘 때마다 네트워크 왕복이 추가되고, 이것은 어떤 대가를 치르더라도 피하고 싶습니다.
Linear가 하는 일은 어떤 JavaScript도 실행되기 전에 브라우저가 전체 목록을 보고 요청을 병렬로 날리게 하는 것입니다. 엔트리 스크립트가 첫 번째 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">
<!-- ...around many more -->
각 preload의 crossorigin 속성은 엔트리 스크립트의 crossorigin과 일치하므로, 브라우저는 preload와 import를 별개의 리소스로 취급하지 않고 캐시된 fetch를 재사용합니다. 폰트 preload에서 쓰는 것과 같은 트릭을, 크리티컬 패스 위의 모든 청크에 적용한 셈입니다.
콜드 로드 타임라인은 순차적 워터폴에서 하나의 병렬 배치로 무너집니다. 네트워크가 해야 할 일 자체가 사라지는 것은 아닙니다. 다만 그것을 한꺼번에 처리합니다. 이 기법의 아름다운 점은 사용자가 처음 로그인 페이지에 도달했을 때 백그라운드에서 이 모든 일을 할 수 있다는 것입니다. 몇 초 안에 전체 앱이 캐시에 저장되고 즉시 제공됩니다.
사람들이 여러분의 앱을 어떻게 사용할지 이해하는 것은 매우 중요합니다. 이 이해가 생기면, Linear처럼 백그라운드 스크립트 프리로딩 같은 방식으로 이를 유리하게 활용할 수 있습니다.
Linear의 나머지 부분, 즉 사용자가 아직 방문하지 않은 뷰의 라우트 수준 청크는 서비스 워커가 백그라운드에서 캐시합니다. 이 워커는 소스 안에 precache manifest를 내장하고 있으며, 약 1,200개의 해시된 에셋으로 구성되어 있습니다. 여기에는 라우트 청크, 아이콘, 폰트가 포함되며, 첫 페이지 로드 이후 이것들을 지연해서 내려받습니다. 로그인 화면에 도달한 지 몇 초 안에 전체 앱이 캐시에 들어앉습니다.
캐시에서 즉시 로드되도록 모든 청크된 JavaScript 파일을 프리로딩하기
이것은 두 가지 이점을 줍니다. 이후 내비게이션은 네트워크를 완전히 건너뜁니다. 서비스 워커가 HTTP 캐시를 거치지도 않고 자신의 캐시에서 바로 응답합니다. 그리고 네트워크가 없을 때도 앱이 계속 동작합니다. 로컬 우선 동기화 엔진과 결합되면, 이미 사용자 데이터가 IndexedDB에 있기 때문에 Linear는 오프라인에서도 사용 가능합니다. 이슈를 읽고, 새로 만들고, 제목과 설명을 편집하고, 상태를 바꿀 수 있습니다. 모든 것은 로컬 트랜잭션 저장소에 큐잉되었다가 연결이 돌아오면 다음에 플러시됩니다.
modulepreload는 앱이 지금 필요한 것을 위한 것입니다. 브라우저가 직렬 import 체인에 막히지 않도록 병렬로 가져옵니다. 서비스 워커는 앱이 다음에 필요로 할 것을 위한 것입니다.
즉, 로드 시간을 빠르게 만들기 위해 Linear가 밟는 단계는 가능한 한 많은 코드를 제거하고, 그것을 작은 조각으로 나누고, 백그라운드에서 precache하는 것입니다. 다시 말하지만, 이 모든 작업의 목표는 네트워크 요청을 가능한 한 빠르게 만들거나, 더 나아가 완전히 없애는 것입니다.
제가 흥미롭게 본 점은 Linear가 사용하는 모든 패키지가 각자 독립적인 청크를 가진다는 점입니다. 전통적인 vendor.js는 어떤 의존성이든 하나만 올라가도 전체 의존성 그래프를 무효화합니다. Linear의 청킹은 벤더 캐싱을 하나의 거대한 파일에서 세분화된 캐싱으로 바꿉니다. 단일 의존성을 올리면 하나의 청크만 무효화되고, 나머지는 캐시에 남습니다.
정말 당연해 보이지만, 빠른 로드 시간을 보장하기 위한 또 하나의 디테일입니다.

각 개별 패키지가 각자의 js 파일로 분리됨
폰트 로딩은 많은 앱이 잘못 처리하는 디테일 중 하나입니다. 실패 양상도 눈에 띕니다. 반 초 동안 보이지 않는 텍스트, 실제 폰트가 교체되며 발생하는 레이아웃 이동, preload가 맞지 않아 두 번 가져오는 리소스 같은 것들입니다. Linear의 설정은 이 셋을 모두 피합니다.
<!-- in <head> of index.html -->
<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");
}
/* Italic and Berkeley Mono follow the same shape, single woff2 each. */
가변 폰트는 100–900의 전체 굵기 축을 하나의 woff2로 커버하므로, 굵기별 요청을 없애 줍니다. font-display: swap은 우선 fallback 스택으로 즉시 렌더링하고, Inter가 로드되면 교체합니다. 놓치기 쉬운 핵심은 preload 태그의 crossorigin="anonymous"입니다. 이것이 없으면 브라우저는 폰트를 preload한 뒤 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 외에도 초기 경험을 로드하는 데 중요한 인라인 JavaScript가 많이 있습니다.
<script>
// Electron context — lets CSS branch on native chrome.
if (navigator.userAgent.includes("Electron") && navigator.userAgent.includes("Linear")) document.documentElement.classList.add("electron");
// No local store → no workspace data → render the auth layout.
if (localStorage.getItem("ApplicationStore") === null) document.documentElement.classList.add("logged-out");
// Restore last-known shell tokens (sidebar bg, width, dark mode) before paint.
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");
// Compact sidebar to a sliver when the user opens links in the desktop app.
if (JSON.parse(localStorage.getItem("userSettings") || "{}").openLinksInDesktop) document.documentElement.style.setProperty("--sidebar-width", "8px");
</script>
어떤 번들도 파싱되기 전에, index.html의 JavaScript는 localStorage.splashScreenConfig를 읽고, 그 위에 sessionStorage 오버라이드를 병합한 다음, 사용자가 기억된 셸 토큰을 document.documentElement.style에 직접 적용합니다. 사이드바 배경, 기본 색상, 테두리 색상, 사이드바 너비, 에이전트 툴바 높이 같은 것들입니다. 색상 스킴 선호와 Electron 컨텍스트도 감지합니다. localStorage.ApplicationStore가 존재하는지 확인하고, 없으면 셸을 인증 레이아웃으로 전환하는 logged-out 클래스를 추가합니다.
네트워크에서 첫 JavaScript 번들이 오기 전까지 로딩 화면은 이미 올바른 테마, 크기, 위치를 갖추고 있으며, 사용자가 로그인 상태인지 여부도 반영되어 있습니다.
이것은 사용자가 URL 바에서 엔터를 누르자마자 앱이 이미 준비된 듯한 느낌을 줍니다. 이것보다 더 빠른 방법은 초기 index.html 응답 안에 초기 앱 셸을 함께 보내는 것뿐입니다.
Linear의 초기 로드가 얼마나 빠른지 보여주는 예시
인증은 대부분의 앱이 성능 예산을 포기하는 또 다른 단계입니다. 일반적인 흐름은 이렇습니다. HTML을 가져오고, 번들을 로드하고, 세션을 검증하고, 사용자를 가져오고, 워크스페이스를 가져온 뒤 렌더링합니다. 사용자가 무언가를 보기까지 1초에서 3초가 걸립니다.
Linear는 인증도 변경 처리와 같은 방식으로 다룹니다. 행복한 경로를 가정하고 백그라운드에서 검증합니다. 이것은 제게 그들의 아키텍처에서 가장 마음에 드는 부분 중 하나인데, 로드 시 거의 즉시 전체 경험을 렌더링할 수 있게 해주기 때문입니다.
대부분의 CRUD 앱은 실제 세션을 HttpOnly 쿠키에 유지한 뒤, 프런트엔드가 시작 시 사용자가 로그인 상태인지 알 수 있도록 JS에서 읽을 수 있는 두 번째 쿠키나 /me 요청을 추가합니다. Linear는 더 단순한 방식을 씁니다. 별도의 인증 신호를 유지하는 대신, 인라인 부트 스크립트는 localStorage.ApplicationStore가 있는지만 확인합니다.
if (localStorage.getItem("ApplicationStore") === null) {
document.documentElement.classList.add("logged-out");
}
그 값이 있다면, 사용자는 이전에 이 브라우저에서 Linear를 사용한 적이 있다는 뜻이고, 따라서 그들의 워크스페이스는 이미 IndexedDB 안에 들어 있습니다. 이것은 앞에서 다룬, 데이터베이스가 브라우저 안에 있다는 이야기로 다시 돌아갑니다. 그 값이 없다면 어차피 렌더링할 것도 없으므로, 셸은 로그아웃 레이아웃으로 전환되고 로그인 흐름이 이어집니다.
Linear의 초기 흐름은 “유효한 세션이 있나요?”가 아닙니다. “보여줄 것이 있나요?”입니다. 실제 세션 토큰은 쿠키에 있습니다. 번들은 그것에 대해 똑똑하게 굴려고 하지 않습니다. 그저 자신이 가진 것을 렌더링하고, 다음 요청(WebSocket 핸드셰이크, sync delta, 어떤 HTTP 호출이든)이 세션이 만료되었을 경우 401로 실패하게 둡니다. 그런 일이 생기면 클라이언트는 로그인으로 리다이렉트합니다.
이 전체 패턴은 나머지 아키텍처와도 일관됩니다. 클라이언트는 로컬에 있는 것을 신뢰하고, 서버는 정확성의 진실 원천이며, 둘은 비동기로 조정됩니다. 변경 처리와 똑같습니다. 동기화 엔진과도 똑같습니다.
인증 세션을 수동으로 삭제하고 데스크톱 앱을 새로고침하는 모습
이것은 아마도 제가 더 많은 앱이 이렇게 동작했으면 하는 Linear의 디테일 중 하나입니다. 인증에서는 행복한 경로를 가정하고, 아니라면 나중에 처리하세요. 보여줄 데이터가 있다면 보여 주세요. 그리고 즉시 렌더링하기 위해 브라우저의 데이터 저장소를 활용하세요.
Linear를 빠르게 만드는 대부분의 것은 하나의 결정에서 파생됩니다. 서버는 UI의 진실 원천이 아니라 동기화 대상이라는 점입니다. 그들의 동기화 엔진 내부 구조는 이미 매우 철저하게 리버스 엔지니어링되어 있고, Tuomas도 이 아키텍처에 대해 훌륭한 발표를 여러 번 했습니다. 저는 그것을 다시 추적하지는 않겠습니다. 대신 실제로 속도를 만들어내는 세 개의 기둥을 짚고 싶습니다. 속도는 어느 한 요소 단독의 성질이 아니라, 그것들이 서로 맞물리는 방식의 성질이기 때문입니다.
앱이 부팅될 때, 서버에서 워크스페이스를 가져오지 않습니다. IndexedDB에서 메모리 내 MobX 객체 풀로 hydrate하고, UI의 모든 쿼리는 먼저 이 풀로 갑니다. “이슈 로딩 중” 상태가 없는 이유는, 이슈가 이미 사용자의 기기에 있기 때문입니다.
제가 흥미롭게 본 점은, 규모가 커지면서 그들이 동기화 엔진의 데이터를 JavaScript 번들과 같은 기본 원리로 청크화했다는 점입니다. 모든 것을 한 번에 가져오지 않습니다. 가장 무거운 두 테이블인 Issue와 Comment는 필요할 때 지연 hydrate합니다. 이것은 데이터 수준의 코드 분할이며, 엔진이 확장될 수 있게 해 주는 요소입니다. 시작 비용은 워크스페이스 크기가 아니라 워크스페이스 구조를 따라갑니다. 이슈 10,000개가 있는 워크스페이스도 이슈 100개짜리와 거의 비슷한 속도로 부팅됩니다.
프로젝트를 클릭하면 이슈가 이미 있습니다. 담당자별로 필터링하면 인덱스가 이미 만들어져 있습니다. 가져올 것이 없습니다. 빠진 것이 없기 때문입니다. 브라우저에서 즉시 로드되었거나, 얼마 지나지 않아 코드 분할된 지연 청크로 들어오기 때문입니다.

IndexedDB: 데이터베이스는 여러분의 브라우저 안에 있다
이슈 상태를 바꾸면 거의 동시에 세 가지 일이 일어납니다. MobX observable이 업데이트되어 UI가 변화를 반영하고, 변경은 IndexedDB의 영속적인 트랜잭션 큐에 기록되며, 서버 전송 대기열에도 들어갑니다. 네트워크는 아직 건드려지지 않았습니다.
사용자는 자신의 변경 사항을 보기 위해 기다리지 않습니다. 재시도, 롤백, 새로고침을 넘어서는 내구성은 모두 백그라운드에서 처리됩니다. 서버가 거부하면 observable은 원래 상태로 되돌아가고 잠깐의 깜빡임이 생기지만, 실제로는 대부분의 잘못된 변경이 트랜잭션이 만들어지기도 전에 잡히기 때문에 이런 일은 거의 없습니다.
제가 계속 말하듯, 네트워크는 적이며 여러분은 그것을 피하기 위해 할 수 있는 모든 것을 해야 합니다. Linear의 흐름은 로컬 변경에서 시작하고, 서버를 허가 단계가 아니라 확인 단계로 취급합니다.
서버가 변경을 확인하면, 그것이 내 변경이든 다른 사람의 변경이든, 바뀐 내용을 설명하는 작은 JSON envelope가 돌아옵니다. 클라이언트는 해당 MobX observable에 기록함으로써 이를 적용합니다.
Linear에서는 모든 모델의 모든 속성이 각각 독립된 observable이고, 그것을 읽는 모든 컴포넌트는 observer()로 감싸져 있기 때문에, MobX는 어떤 컴포넌트가 어떤 필드에 의존하는지 정확히 압니다. 하나의 이슈에서 하나의 필드가 바뀌면, 그 필드를 읽는 컴포넌트만 다시 렌더링됩니다. 부모 리스트도 아니고, 사이드바도 아니고, 하나의 셀입니다. 이슈 50개 업데이트는 리스트 한 번의 재렌더가 아니라 셀 50개의 재렌더입니다. 이것이 열 명이 동시에 편집하는 바쁜 워크스페이스에서도 부드러움을 유지하게 해 주는 이유입니다. 업데이트 수신 비용이 화면에 무엇이 있느냐가 아니라 무엇이 바뀌었느냐에 따라 확장되기 때문입니다.
저도 주식 데이터와 펀더멘털이 실시간으로 흘러들어오는 앱을 만들어 본 적이 있는데, 개별 컴포넌트의 원자적 업데이트는 앱을 성능 좋게 느끼게 만드는 핵심입니다. 가능한 한 연쇄적인 업데이트를 피하고 싶고, Linear는 정확히 그렇게 합니다.
리스트에서 이슈를 업데이트할 때 단일 이슈 행만 다시 렌더링됨
이 중 하나만 빠져도 앱은 느리게 느껴지기 시작합니다. 로컬 데이터베이스가 있어도 낙관적 쓰기가 없으면 저장 시 여전히 스피너가 돕니다. 낙관적 쓰기가 있어도 세밀한 observable이 없으면 매 업데이트마다 버벅입니다. 세밀한 observable이 있어도 로컬 데이터베이스가 없으면 초기 로드를 기다려야 합니다. Linear의 속도는 어느 한 레이어의 속성이 아닙니다. 시스템 전체의 속성입니다.
번들러와 로더 셸은 첫 번째 페인트에서 앱을 빠르게 느끼게 만듭니다. 동기화 엔진은 실제로 사용하기 시작한 뒤에도 계속 빠르게 느껴지게 만듭니다.
속도는 단지 엔지니어링 문제만이 아닙니다. 설계 문제이기도 합니다. 완벽하게 만들어진 동기화 엔진도 느린 입력 모델 앞에서는 집니다. 어떤 동작으로 가는 가장 빠른 경로가 마우스, 세 개의 메뉴, 그리고 클릭을 필요로 한다면, 그 아래 엔진이 얼마나 빨라도 사용자는 그 단계를 모두 치러야 합니다.
Linear 속도의 또 다른 초석은 키보드를 작업을 탐색하고 완료하는 주된 도구로 통합한 방식입니다. 모든 흔한 동작에는 단축키가 있습니다. 커맨드 팔레트는 키 한 번이면 열립니다. 우클릭 메뉴는 커스텀으로 만들어졌습니다. 이 모든 것은 우연이 아니라 첫날부터의 의도적인 설계 결정입니다.
단일 글자 키는 포커스된 이슈를 편집합니다. 두 글자 조합은 탐색을 담당합니다. modifier 키는 전역적으로 작동합니다.
창업자들이 Linear 초창기를 이야기하는 것을 들어보면, 단축키가 처음부터 기반 요소였다는 것이 분명합니다. 동기화 엔진도 어떤 동작이든 언제든 수행될 수 있도록 설계되었습니다. 설계와 엔지니어링의 이 조합이 지금도 모든 기능 뒤에 계속 깔려 있는 것처럼 느껴집니다.
UI를 둘러보면 곳곳에 단축키가 노출되어 있는 것을 볼 수 있습니다. 가장 자주 쓰는 것들은 단일 문자입니다. 가장 자주 쓰이기 때문입니다. 게다가 초보자를 소외시키지 않도록 모든 동작은 마우스로도 할 수 있습니다.






⌘ k는 사용자가 Linear의 거의 모든 동작을 검색할 수 있는 커맨드 팔레트를 엽니다. 이슈, 프로젝트, 라벨, 상태 변경, 탐색, 이슈 생성, 설정, 테마 토글까지 포함됩니다. 이 명령이 믿을 수 없을 만큼 빠른 이유는 서버가 아니라 로컬의 MobX 객체 풀을 검색하기 때문입니다. 기억하세요. 네트워크를 피해야 합니다.
아키텍처 차원에서의 보상은 전체 앱이 하나의 패널에서 접근 가능해진다는 점입니다. 내비게이션은 검색입니다. 이슈 생성도 검색입니다. 상태 변경은 상태 범위로 제한된 검색입니다. 게다가 이 명령은 맥락을 이해하고, 지금 작업 중인 대상에 맞춰 적응합니다. 어떤 뷰에서든 핵심 동작과 단축키를 가르치는 훌륭한 방법이기도 합니다. 하나의 기본 요소를, 메모리에 이미 있는 데이터를 바탕으로, 어디에나 쓰는 것입니다.
빠른 앱에는 뛰어난 엔지니어링과 설계가 모두 필요합니다. 완벽한 동기화 엔진과 흠잡을 데 없는 렌더링 파이프라인을 만들고도, 설계가 잘못되면 느리게 느껴지는 것을 내놓을 수 있습니다. 엔지니어링 속도는 개별 상호작용 하나를 빠르게 만듭니다. 설계 속도는 그 상호작용에 이르는 경로 자체를 짧게 만듭니다.
하루 종일 쓰는 도구에서는, 단축키와 2초짜리 마우스 경로의 차이가 모든 동작에 누적됩니다. 단축키와 전역 커맨드 팔레트를 결합하면, 사용하기 엄청나게 빠른 앱이 됩니다.
지금까지의 모든 노력도 나쁜 애니메이션 하나로 무너질 수 있습니다. 팀은 앱의 모든 부분을 빠르게 만들기 위해 엄청난 노력을 들입니다. 초기 로드, 업데이트, 데이터베이스 쿼리, 전부 그렇습니다. 사용자가 기다릴 필요가 없도록 몇 밀리초씩 깎아 냅니다. 그런데 마지막 단계에서 누군가 어떤 요소에 500ms 높이 애니메이션을 넣어 버립니다.
브라우저에는 속성 변화의 세 가지 계층이 있고, 비용은 렌더링 파이프라인에서 그것이 얼마나 위에 있느냐에 따라 커집니다. 합성 단계에서 처리되는 속성인 transform, opacity는 작업을 GPU에 넘기고 메인 스레드와 독립적으로 실행됩니다. 페인트를 유발하는 속성인 color, background-color, border-color, fill은 레이아웃은 건너뛰지만 여전히 픽셀을 다시 그립니다. 레이아웃을 유발하는 속성인 width, height, top, left, margin, padding은 페이지에서 그 뒤에 오는 모든 요소의 위치를 브라우저가 다시 계산하게 만듭니다. 이런 것들은 절대 애니메이션하지 마세요. 정말 절대입니다.
/* What Linear does */
.row:hover {
background-color: var(--color-bg-hover);
transition: background-color 0.12s;
}
.icon-arrow {
transform: translateX(0);
transition: transform 0.15s;
}
/* What you'd write if you didn't know better */
.row:hover {
margin-left: 2px; /* triggers layout for every row beneath */
transition: all 0.2s; /* and now you're animating margin */
}
margin-left 버전은 호버된 행 아래의 모든 행에 대해, 전환 200ms 전체 동안, 매 프레임 레이아웃을 다시 계산합니다. 긴 이슈 리스트에서는 이것이 버터처럼 부드러운 경험과 버벅임의 차이를 만듭니다.
Linear 앱에서 실제로 애니메이션되는 모든 속성을 하나하나 보면, 대부분 transform과 opacity 같은 합성 속성에 한정되고, 가끔 background-color와 border-color 같은 속성이 쓰일 뿐입니다.
제 생각에, 합성 속성만 애니메이션하는 것만큼이나 중요한 것은 아예 애니메이션하지 말아야 할 때를 아는 것입니다. 애니메이션은 쉽게 과해질 수 있습니다. 하지만 매일 쓰는 도구에서는, 마케팅 사이트에서는 멋져 보이는 애니메이션이 오히려 방해가 되기 시작합니다. 작은 호버 지연조차도 잘못된 위치에 있으면 사용자가 가장 먼저 알아차리는 것이 됩니다.
Linear는 이 부분 대부분을 잘 해냅니다. 제가 느끼기에 커맨드 팔레트만은 조금 느린 편인데, 제가 세월이 지나며 까다로운 사람이 되어 버린 탓일 수도 있습니다.
빠릿함을 유지하기 위해 리스트 아이템에는 전환이 없다
그들의 많은 애니메이션이 잘 작동하는 이유는 출발점을 참조하기 때문입니다. 상태 popover는 상태 pill에서부터 커집니다. 에이전트 패널은 토글 위치에서 미끄러져 들어옵니다. 이 움직임은 장식처럼 아무 데서나 페이드인하는 것이 아니라, 새 요소가 어디서 왔는지를 사용자에게 알려 주는 공간적 역할을 합니다.
/* variables form Linear's stylesheet */
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;
대부분의 디자인 시스템은 기본값이 필요 이상으로 깁니다. Material의 표준 지속 시간은 200ms이고, iOS의 spring은 350ms에 더 가깝습니다. 더 짧은 전환을 기본값으로 두는 것은 앱을 더 빠르게 느끼게 만드는 가장 쉬운 방법 중 하나이며, Linear의 기본값은 업계 표준보다 훨씬 짧습니다.
Linear는 여기에 한 걸음 더 나아가 진입과 이탈에 비대칭 타이밍을 적용합니다. 호버 하이라이트, popover, 에이전트 패널은 호출하는 순간 즉시 나타나고, 닫을 때는 150ms에 걸쳐 사라집니다.
에이전트 창은 즉시 나타나지만 macOS처럼 페이드아웃된다
Linear가 빠르게 느껴지게 만드는 디테일은 이보다 훨씬 더 많이 다룰 수 있습니다. 현실은 앱을 성능 좋게 만드는 단 하나의 요소는 없다는 것입니다. 수백 가지 결정을 올바르게 내린 결과가 쌓여서 만들어집니다.
제가 Linear의 접근에서 좋아하는 점은 대부분이 무척 단순하다는 것입니다. Next도 없고, Tanstack도 없고, 화려한 프레임워크도 없습니다. 그들은 일찍부터 어떤 아키텍처가 사용자에게 가장 잘 맞을지 결정했고, 그 결정에 충실하게 남아 있었습니다. 그 결과는 서버 렌더링 앱보다 더 빠른 클라이언트 사이드 렌더링 앱입니다. 그것도 복잡성 없이 말입니다.
전체 모양은 대략 이렇습니다. 서버는 진실 원천이 아니라 동기화 대상입니다. 데이터베이스는 브라우저 안에 있습니다. 변경은 먼저 로컬에 적용되고 백그라운드에서 조정됩니다. 첫 로드에서는 더 적은 코드를 더 많은 조각으로 보내고, 서비스 워커는 사용자가 아직 로그인 페이지에 있는 동안 나머지를 precache합니다. 인증은 상태를 기반으로 일단 있다고 가정하고 나중에 검증합니다. 동기화 엔진은 IndexedDB에서 속성별 MobX observable로 hydrate하므로, 이슈 50개 업데이트는 리스트 한 번의 재렌더가 아니라 셀 50개의 재렌더가 됩니다. 입력 모델은 키보드 우선입니다. 모든 흔한 동작에 단축키가 있고 전역 커맨드 팔레트가 있습니다. 애니메이션은 GPU 위에 머무르고, 지속 시간은 100ms의 인과 체감 임계값 아래에 있으며, 레이아웃을 유발하는 속성은 절대 애니메이션하지 않습니다.
어려운 부분은 구현 자체가 아닙니다. 코드베이스가 성숙하고, 확장되고, 새로운 제약에 부딪히는 여러 해 동안 이 공예에 헌신하는 것입니다.
아직 써보지 않았다면, 모든 것이 실제로 어떻게 동작하는지 보기 위해 Linear를 확인해 보길 권합니다.
한두 가지라도 배웠기를 바랍니다! 이 글을 쓰고, Linear를 Linear답게 만드는 디테일에 깊이 들어가 보는 과정이 즐거웠습니다. 저는 그저 세상에서 가장 좋은 웹 앱을 만드는 일을 사랑하고, 다른 사람들이 그것을 어떻게 하는지 보는 것도 좋아합니다. 피드백이나 제안이 있거나, 연결되고 싶다면 X에서 저를 찾아주세요.