Metadata
<think>번역</think>
# Linear는 어떻게 이렇게 빠를까? 기술적 분석

Linear에서 이슈 하나를 업데이트하는 데 걸리는 시간은 몇 밀리초에 불과하다. 같은 작업을 전통적인 CRUD 앱에서 하면 대략 300ms 정도가 걸린다. 어떻게 이런 차이가 날까? 성능에는 비밀 병기 같은 건 없다. 실제로는 올바른 기반 위에 처음부터 쌓아 올리고, 그 위에 수없이 많은 결정을 더해 개선한 결과다. 이 글에서는 Linear가 지금과 같은 사용감을 만들어 내는 몇 가지 기법을 살펴보고, 여러분도 비슷한 방식을 구현할 수 있도록 돕고자 한다.
## 다룰 내용
- 브라우저 안의 데이터베이스(database)
- 첫 로딩을 즉시 뜨는 것처럼 느끼게 만드는 방법
- 동기화 엔진(sync engine)
- 속도를 고려한 설계
- 애니메이션(animations)
먼저 짧게 밝혀 두자면, 나는 [Linear](https://linear.app/)에서 일한 적이 없고 그들의 코드를 본 적도 없다. 여기서 공유하는 내용은 전부 내 개인적인 경험, 앱을 관찰하며 분석한 결과, 그들의 블로그 글, 그리고 컨퍼런스 발표를 바탕으로 한 것이다. 나는 그저 웹 앱을 만드는 일을 좋아하고, 베타 출시 때부터 Linear를 써 왔다. 참고로 이 글의 대표 이미지는 [Meg Wayne](https://x.com/megxwayne)의 영상에서 가져왔는데, Linear를 위해 한 작업이 정말 뛰어나다.
## 첫 로딩을 즉시 뜨는 것처럼 느끼게 만들기
내가 집착하는 것 중 하나가 첫 로딩인데, Linear도 분명 여기에 집착하고 있다. 특히 생산성 도구에서는 실제로 일을 시작할 수 있기까지 걸리는 시간이 가장 중요한 디테일 중 하나다. 새 탭을 열었는데 몇 초씩 기다리고 싶은 사람은 없다.
먼저 초기 로딩을 느리게 만드는 요인을 이해해야 한다. 클라이언트 사이드 앱은 `index.html`을 요청하고, 그 파일이 다시 자바스크립트(JavaScript)와 CSS를 요청한다. 그런 다음 인증(authentication) 같은 과정을 거치고, 마지막으로 앱을 보여 주기 위한 API 요청을 보낸다.
### Linear의 번들러(bundler) 여정: Parcel, Rollup, Vite, Rolldown
앱을 즉각적으로 느끼게 만드는 첫 단계는 런타임(runtime)보다 훨씬 이전에 시작된다. 빌드 타임(build time)이다. 다시 말하지만 병목은 네트워크이므로, 가능한 한 적은 양의 자바스크립트와 CSS를 내려보내는 것이 빠른 로딩의 핵심이다.
내가 파악한 바로는 Linear는 빌드 파이프라인(build pipeline)을 네 번 다시 썼다. Parcel → Rollup → Vite → Rolldown 순서다. 각 마이그레이션(migration)의 목적은 하나였다. 전송하는 자바스크립트와 CSS의 양을 줄이고, 개발자 경험(developer experience)을 개선하는 것.
그들의 블로그 글에 따르면 결과는 이렇다.
- 전송 코드량 50% 감소
- 압축 후 크기 30% 감소
- 콜드 캐시(cold-cache) 페이지 로드 10~30% 빨라짐
- 활성 이슈(active-issues) 뷰의 첫 페인트(time-to-first-paint)가 사파리(Safari)에서 59% 감소
- 메모리 사용량 70~80% 감소
이 대부분은 현대 브라우저만 대상으로 삼는 결정, 더 나은 데드 코드 제거(dead-code elimination), 공격적인 코드 분할(code splitting)이 결합된 결과다. 레거시 지원을 버린 것이 가장 큰 승리다. 폴리필(polyfill)도 없고, ES5 트랜스파일(transpile)도 없고, `nomodule` 폴백도 필요 없다. 하지만 데드 코드 제거와 청크(chunk) 분할 작업도 그만큼 중요하다.
이런 최적화를 모두 해도 Linear는 여전히 적지 않은 양의 코드를 보낸다. 축소(minified)된 자바스크립트가 대략 21MB 정도다. 차이는 이 코드가 라우트(route) 단위 청크 수백 개로 공격적으로 분할되어 있고, 필요할 때만 가져온다는 점이다.
```typescript
// vite.config.ts (재구성본; 관찰된 청크 그래프와 일치)
export default defineConfig({
plugins: [react()],
build: {
target: "esnext", // 레거시 문법 없음, 폴리필 없음
cssMinify: "lightningcss",
modulePreload: { polyfill: false },
rollupOptions: {
output: {
// npm 패키지 하나당 청크 하나(대략 3KB 초과).
// 캐시 무효화가 앱 전체 리비전 단위가 아니라 라이브러리 단위가 된다.
manualChunks(id) {
if (id.includes("node_modules")) {
const pkg = id.match(/node_modules\/([^/]+)/)?.[1];
if (pkg) return `vendor-${pkg}`;
}
},
},
},
},
});
```
여기서 교훈은 어떤 번들러를 고를지가 아니다. 중요한 것은 레거시 브라우저를 버리고, 네이티브 ESM으로 가고, 미친 듯이 코드 분할을 하는 일이다. 단계마다 보면 작은 변화처럼 보인다. 하지만 쌓이면 Linear의 첫 로딩 자바스크립트를 대략 절반으로 줄였고, 빌드 시간은 자릿수(order of magnitude) 수준으로 단축했다.
즉, 즉시 로드되는 듯한 경험을 만드는 첫 번째 비결은, 사용자에게 무언가를 보여 주는 데 필요한 자바스크립트와 CSS의 양을 줄이는 것이다.
### 초기 로드 이후 프리로딩(preloading)
**자바스크립트를 가능한 한 작은 청크로 나눴다면, 이제 백그라운드에서 일을 시킬 수 있다.**
하지만 잠깐. 번들을 수백 개 청크로 나누면 새로운 문제가 생긴다. 각 청크가 또 다른 청크를 `import`하고, 브라우저는 엔트리(entry) 스크립트를 파싱하기 전까지 그 목록을 모른다. 아무 도움도 없으면 로딩 타임라인은 폭포수(waterfall)처럼 된다. 엔트리를 가져오고, 파싱하고, 그 안의 `import`를 가져오고, 다시 파싱하고, 또 그다음 `import`를 가져온다. 단계가 하나 늘 때마다 네트워크 왕복이 추가되고, 이것은 무슨 일이 있어도 피해야 한다.
Linear는 자바스크립트가 실행되기 전에 브라우저가 전체 목록을 보고 병렬로 요청을 날리게 만든다. 그 결과 엔트리 스크립트가 첫 `import`에 도달했을 때는 이미 필요한 청크가 캐시에 들어 있다.
그 모습은 `index.html`의 `<head />`에서 이렇게 보인다.
```html
<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`과 맞춰져 있다. 그래서 브라우저는 프리로드와 실제 `import`를 별도 리소스로 취급하지 않고, 이미 캐시된 요청을 재사용한다. 폰트 프리로드에서 쓰는 것과 같은 요령을, 크리티컬 패스(critical path)의 모든 청크에 적용한 셈이다.
그 결과 콜드 로드(cold-load) 타임라인이 순차적 폭포수 구조에서 하나의 병렬 배치(batch)로 압축된다. 네트워크가 해야 할 일 자체가 사라지는 것은 아니다. 다만 한 번에 처리하게 되는 것이다. 이 기법의 아름다운 점은, 사용자가 처음 로그인 페이지에 도달했을 때 그 백그라운드에서 이런 작업을 모두 해낼 수 있다는 데 있다. 몇 초만 지나면 전체 앱이 캐시에 저장되고, 이후에는 즉시 제공된다.
사용자가 여러분의 앱을 어떻게 쓸지를 이해하는 일은 정말 중요하다. 이를 제대로 이해하면, Linear처럼 백그라운드에서 스크립트를 프리로드하는 식으로 그 사용 패턴을 유리하게 활용할 수 있다.
### 서비스 워커(service worker)로 더 빠르게, 그리고 오프라인까지
Linear의 나머지 부분, 즉 사용자가 아직 방문하지 않은 뷰의 라우트 단위 청크는 서비스 워커가 백그라운드에서 캐싱한다. 워커 소스에는 프리캐시 매니페스트(precache manifest)가 내장되어 있고, 여기에는 라우트 청크, 아이콘, 폰트 등을 포함하는 해시 처리된 자산(asset) 약 1,200개가 들어 있다. 이들은 첫 페이지 로드 이후 지연 로드(lazy) 방식으로 내려받는다. 로그인 화면에 도달한 뒤 몇 초만 지나면 앱 전체가 캐시에 들어가 있다.
캐시에서 즉시 로드되도록 모든 청크 자바스크립트 파일을 프리로드하는 모습
이렇게 하면 두 가지 이점이 생긴다. 첫째, 이후의 탐색(navigation)은 네트워크를 아예 건너뛴다. 서비스 워커가 HTTP 캐시를 거치지도 않고 자기 캐시에서 바로 응답한다. 둘째, 네트워크가 끊겨도 앱이 계속 동작한다. 여기에 로컬 우선(local-first) 동기화 엔진이 결합되면, 이미 사용자 데이터가 IndexedDB에 있기 때문에 Linear는 오프라인에서도 사용 가능하다. 이슈를 읽고, 새로 만들고, 제목과 설명을 수정하고, 상태를 바꿀 수 있다. 모든 변경은 로컬 트랜잭션 저장소에 큐잉되고, 다음에 연결이 복구되면 서버로 전송된다.
`modulepreload`는 앱이 **지금** 필요로 하는 것을 위한 것이다. 브라우저가 직렬적인 `import` 체인에 막히지 않도록 병렬로 가져온다. 서비스 워커는 앱이 **다음에** 필요로 할 것을 위한 것이다.
정리하면, Linear가 빠른 로딩 속도를 얻는 방법은 가능한 한 많은 코드를 없애고, 작은 조각으로 나누고, 나머지를 백그라운드에서 프리캐시하는 것이다. 다시 말하지만 이 모든 작업의 목표는 네트워크 요청을 가능한 한 빠르게 하거나, 더 좋게는 완전히 없애는 데 있다.
### 벤더 번들(vendor bundle) 구성
흥미로웠던 점 하나는 Linear가 사용하는 각 패키지(package)가 각자 독립된 청크를 가진다는 것이다. 전통적인 `vendor.js`는 의존성 그래프 전체를 하나의 파일에 넣기 때문에, 하나만 바뀌어도 전체가 무효화된다. 반면 Linear의 청킹(chunking)은 벤더 캐싱을 하나의 거대한 파일에서 세밀한 단위로 바꿔 놓는다. 의존성 하나만 업데이트되면 해당 청크 하나만 무효화되고, 나머지는 캐시에 그대로 남는다.
당연한 선택처럼 보이지만, 빠른 로딩 시간을 보장하기 위한 또 하나의 세심한 디테일이다.

각 개별 패키지를 각각의 JS 파일로 분할한 모습
### 거대한 폰트 파일 로딩
폰트 로딩은 많은 앱이 잘못 처리하는 세부 사항 중 하나다. 잘못되면 바로 눈에 띈다. 반 초 동안 글자가 안 보이거나, 실제 폰트가 바뀌며 레이아웃이 흔들리거나, 프리로드가 맞지 않아 리소스를 두 번 가져오기도 한다. Linear의 설정은 이 세 가지를 모두 피한다.
```html
<!-- 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>
```
```css
@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 파일이다. */
```
가변 폰트(variable fonts)는 하나의 woff2 파일로 100~900 전체 가중치 축을 커버하므로, 굵기별 요청이 사라진다. `font-display: swap`은 먼저 폴백 스택(fallback stack)을 즉시 그린 뒤, 로드가 끝나면 Inter로 바꾼다. 여기서 놓치기 쉬운 핵심은 프리로드 태그의 `crossorigin="anonymous"`다. 이게 없으면 브라우저는 폰트를 프리로드한 뒤, 나중에 CSS가 해당 폰트를 참조할 때 다시 한 번 가져온다. 두 요청의 CORS 모드가 다르기 때문이다. 프리로드에 `crossorigin`을 붙이면 브라우저는 이미 캐시된 폰트를 재사용한다.
전부 단순해 보이지만, 놀랄 만큼 많은 앱이 폰트를 잘못 로드한다. Linear는 세부 사항을 끝까지 생각해 폰트 로딩이 최대한 빠르고 정확하도록 만든 좋은 사례다.
### 인라인 앱 셸(app shell)
첫 로딩을 빠르게 느끼게 하는 또 다른 핵심 기법은 인라인(inlined) 앱 셸이다. `<head/>` 안에는 외부 스타일시트를 가져오지 않고도 로딩 상태를 그릴 수 있을 정도의 CSS만 들어 있다. 다시 강조하지만, 병목은 네트워크고, 앱을 빠르게 느끼게 만들기 위해 늘 맞서 싸워야 할 대상도 네트워크다. 여기서 Linear는 사용자가 앱 셸을 볼 수 있게 하는 데 필요한 핵심 CSS를 인라인으로 넣어 네트워크 요청 하나를 없앤다.
```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 외에도 초기 경험을 로드하는 데 꼭 필요한 자바스크립트가 꽤 많이 인라인으로 들어 있다.
```typescript
<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`의 오버라이드(override)를 덮어쓴다. 그런 다음 사용자가 마지막으로 보았던 셸 토큰을 `document.documentElement.style`에 바로 적용한다. 사이드바 배경, 기본 색, 테두리 색, 사이드바 너비, 에이전트 툴바 높이 등이 여기에 해당한다. 색상 테마 선호도와 일렉트론(Electron) 컨텍스트도 감지한다. 또한 `localStorage.ApplicationStore`가 있는지 확인하고, 없으면 `logged-out` 클래스를 추가해 셸을 인증 레이아웃으로 전환한다.
즉, 첫 번째 자바스크립트 번들이 네트워크에서 도착할 때쯤에는 로딩 화면이 이미 올바른 테마, 크기, 위치로 맞춰져 있고, 사용자가 로그인 상태인지 여부까지 반영된 상태다.
사용자는 URL 입력창에서 엔터를 누르자마자 앱이 거의 준비된 것처럼 느낀다. 이보다 더 빠른 방법은 없다. 초기 앱 셸을 최초 `index.html` 응답에 실어 보내는 것이다.
Linear의 초기 로딩이 얼마나 빠른지 보여 주는 예
### 먼저 렌더링하고, 인증은 나중에
인증(authentication)은 대부분의 앱이 성능 예산(performance budget)을 쉽게 써 버리는 지점이다. 일반적인 흐름은 이렇다. HTML을 받아오고, 번들을 로드하고, 세션(session)을 검증하고, 사용자를 가져오고, 워크스페이스를 가져온 다음에야 렌더링한다. 사용자가 무언가를 보기까지 1~3초가 걸린다.
Linear는 인증도 변경(mutation)과 같은 방식으로 다룬다. 성공할 가능성이 높은 경로(happy path)를 먼저 가정하고, 검증은 백그라운드에서 한다. 개인적으로 이 부분은 Linear 아키텍처에서 가장 마음에 드는 요소 중 하나다. 덕분에 로드 직후 거의 즉시 전체 경험을 렌더링할 수 있기 때문이다.
대부분의 CRUD 앱은 실제 세션을 HttpOnly 쿠키에 저장하고, 프런트엔드(frontend)가 시작 시점에 로그인 여부를 알 수 있도록 자바스크립트가 읽을 수 있는 보조 쿠키나 `/me` 요청을 추가한다. Linear는 훨씬 단순하다. 별도의 인증 신호를 유지하는 대신, 인라인 부트 스크립트(boot script)가 그저 `localStorage.ApplicationStore`가 있는지만 확인한다.
```javascript
if (localStorage.getItem("ApplicationStore") === null) {
document.documentElement.classList.add("logged-out");
}
```
그 값이 있으면 사용자는 이 브라우저에서 이미 Linear를 사용한 적이 있다는 뜻이고, 워크스페이스가 이미 IndexedDB에 들어 있다는 뜻이다. 이것은 앞서 본 첫 번째 섹션, 즉 데이터베이스가 브라우저 안에 있다는 이야기로 다시 연결된다. 반대로 값이 없으면 애초에 렌더링할 것도 없다. 그래서 셸이 로그아웃 레이아웃으로 바뀌고 로그인 흐름이 시작된다.
Linear의 초기 흐름은 “유효한 세션이 있는가?”가 아니다. “보여 줄 것이 있는가?”다. 실제 세션 토큰은 쿠키에 있다. 번들은 그걸 똑똑하게 판단하려 들지 않는다. 그냥 지금 가진 것을 렌더링하고, 그다음 요청이 알아서 판별하게 둔다. WebSocket 핸드셰이크(handshake), 동기화 델타(delta), 또는 어떤 HTTP 호출이든 세션이 오래되어 무효해졌다면 401을 반환할 것이다. 그러면 클라이언트가 로그인 화면으로 리디렉션(redirect)한다.
이 패턴은 전체 아키텍처와도 일관된다. 클라이언트는 로컬 상태를 신뢰한다. 정확성의 최종 출처(source of truth)는 서버다. 둘은 비동기적으로 조정되고 다시 맞춰진다. 변경 처리와 똑같고, 동기화 엔진과도 똑같다.
인증 세션을 수동으로 삭제하고 데스크톱 앱을 새로고침하는 모습
이건 아마 Linear에서 내가 가장 좋아하는 디테일 중 하나다. 더 많은 앱이 이렇게 동작했으면 좋겠다. 인증에서는 일단 성공 경로를 가정하고, 아니면 그때 되돌아가면 된다. 보여 줄 데이터가 있으면 보여 줘라. 그리고 브라우저의 데이터 저장소를 적극 활용해 즉시 렌더링하라.
---
## 동기화 엔진(sync engine)
Linear를 빠르게 만드는 요소 대부분은 하나의 결정에서 시작된다. 서버는 UI의 진실의 원천(source of truth)이 아니라 동기화 대상(sync target)이라는 결정이다. 그들의 동기화 엔진 내부 구조는 이미 상당히 철저하게 역공학(reverse engineering)되었고, Tuomas도 이 아키텍처에 대해 훌륭한 발표를 여러 번 했다. 여기서 그 내용을 다시 따라가지는 않겠다. 내가 하고 싶은 것은 실제로 속도를 만들어 내는 세 개의 기둥을 짚는 것이다. 속도는 어느 한 요소가 아니라, 이 셋이 맞물리는 방식에서 나오기 때문이다.
### 1. 데이터가 이미 있다
앱이 부팅될 때, 서버에서 워크스페이스를 가져오지 않는다. IndexedDB에서 메모리 내 MobX 객체 풀(object pool)로 하이드레이션(hydration)하고, UI의 모든 쿼리(query)는 먼저 이 풀을 조회한다. “이슈 로딩 중” 같은 상태가 없는 이유는, 이슈가 이미 사용자 기기에 있기 때문이다.
내가 흥미롭게 본 점은, 규모가 커지면서 이들도 자바스크립트 번들에서 쓰던 것과 비슷한 원리로 동기화 엔진 안의 데이터를 청크 단위로 나눴다는 것이다. 모든 데이터를 한 번에 가져오지 않는다. 가장 무거운 두 테이블인 Issue와 Comment는 필요할 때 지연 하이드레이션(lazy-hydrate)된다. 이것은 데이터 수준의 코드 분할이다. 그리고 동기화 엔진이 확장 가능하게(scale) 동작하는 이유도 여기에 있다. 시작 비용(startup cost)이 워크스페이스의 크기가 아니라 워크스페이스의 구조를 따라가기 때문이다. 이슈가 10,000개인 워크스페이스도 100개인 워크스페이스와 비슷한 속도로 부팅된다.
프로젝트를 클릭하면 이슈가 이미 있다. 담당자 기준으로 필터링하면 인덱스(index)도 이미 만들어져 있다. 가져올 것이 없으니 요청할 것도 없다. 데이터는 이미 브라우저에서 바로 로드되었거나, 혹은 코드 분할된 지연 청크를 통해 곧바로 로드된다.

IndexedDB: 데이터베이스는 브라우저 안에 있다
### 2. 변경은 네트워크를 기다리지 않는다
이슈 상태를 바꾸면 거의 동시에 세 가지 일이 일어난다. MobX observable이 업데이트되어 UI가 바뀐 상태를 즉시 반영하고, 변경 내용이 IndexedDB의 영속적(durable) 트랜잭션 큐에 기록되며, 서버 전송 대기열에도 올라간다. 이 시점에서 네트워크는 아직 건드리지도 않았다.
사용자는 자신의 변경이 반영되는 모습을 보기 위해 기다리지 않는다. 재시도(retry), 롤백, 새로고침 후에도 유지되는 내구성(durability)은 모두 백그라운드에서 처리된다. 서버가 거부하면 observable이 되돌아가고 잠깐 깜빡일 수는 있다. 하지만 실제로는 거의 일어나지 않는다. 대부분의 잘못된 변경은 트랜잭션이 만들어지기 전에 이미 걸러지기 때문이다.
계속 말하지만, 네트워크는 적이다. 가능한 한 피해야 한다. Linear의 흐름은 로컬 변경에서 시작하며, 서버는 허가를 내려주는 단계가 아니라 확인해 주는 단계로 취급한다.
### 3. 델타(delta) 하나, 셀(cell) 하나
서버가 변경 사항을 확인하면, 그 변화가 내 것이든 다른 사람의 것이든 작은 JSON 엔벌로프(envelope) 형태로 돌아온다. 무엇이 바뀌었는지를 설명하는 데이터다. 클라이언트는 이를 해당 MobX observable에 기록해 적용한다.
Linear에서는 모든 모델의 모든 속성이 각각 독립적인 observable이고, 그것을 읽는 모든 컴포넌트(component)가 `observer()`로 감싸져 있기 때문에, MobX는 어떤 컴포넌트가 어떤 필드에 의존하는지 정확히 알고 있다. 이슈 하나의 필드 하나가 바뀌면, 그 필드를 읽는 컴포넌트만 다시 렌더링된다. 부모 리스트도 아니고, 사이드바도 아니고, 딱 그 셀 하나다. 이슈 50개가 업데이트되면 리스트 전체 재렌더링이 아니라 셀 50개 재렌더링이 일어난다. 여러 사람이 동시에 수정하는 바쁜 워크스페이스에서도 부드럽게 유지되는 이유가 바로 이것이다. 업데이트 수신 비용이 화면에 무엇이 보이느냐가 아니라, 실제로 무엇이 바뀌었느냐에 비례하기 때문이다.
나도 주가 데이터와 펀더멘털(fundamentals)이 실시간으로 들어오는 앱을 만든 적이 있는데, 개별 컴포넌트를 원자적(atomic)으로 갱신할 수 있어야 앱이 빠르게 느껴진다. 연쇄적인 갱신(cascading updates)은 가능한 한 피해야 하고, Linear는 정확히 그렇게 한다.
리스트에서 이슈를 업데이트할 때 단일 이슈 행만 다시 렌더링되는 모습
### 왜 이 세 가지가 함께 맞물리는가
셋 중 하나만 빠져도 앱은 느리게 느껴지기 시작한다. 로컬 데이터베이스만 있고 낙관적 쓰기(optimistic writes)가 없으면 저장할 때 여전히 스피너가 돈다. 낙관적 쓰기만 있고 세밀한 observable이 없으면 업데이트마다 버벅거린다. 세밀한 observable만 있고 로컬 데이터베이스가 없으면 초기 로딩에서 기다려야 한다. Linear의 속도는 어느 한 계층(layer)의 속성이 아니다. 시스템 전체의 속성이다.
번들러와 로더 셸(loader shell)은 첫 페인트(first paint) 시점을 빠르게 느끼게 한다. 동기화 엔진은 실제 사용을 시작한 뒤에도 계속 빠르게 느껴지도록 만든다.
---
## 속도를 고려한 설계
속도는 단지 엔지니어링 문제만이 아니다. 설계 문제이기도 하다. 동기화 엔진을 완벽하게 만들어도 입력 모델(input model)이 느리면 소용없다. 어떤 작업에 도달하는 가장 빠른 경로가 마우스, 메뉴 세 개, 클릭 한 번이라면, 엔진이 아무리 빨라도 사용자는 그 단계를 모두 치러야 한다.
Linear의 속도를 떠받치는 또 하나의 핵심은 키보드(keyboard)를 탐색과 작업 완료의 주된 도구로 통합했다는 점이다. 흔한 작업마다 모두 단축키(shortcut)가 있다. 커맨드 팔레트(command palette)는 한 번의 키 입력이면 열린다. 오른쪽 클릭 메뉴도 직접 구현했다. 이 모든 것은 우연이 아니라, 첫날부터 내려온 사려 깊은 설계 결정이다.
### 모든 동작에 단축키가 있다
포커스된 이슈는 한 글자 키로 편집한다. 두 글자 조합으로 탐색한다. 수정 키(modifier)는 전역적으로 작동한다.
창업자들이 Linear 초기에 대해 이야기하는 것을 들어 보면, 단축키가 처음부터 핵심이었다는 점이 분명하다. 동기화 엔진도 어떤 동작이든 언제든 수행할 수 있도록 설계되었다. 설계와 엔지니어링의 이런 결합이 지금도 모든 기능 뒤에서 계속 작동하고 있다는 느낌을 준다.
UI 전체를 둘러보면 곳곳에 단축키가 보인다. 가장 자주 쓰는 것들은 한 글자로 되어 있는데, 그만큼 자주 쓰이기 때문이다. 동시에 초보 사용자를 소외시키지 않도록 모든 동작은 마우스로도 할 수 있다.
     
### 커맨드 팔레트(command palette)는 언제나 한 번의 키 입력 거리
`⌘ k`를 누르면 사용자는 Linear 안의 거의 모든 동작을 검색할 수 있는 커맨드 팔레트를 연다. 이슈, 프로젝트, 라벨, 상태 변경, 탐색, 이슈 생성, 설정, 테마 전환까지 모두 포함된다. 이 명령이 매우 빠른 이유는 서버가 아니라 로컬 MobX 객체 풀을 검색하기 때문이다. 다시 말하지만, 네트워크는 피해야 한다.
아키텍처 관점에서 보면, 앱 전체가 하나의 창(pane)에서 접근 가능해진다는 의미다. 탐색도 검색이고, 이슈 생성도 검색이며, 상태 변경도 상태 목록으로 범위를 좁힌 검색이다. 게다가 이 명령은 맥락적(contextual)이다. 지금 무엇을 하고 있는지에 따라 적응한다. 어떤 뷰에서든 핵심 동작과 단축키를 가르치는 훌륭한 방식이기도 하다. 하나의 기본 도구(primitive)를 어디서나 쓰고, 이미 메모리에 있는 데이터 위에서 동작하게 만든 것이다.
빠른 앱에는 뛰어난 엔지니어링과 뛰어난 설계가 모두 필요하다. 완벽한 동기화 엔진과 흠잡을 데 없는 렌더링 파이프라인(rendering pipeline)을 만들어도, 설계가 잘못되면 앱은 여전히 느리게 느껴질 수 있다. 엔지니어링의 속도는 개별 상호작용을 빠르게 만든다. 설계의 속도는 그 상호작용에 도달하는 경로 자체를 짧게 만든다.
하루 종일 쓰는 도구에서는 단축키와 2초짜리 마우스 경로 사이의 차이가 모든 동작마다 누적된다. 여기에 전역 커맨드 팔레트까지 더하면, 사용 속도가 대단히 빠른 앱이 된다.
---
## 애니메이션(animations)
지금까지 한 모든 노력도 나쁜 애니메이션 하나로 무너질 수 있다. 팀은 앱의 모든 부분을 빠르게 만들기 위해 엄청난 노력을 들인다. 초기 로딩, 업데이트, 데이터베이스 쿼리까지 모두 그렇다. 사용자가 기다리지 않도록 밀리초 단위까지 깎아 낸다. 그런데 마지막 단계에서 누군가 어떤 요소에 500ms짜리 height 애니메이션을 넣어 버린다.
### 애니메이션해야 할 속성(property)은 몇 개 안 된다
브라우저는 속성 변경을 세 단계로 처리하고, 비용은 렌더링 파이프라인에서 위로 올라갈수록 커진다. 합성(composited) 속성인 `transform`, `opacity`는 작업을 GPU로 넘기므로 메인 스레드(main thread)와 독립적으로 실행된다. 페인트를 유발하는 속성인 `color`, `background-color`, `border-color`, `fill`은 레이아웃은 건너뛰지만 픽셀을 다시 그려야 한다. 레이아웃을 유발하는 속성인 `width`, `height`, `top`, `left`, `margin`, `padding`은 페이지 아래쪽에 이어지는 모든 요소의 위치를 다시 계산하게 만든다. 이런 속성은 절대 애니메이션하지 마라. 정말 절대다.
```css
/* 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` 버전은 마우스를 올린 행 아래의 모든 행에 대해, 전환 200ms 내내 매 프레임 레이아웃을 다시 계산한다. 긴 이슈 리스트에서는 이것이 부드러움과 버벅임의 차이가 된다.
Linear 앱에서 실제로 애니메이션하는 모든 속성을 살펴보면, 대부분 `transform`, `opacity` 같은 합성 속성에만 한정되어 있고, 가끔 `background-color`, `border-color` 정도를 쓴다.
### 언제 멈춰야 하는지도 알아야 한다
내 생각에 합성 속성만 애니메이션하는 것만큼 중요한 것은, 아예 애니메이션하지 말아야 할 때를 아는 것이다. 애니메이션은 쉽게 과해진다. 하지만 매일 쓰는 도구에서는 마케팅 사이트에서는 멋져 보이던 애니메이션이 거슬리기 시작한다. 잘못된 위치의 작은 hover 지연 하나도 결국 사용자가 신경 쓰게 되는 요소가 된다.
Linear는 이 부분을 대체로 아주 잘한다. 다만 커맨드 팔레트는 조금 느리다고 말하고 싶다. 세월이 지나면서 내가 까다로운 사람이 되어 버렸을지도 모르지만.
리스트 아이템에는 전환이 없어 사용감이 경쾌하다
그들의 애니메이션이 잘 작동하는 이유 중 하나는, 대부분 원점(origin)을 참조하기 때문이다. 상태 팝오버(popover)는 상태 필(status pill)에서 확장되어 나온다. 에이전트 패널(agent panel)은 토글 위치에서 미끄러져 들어온다. 이 움직임은 단순 장식이 아니라, 새 요소가 어디서 왔는지를 사용자에게 알려 주는 공간적 정보(spatial work)를 제공한다. 아무 데서도 오지 않은 것처럼 그냥 페이드인(fade in)하는 것이 아니다.
### 지속 시간(duration)은 짧고 경쾌하게
```css
/* Linear 스타일시트의 변수 */
--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: .15s;
--speed-quickTransition: .1s;
--speed-regularTransition: .25s;
--speed-slowTransition: .35s;
```
대부분의 디자인 시스템(design system)은 기본 전환 시간이 필요 이상으로 길다. 머티리얼(Material)의 표준 지속 시간은 200ms이고, iOS의 스프링은 350ms에 더 가깝다. 기본값을 더 짧게 잡는 것만으로도 앱은 훨씬 빠르게 느껴진다. Linear의 기본값은 업계 평균보다 분명히 짧다.
Linear는 여기서 한 걸음 더 나아가, 등장과 퇴장의 타이밍을 비대칭(asymmetric)으로 설정한다. hover 하이라이트(highlight), 팝오버, 에이전트 패널은 호출했을 때 즉시 나타나고, 닫을 때만 150ms 동안 서서히 사라진다.
에이전트 창은 즉시 나타나고, 사라질 때는 macOS처럼 부드럽게 페이드아웃된다
작은 덧붙임으로, Linear의 디자인 엔지니어(design engineer) 중 한 명인 [Emil Kowalski](https://x.com/emilkowalski)는 [animations.dev](http://animations.dev/)에서 정말 훌륭한 강의를 만들었다. 방금의 두 섹션이 흥미로웠다면 꼭 한번 보길 권한다. 애니메이션 원리를 풍부한 예제와 실용적인 튜토리얼과 함께 깊이 있게 다룬다.
---
## Linear가 이렇게 빠른 이유
Linear를 빠르게 느끼게 만드는 디테일은 여기서 다룬 것보다 훨씬 더 많다. 실제로 앱을 고성능으로 만드는 단 하나의 비결은 없다. 수백 가지 결정을 올바르게 내려 쌓아 올린 결과다.
내가 Linear의 접근에서 좋아하는 점은, 그 대부분이 의외로 단순하다는 것이다. Next도 없고, Tanstack도 없고, 화려한 프레임워크도 없다. 그들은 일찍부터 어떤 아키텍처가 사용자에게 가장 잘 맞는지 결정했고, 그 방향을 끝까지 지켜 왔다. 그 결과는 서버 렌더링 앱보다 더 빠른 클라이언트 사이드 렌더링 앱이다. 그것도 더 적은 복잡성으로 말이다.
전체 그림은 대략 이렇다. 서버는 진실의 원천이 아니라 동기화 대상이다. 데이터베이스는 브라우저 안에 있다. 변경은 먼저 로컬에 적용하고, 백그라운드에서 조정한다. 첫 로딩에서는 더 적은 코드를 더 많은 조각으로 나누어 보내고, 사용자가 아직 로그인 페이지에 머무는 동안 서비스 워커가 나머지를 프리캐시한다. 인증은 상태를 기준으로 우선 가정하고 나중에 검증한다. 동기화 엔진은 IndexedDB에서 속성별 MobX observable로 하이드레이션하므로, 이슈 50개 업데이트는 리스트 전체 재렌더링이 아니라 셀 50개 재렌더링이 된다. 입력 모델은 키보드 중심이다. 흔한 작업에는 모두 단축키가 있고, 전역 커맨드 팔레트가 있다. 애니메이션은 GPU에서 처리되는 속성만 사용하고, 지속 시간은 인과 관계를 체감하는 100ms 임계값 아래에 두며, 레이아웃을 유발하는 속성은 절대 애니메이션하지 않는다.
어려운 부분은 구현 자체가 아니다. 코드베이스가 성숙해지고, 커지고, 새로운 제약에 부딪히는 동안에도 수년간 그 완성도를 꾸준히 지켜 내는 헌신이다.
아직 써 보지 않았다면, [Linear](https://linear.app/)를 직접 사용해 보길 권한다. 여기서 말한 것들이 실제로 어떻게 작동하는지 확인할 수 있다.
---
뭔가 한두 가지라도 얻어 가셨길 바란다. 이 글을 쓰면서 Linear를 Linear답게 만드는 세부 사항을 깊이 들여다보는 과정이 정말 즐거웠다. 나는 그저 세상에서 가장 뛰어난 웹 앱을 만드는 일을 좋아하고, 다른 사람들이 그걸 어떻게 해내는지도 보는 걸 좋아한다. 피드백이나 제안이 있거나, 그냥 연결되고 싶다면 [X에서 나를 찾으면 된다.](https://x.com/brotzky)