(번역) Linear는 어떻게 이렇게 빠를까요? 기술적으로 분석해봅시다.

2026-08-092026-08-09
  • 번역

원문: https://performance.dev/how-is-linear-so-fast-a-technical-breakdown

Linear에서 이슈 하나를 업데이트하는 데는 몇 밀리초밖에 걸리지 않습니다. 똑같은 작업을 하는 전통적인 CRUD 앱은 약 300ms가 걸립니다. 어떻게 가능한 걸까요? 성능을 단숨에 해결하는 비법은 없습니다. 처음부터 올바른 기반 위에 구축한 뒤 수없이 많은 결정을 거쳐 개선해 온 것이 실제 비결입니다. 이 글에서는 Linear 특유의 사용감을 만드는 몇 가지 기법을 살펴보고, 여러분의 앱에도 같은 방식을 적용할 수 있도록 돕겠습니다.

다룰 내용

  • 브라우저 안의 데이터베이스
  • 첫 로딩을 즉각적으로 느끼게 만들기
  • 동기화 엔진
  • 속도를 고려한 설계
  • 애니메이션

먼저 짧게 밝혀둘 점이 있습니다. 저는 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 옵저버블을 사용합니다. 두 번째 줄인 issue.save();는 트랜잭션을 큐에 넣고, 동기화 엔진이 이를 묶어 서버로 보냅니다. 여기서 핵심은 로컬 인메모리 업데이트에 맞춰 UI가 동기적으로 다시 렌더링된다는 점입니다. 데이터는 백그라운드에서 동기화되므로 기다릴 일이 없고, 스피너도 없습니다. 각 사용자의 브라우저를 데이터베이스처럼 다루는 마법입니다.

Linear 공동 창업자 중 한 명인 Tuomas는 2024년 한 콘퍼런스에서 이렇게 말했습니다. “제가 말 그대로 처음 작성한 코드는 동기화 엔진이었습니다. 스타트업에서 보통 하는 방식과는 아주 다르죠.” Linear는 첫날부터 어떤 접근법을 택하고 어떤 트레이드오프를 감수할지 알고 있었습니다.

Linear에서 이슈를 만들 때 스피너도 지연도 없는 모습

앱을 빠르게 느끼게 하려고 Linear처럼 자체 동기화 엔진을 만들 사람은 많지 않을 것이고, 그럴 필요도 없습니다. 대부분의 사용 사례에서는 TanStack QuerySWR 같은 라이브러리의 낙관적 업데이트만으로도 놀랄 만큼 비슷한 결과를 얻을 수 있습니다. 많은 웹 앱이 느리게 느껴지는 이유는 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의 기술 스택 들여다보기

Linear는 리액트, 타입스크립트, MobX, Postgres, CDN처럼 가장 단순한 축에 드는 기술 스택으로 만들어졌습니다. 엣지 데이터베이스도, 리액트 서버 컴포넌트도, 화려한 프레임워크도 없습니다.

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은 첫 로딩이 느리다는 비판을 자주 받지만, 적절한 아키텍처와 설계를 갖추면 즉각적으로 느껴질 수 있습니다.

저는 CSR이 선사하는 단순함도 무척 좋아합니다. 앱 전체를 클라이언트 사이드에 두면 멘탈 모델이 훨씬 깔끔해지고 서버 렌더링 앱에서 따라오는 복잡성도 크게 줄어듭니다. 지금 서버에 있는지 클라이언트에 있는지, window 객체에 접근할 수 있는지, 캐시 헤더를 제대로 설정했는지 끊임없이 고민할 필요가 없습니다. 단순함과 그로 인해 생기는 제약에는 아름다움이 있습니다.

그렇다면 Linear는 클라이언트 사이드 렌더링 앱의 첫 화면을 어떻게 즉시 나타나게 할까요?


첫 로딩을 즉각적으로 느끼게 만들기

제가 집착하는 것 중 하나가 첫 로딩이며, Linear도 분명 마찬가지입니다. 특히 생산성 도구에서는 실제로 작업을 시작하기까지 걸리는 시간이 가장 중요하게 살펴야 할 세부 요소 중 하나입니다. 새 탭이 열리기를 몇 초씩 기다리고 싶은 사람은 없습니다.

먼저 첫 로딩이 느려지는 이유를 이해해야 합니다. 클라이언트 사이드 앱은 index.html을 요청하고, 이어서 모든 자바스크립트와 CSS를 요청합니다. 그다음 어떤 형태로든 인증을 처리하고, 마침내 앱을 보여주기 위한 API 요청을 보냅니다.

Linear 번들러의 여정: Parcel, Rollup, Vite, Rolldown

앱이 즉시 뜨는 듯한 느낌을 만드는 첫 단계는 런타임보다 훨씬 전에 시작됩니다. 빌드 단계에서 시작합니다. 네트워크가 병목이라는 점을 기억하세요. 빠르게 로드하려면 전송하는 자바스크립트와 CSS를 최소화해야 합니다.

제가 파악한 바로는 Linear가 빌드 파이프라인을 네 번 다시 작성했습니다. Parcel → Rollup → Vite → Rolldown 순입니다. 각 마이그레이션의 목표는 같았습니다. 자바스크립트와 CSS의 양을 줄이고 개발자 경험을 개선하는 것입니다.

Linear가 직접 작성한 블로그 글에 따르면 다음과 같은 성과를 거뒀습니다.

  • 전송하는 코드 50% 감소
  • 압축 후 크기 30% 감소
  • 캐시가 비어 있을 때 페이지 로딩 시간 10~30% 단축
  • 활성 이슈 화면의 첫 페인트 시간 59% 단축(Safari 기준)
  • 메모리 사용량 70~80% 감소

이러한 성과 대부분은 최신 브라우저만 지원하기로 한 결정, 향상된 데드 코드 제거, 공격적인 코드 스플리팅이 어우러진 결과입니다. 레거시 지원을 중단한 효과가 가장 큽니다. 폴리필도, ES5 트랜스파일도, nomodule 폴백도 필요하지 않기 때문입니다. 하지만 데드 코드 제거와 청크 분할 작업도 그만큼 중요합니다.

이렇게 최적화하고도 Linear가 전송하는 코드의 양은 상당합니다. 축소된 자바스크립트가 약 21MB에 달합니다. 차이는 이를 수백 개의 라우트 수준 청크로 공격적으로 분할해 필요할 때 가져온다는 데 있습니다.

// 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의 첫 로딩 자바스크립트는 대략 절반으로, 빌드 시간은 10분의 1로 줄어듭니다.

즉, 즉각적인 로딩을 위한 첫 번째 비결은 사용자에게 무언가를 렌더링할 때 필요한 자바스크립트와 CSS를 줄이는 것입니다.

첫 로딩 후 미리 불러오기

자바스크립트를 가능한 한 작은 청크로 분할했다면 백그라운드 작업을 시작할 수 있습니다.

하지만 잠깐만요. 번들을 수백 개의 청크로 나누면 새로운 문제가 생깁니다. 각 청크는 다른 청크를 임포트하는데, 브라우저는 엔트리 스크립트를 파싱하기 전까지 그 대상이 무엇인지 모릅니다. 별도의 도움 없이는 로딩 타임라인이 폭포수처럼 이어집니다. 엔트리를 가져와 파싱하고, 엔트리가 임포트하는 파일을 가져와 파싱한 뒤, 다시 그 파일이 임포트하는 파일을 가져옵니다. 단계가 하나 늘 때마다 반드시 피해야 할 네트워크 왕복이 추가됩니다.

Linear에서는 자바스크립트가 실행되기 전에 브라우저가 전체 목록을 확인하고 모든 요청을 병렬로 보냅니다. 엔트리 스크립트가 첫 import에 도달할 즈음에는 청크가 이미 캐시에 들어 있습니다.

Linear의 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 -->

각 프리로드의 crossorigin 속성이 엔트리 스크립트의 crossorigin과 일치하므로, 브라우저는 프리로드와 임포트를 별개의 리소스로 취급하지 않고 캐시된 요청을 재사용합니다. 폰트 프리로드와 같은 기법을 핵심 경로의 모든 청크에 적용한 것입니다.

처음 로드할 때 요청이 차례로 이어지는 폭포수 구조가 사라지고, 필요한 파일을 한꺼번에 병렬로 요청합니다. 네트워크가 처리할 양은 같지만 모든 요청을 동시에 시작합니다. 이 기법의 아름다운 점은 사용자가 로그인 페이지에 처음 도착했을 때 이 모든 작업을 백그라운드에서 처리할 수 있다는 것입니다. 몇 초 안에 전체 앱이 캐시에 저장되고 즉시 제공됩니다.

사용자가 앱을 어떻게 이용할지 이해하는 것은 대단히 중요합니다. 이를 이해하면 Linear처럼 스크립트를 백그라운드에서 미리 불러오는 등 사용자 행동을 유리하게 활용할 수 있습니다.

속도와 오프라인 기능을 한층 끌어올리는 서비스 워커

사용자가 아직 방문하지 않은 화면의 라우트 수준 청크를 비롯한 Linear의 나머지 부분은 서비스 워커가 백그라운드에서 캐시합니다. 서비스 워커 소스에는 미리 캐시할 파일 목록이 들어 있습니다. 라우트 청크, 아이콘, 폰트처럼 파일명에 해시가 붙은 에셋 약 1,200개를 첫 페이지가 로드된 뒤 백그라운드에서 내려받습니다. 로그인 화면에 도착하고 몇 초만 지나면 전체 앱이 캐시에 담깁니다.

캐시에서 즉시 로드할 수 있도록 분할된 모든 자바스크립트 파일을 미리 불러오는 모습

이 방식은 두 가지 이점을 줍니다. 이후 내비게이션에서는 네트워크를 완전히 건너뛰고, HTTP 캐시조차 거치지 않은 채 서비스 워커가 자체 캐시에서 바로 응답합니다. 네트워크가 없어도 앱은 계속 작동합니다. 사용자의 데이터가 이미 IndexedDB에 담겨 있는 로컬 우선 동기화 엔진과 결합하면 Linear를 오프라인에서도 쓸 수 있습니다. 이슈를 읽고 새로 만들며 제목과 설명을 편집하고 상태를 바꿀 수 있습니다. 모든 작업은 로컬 트랜잭션 저장소의 큐에 들어갔다가 연결이 복구되면 전송됩니다.

modulepreload는 앱에 지금 필요한 것을 병렬로 가져와 브라우저가 연쇄적인 임포트 때문에 멈추지 않게 합니다. 서비스 워커는 앱에 다음으로 필요한 것을 준비합니다.

Linear가 로딩 시간을 줄이는 단계는 가능한 한 많은 코드를 없애고, 작은 조각으로 나눈 뒤, 백그라운드에서 미리 캐시하는 것입니다. 다시 한번 말하지만 이 모든 작업의 목표는 네트워크 요청을 최대한 빠르게 만들거나, 더 나아가 완전히 없애는 것입니다.

벤더 번들 구성

Linear가 사용하는 모든 패키지가 별도의 청크로 나뉘어 독립적으로 캐시된다는 점이 흥미로웠습니다. 전통적인 vendor.js는 패키지 하나의 버전만 올려도 전체 의존성 그래프의 캐시가 무효화됩니다. Linear는 청크를 나눠 하나의 거대한 벤더 캐시를 세분화했습니다. 패키지 하나를 업데이트하면 청크 하나만 무효화되고 나머지는 캐시에 남습니다.

당연한 선택처럼 보이지만, 빠른 로딩을 보장하는 또 하나의 세심한 결정입니다.

각 패키지를 별도의 자바스크립트 파일로 분할한 모습

대용량 폰트 파일 불러오기

폰트 로딩은 많은 앱이 잘못 처리하는 세부 요소 중 하나입니다. 실패하면 눈에 바로 보입니다. 텍스트가 0.5초 동안 보이지 않거나, 실제 폰트로 바뀔 때 레이아웃이 움직이거나, 프리로드가 일치하지 않아 리소스를 두 번 가져옵니다. 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. */

가변 폰트는 woff2 파일 하나로 100~900의 전체 굵기를 지원하므로 굵기마다 별도로 요청할 필요가 없습니다. font-display: swap은 먼저 폴백 폰트를 즉시 렌더링하고 Inter가 로드되면 교체합니다. 놓치기 쉬운 비결은 프리로드 태그의 crossorigin="anonymous"입니다. 이 속성이 없으면 프리로드와 CSS의 요청이 서로 다른 CORS 모드를 사용하기 때문에 브라우저가 폰트를 미리 불러온 뒤 CSS에서 참조할 때 다시 가져옵니다. 프리로드에 crossorigin을 설정하면 브라우저가 캐시된 폰트를 재사용합니다.

모두 단순해 보이지만, 폰트를 잘못 불러오는 앱이 얼마나 많은지 늘 놀랍습니다. Linear는 세부 사항을 깊이 고민해 폰트를 최대한 빠르고 정확하게 불러온 훌륭한 사례입니다.

인라인 앱 셸

첫 로딩을 빠르게 느끼게 하는 또 하나의 핵심 기법이 있습니다. 외부 스타일시트를 가져오지 않고도 로딩 상태를 그리는 데 충분한 CSS를 <head/> 안에 인라인으로 넣습니다. 네트워크가 병목이며, 앱을 빠르게 느끼게 하려면 언제나 네트워크와 싸워야 한다는 점을 기억하세요. 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);
    }
  }
</style>
<script>
  performance.mark('appStart');
</script>

CSS 외에도 첫 경험을 로드하는 데 필수적인 자바스크립트를 상당량 인라인으로 넣습니다.

<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의 자바스크립트가 localStorage.splashScreenConfig를 읽고 sessionStorage에 저장된 임시 설정을 합친 뒤, 사용자가 마지막으로 사용한 화면 설정을 document.documentElement.style에 바로 적용합니다. 사이드바 배경과 기본 색상, 테두리 색상, 사이드바 너비, 에이전트 툴바 높이 등이 여기에 해당합니다. 색상 모드 설정과 Electron 환경도 감지합니다.

localStorage.ApplicationStore의 존재 여부를 확인하고, 없으면 로그아웃 클래스를 추가해 셸을 인증 레이아웃으로 전환합니다.

첫 자바스크립트 번들이 네트워크에서 도착할 무렵에는 로그인 여부에 맞춰 로딩 화면의 테마와 크기, 위치가 이미 정확히 설정되어 있습니다.

덕분에 사용자는 주소창에서 엔터를 누르자마자 앱을 사용할 준비가 끝났다고 느낍니다. 첫 index.html 응답에 초기 앱 셸을 함께 보내는 것보다 빠른 방법은 없습니다.

Linear의 첫 로딩이 얼마나 빠른지 보여주는 예시

먼저 렌더링하고 나중에 인증하기

인증은 대부분의 앱이 성능 예산을 포기하는 또 하나의 단계입니다. 일반적인 흐름은 HTML 가져오기, 번들 로드, 세션 검증, 사용자 정보 가져오기, 워크스페이스 가져오기, 렌더링 순서입니다. 사용자가 무언가를 보기까지 1~3초가 걸립니다.

Linear는 인증을 변경 작업과 똑같이 다룹니다. 성공하는 경우를 가정한 뒤 백그라운드에서 검증합니다. 거의 즉시 전체 화면을 렌더링할 수 있기에 제가 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의 데이터 원천이 아니라 동기화 대상입니다. Linear 동기화 엔진의 내부 구조는 이미 철저히 리버스 엔지니어링되었고, Tuomas도 아키텍처에 관해 훌륭한 발표를 여러 차례 했습니다. 그 내용을 다시 따라가지는 않겠습니다. 실제 속도를 만들어 내는 세 가지 축을 짚고 싶습니다. 속도는 어느 한 요소가 아니라 세 요소가 맞물리는 방식에서 나오기 때문입니다.

1. 데이터가 이미 존재합니다

앱이 시작될 때 서버에서 워크스페이스를 가져오지 않습니다. IndexedDB에 저장된 데이터를 브라우저 메모리의 MobX 객체 풀로 불러오고, UI의 모든 쿼리는 이 풀을 먼저 조회합니다. 이슈가 이미 사용자의 기기에 있으므로 “이슈를 불러오는 중”이라는 상태가 없습니다.

흥미로운 점은 Linear가 규모를 키우며 자바스크립트 번들과 비슷한 기본 원리에 따라 동기화 엔진의 데이터도 청크로 나눴다는 것입니다. 모든 데이터를 한꺼번에 가져오지 않습니다. 가장 무거운 두 테이블인 Issue와 Comment는 실제로 필요할 때만 메모리로 불러옵니다. 데이터 수준의 코드 스플리팅이며, 덕분에 엔진을 확장할 수 있습니다. 시작 비용은 워크스페이스 크기가 아니라 구조에 따라 달라집니다. 이슈가 10,000개인 워크스페이스도 100개인 워크스페이스와 비슷한 속도로 시작합니다.

프로젝트를 클릭하면 이슈가 이미 있습니다. 담당자로 필터링하면 인덱스가 이미 만들어져 있습니다. 빠진 것이 없으므로 가져올 것도 없습니다. 브라우저에서 즉시 불러왔거나, 코드 분할된 지연 청크에서 잠시 후 불러옵니다.

IndexedDB: 데이터베이스가 브라우저 안에 있습니다

2. 변경 작업이 네트워크를 기다리지 않습니다

이슈 상태를 바꾸면 거의 동시에 세 가지 일이 일어납니다. MobX 옵저버블을 업데이트해 UI에 변경 사항을 반영하고, 변경 작업을 IndexedDB의 트랜잭션 큐에 기록해 새로고침 후에도 남겨 두며, 서버 전송 큐에도 넣습니다. 아직 네트워크에는 손도 대지 않았습니다.

사용자는 자신의 변경 사항을 확인하려고 기다리지 않습니다. 재시도, 롤백, 새로고침 이후에도 유지되는 영속성은 모두 백그라운드에서 처리합니다. 서버가 거부하면 옵저버블을 되돌려 잠깐 깜박이지만 실제로는 거의 일어나지 않습니다. 잘못된 변경 작업 대부분은 트랜잭션을 만들기도 전에 감지하기 때문입니다.

계속 말했듯 네트워크는 적이며, 피할 수 있는 일은 모두 해야 합니다. Linear의 흐름은 로컬 변경에서 시작하고 서버를 허가 단계가 아닌 확인 단계로 다룹니다.

3. 변경 하나, 셀 하나

서버가 변경 작업을 확인하면, 자신이 만든 변경이든 다른 사람이 만든 변경이든 무엇이 달라졌는지 설명하는 작은 JSON 메시지가 돌아옵니다. 클라이언트는 메시지에 담긴 필드의 MobX 옵저버블 값만 바꿉니다.

Linear에서는 모든 모델의 프로퍼티 하나하나가 각각 옵저버블이고, 이를 읽는 모든 컴포넌트가 observer()로 감싸져 있습니다. 따라서 MobX는 어떤 컴포넌트가 어떤 필드에 의존하는지 정확히 압니다. 이슈 하나의 필드 하나를 업데이트하면 해당 필드를 읽는 컴포넌트만 다시 렌더링합니다. 상위 목록도 사이드바도 아닌 셀 하나만 바뀝니다. 이슈 50개를 업데이트하면 목록 전체가 아니라 셀 50개를 다시 렌더링합니다.

열 명이 동시에 편집하는 바쁜 워크스페이스도 부드럽게 유지되는 이유입니다. 업데이트를 받는 비용은 화면에 무엇이 있는지가 아니라 무엇이 바뀌었는지에 따라 늘어납니다.

저는 주가와 기업의 기초 데이터를 스트리밍하는 실시간 앱을 만들어 봤습니다. 앱이 빠르다고 느끼게 하려면 각 컴포넌트를 서로 독립적으로 업데이트하는 일이 핵심입니다. 연쇄 업데이트는 최대한 피해야 하며 Linear가 정확히 그렇게 합니다.

목록에서 이슈를 업데이트할 때 해당 이슈 행 하나만 다시 렌더링되는 모습

세 가지가 맞물려야 하는 이유

하나라도 빠지면 앱이 느리게 느껴집니다. 로컬 데이터베이스가 있어도 낙관적 업데이트가 없으면 저장할 때 스피너가 뜹니다. 낙관적 업데이트가 있어도 세분화된 옵저버블이 없으면 업데이트할 때마다 화면이 버벅입니다. 세분화된 옵저버블이 있어도 로컬 데이터베이스가 없으면 첫 로딩을 기다려야 합니다. Linear의 속도는 어느 한 계층의 특성이 아니라 시스템 전체의 특성입니다.

번들러와 로더 셸은 첫 화면을 빠르게 느끼게 합니다. 동기화 엔진은 사용하기 시작한 뒤에도 계속 빠르게 느끼게 합니다.


속도를 고려한 설계

속도는 엔지니어링 문제만이 아닙니다. 설계 문제이기도 합니다. 완벽하게 만든 동기화 엔진도 사용자가 작업하는 방식이 느리고 번거로우면 소용없습니다. 어떤 작업을 하는 가장 빠른 경로에 마우스와 메뉴 세 개, 클릭 한 번이 필요하다면 기반 엔진이 아무리 빨라도 사용자는 그 단계만큼 시간을 치러야 합니다.

Linear 속도의 또 다른 초석은 탐색하고 작업을 끝내는 주된 도구로 키보드를 통합한 방식입니다. 자주 쓰는 모든 작업에 단축키가 있습니다. 명령 팔레트는 키 하나로 열립니다. 우클릭 메뉴는 직접 만들었습니다. 어느 것도 우연이 아니라 첫날부터 숙고해 내린 설계 결정입니다.

모든 작업에 단축키가 있습니다

글자 하나로 포커스된 이슈를 편집하고, 두 글자 조합으로 이동합니다. 보조 키는 전역으로 동작합니다.

창업자들이 Linear 초창기를 이야기하는 것을 들어보면 단축키가 처음부터 토대였다는 사실이 분명합니다. 언제든 어떤 작업이든 수행할 수 있게 하려는 목적도 동기화 엔진 설계에 일부 반영되었습니다. 설계와 엔지니어링의 이러한 조합은 지금도 모든 기능의 바탕에 자리한 듯합니다.

UI를 살펴보면 곳곳에서 단축키를 볼 수 있습니다. 가장 자주 쓰는 작업에는 사용 빈도에 맞춰 문자 하나짜리 단축키를 배정했습니다. 초보자를 소외시키지 않도록 모든 작업을 마우스로도 할 수 있습니다.

Linear 단축키 예시 1 Linear 단축키 예시 2 Linear 단축키 예시 3 Linear 단축키 예시 4 Linear 단축키 예시 5 Linear 단축키 예시 6

명령 팔레트는 언제나 키 하나면 열립니다

⌘ 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는 이 부분을 대부분 훌륭하게 처리합니다. 명령 팔레트 하나는 너무 느리다고 생각하지만, 제가 세월이 흐르며 까다로운 아저씨가 된 탓일지도 모르겠습니다.

목록 항목을 경쾌하게 유지하려고 전환 효과를 적용하지 않은 모습

Linear의 애니메이션 상당수가 자연스러운 이유는 애니메이션이 시작된 위치를 나타내기 때문입니다. 상태 팝오버는 상태 필에서 커져 나오고, 에이전트 패널은 토글에서 미끄러져 나옵니다. 움직임은 공간 정보를 전달합니다. 장식처럼 아무 데서나 페이드인하지 않고 새 요소가 어디서 왔는지 알려줍니다.

지속 시간은 짧고 경쾌하게

/* variables form Linear's stylesheet */

--speed-highlightFadeIn: 0s;
--speed-highlightFadeOut: 0.15s;
--speed-quickTransition: 0.1s;
--speed-regularTransition: 0.25s;
--speed-slowTransition: 0.35s;

대부분의 디자인 시스템은 기본 지속 시간을 필요 이상으로 길게 잡습니다. Material의 표준 지속 시간은 200ms이고, iOS의 스프링은 350ms에 가깝습니다. 전환 시간을 짧게 설정하는 것은 앱을 더 빠르게 느끼게 하는 가장 쉬운 방법 중 하나이며, Linear의 기본값은 업계 표준보다 훨씬 짧습니다.

Linear는 진입과 퇴장에 비대칭 타이밍을 적용해 한 단계 더 나아갑니다. 호버 강조, 팝오버, 에이전트 패널은 호출하면 즉시 나타나고 닫으면 150ms에 걸쳐 사라집니다.

에이전트 창은 즉시 나타나지만 macOS처럼 서서히 사라지는 모습

짧게 덧붙이자면 Linear의 디자인 엔지니어 중 한 명인 Emil Kowalskianimations.dev에서 훌륭한 강의를 만들었습니다. 앞의 몇 섹션이 흥미로웠다면 확인해 볼 만합니다. 풍부한 예제와 실용적인 튜토리얼을 통해 애니메이션 원칙을 깊이 다룹니다.


Linear가 이렇게 빠른 이유

Linear를 빠르게 느끼게 하는 세부 요소는 이 글에서 다룬 것보다 훨씬 많습니다. 실제로 앱의 성능을 높이는 단 하나의 요소는 없습니다. 올바르게 내린 수백 가지 결정이 모인 결과입니다.

Linear의 접근법에서 제가 좋아하는 점은 대부분 아주 단순하다는 것입니다. Next도, TanStack도, 화려한 프레임워크도 없습니다. 사용자에게 가장 알맞은 아키텍처가 무엇인지 일찍 결정하고 그 선택을 지켜 왔습니다. 그 결과 서버 렌더링 앱보다 빠르면서 복잡성도 덜한 클라이언트 사이드 렌더링 앱을 만들었습니다!

대략 이런 구조입니다. 서버는 데이터 원천이 아니라 동기화 대상입니다. 데이터베이스는 브라우저에 있습니다. 변경 사항을 먼저 로컬에 적용한 뒤 백그라운드에서 조정합니다. 첫 로딩에서는 적은 코드를 더 많은 조각으로 나눠 전송하고, 사용자가 로그인 페이지에 있는 동안 서비스 워커가 나머지를 미리 캐시합니다. 상태를 바탕으로 인증됐다고 가정하고 나중에 검증합니다.

동기화 엔진은 IndexedDB의 데이터를 프로퍼티별 MobX 옵저버블로 하이드레이션합니다. 따라서 이슈 50개가 업데이트되면 목록 전체가 아닌 셀 50개를 다시 렌더링합니다. 입력 모델은 키보드를 우선합니다. 자주 쓰는 모든 작업에 단축키가 있고 전역 명령 팔레트를 제공합니다. 애니메이션은 GPU에서 처리하고 지속 시간은 인과 관계를 즉각적으로 느끼는 100ms 임계점보다 짧게 유지하며, 레이아웃을 일으키는 프로퍼티에는 애니메이션을 절대 적용하지 않습니다.

어려운 것은 구현이 아닙니다. 코드베이스가 성숙하고 확장되며 새로운 제약에 부딪히는 동안에도 수년에 걸쳐 완성도를 지키려는 헌신입니다.

아직 사용해 보지 않았다면 Linear에서 이 모든 것을 직접 경험해 보기를 권합니다.


한두 가지라도 배워 가셨기를 바랍니다! 이 글을 쓰고 Linear를 특별하게 만드는 세부 요소를 깊이 파고드는 일은 즐거웠습니다. 저는 세계 최고의 웹 앱을 만드는 일과 다른 사람들이 어떻게 만드는지 살펴보는 일을 정말 좋아합니다. 피드백이나 제안이 있거나 이야기를 나누고 싶다면 X에서 저를 찾아주세요.

🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!

Profile picture

emewjin

Frontend Developer

잘못된 내용 혹은 더 좋은 방법이 있으면 언제든지 알려주세요 XD