Linear는 어떻게 이렇게 빠른가? 기술적 분석

1 hour ago 2
  • Linear는 이슈 관리 작업을 브라우저 내 데이터베이스와 로컬 우선 동기화로 처리해, 이슈 업데이트가 몇 밀리초 안에 UI에 반영되는 생산성 도구
  • UI가 읽는 실제 데이터베이스는 IndexedDB에 있고, 변경은 로컬에 먼저 적용된 뒤 서버로 비동기 전송되고 WebSocket으로 델타를 다시 배포하는 구조
  • 첫 로드는 적은 JavaScript·CSS 전송, 공격적 코드 분할, modulepreload, 서비스 워커 프리캐시, 인라인 앱 셸로 네트워크 대기를 줄이는 로딩 전략
  • 동기화 엔진은 IndexedDB 데이터를 MobX 객체 풀로 수화하고, 변경을 트랜잭션 큐에 저장하며, 필드 단위 관찰 가능 상태로 필요한 셀만 다시 렌더링하는 구현
  • 빠른 체감 속도는 키보드 중심 입력, 전역 명령 팔레트, GPU 친화적 애니메이션, 짧은 전환 시간까지 합쳐진 시스템 설계의 결과

브라우저 안의 데이터베이스

  • 전통적인 CRUD 웹앱은 사용자의 클릭 뒤 HTTP 요청, 서버의 데이터베이스 조회, 응답 수신, 브라우저 리페인트를 거치며 수백 밀리초 동안 스피너·스켈레톤·멈춘 UI가 발생
  • Linear는 UI가 읽는 실제 데이터베이스를 브라우저의 IndexedDB에 두고, 변경은 로컬에 먼저 적용한 뒤 서버로 비동기 전송하며, 서버는 WebSocket으로 다른 클라이언트에 델타를 브로드캐스트
  • 빠른 웹앱에서 가장 큰 병목은 네트워크이며, 클라이언트와 서버 사이의 데이터 전송은 수백 밀리초 비용을 발생
  • Linear의 핵심 흐름은 네트워크 요청을 사용자에게 보이지 않게 만들고, 가능한 로딩 상태를 없애는 방식
// Linear issue.title = "Faster app launch"; issue.save();
  • issue.title = "Faster app launch"는 메모리 내 데이터 저장소를 갱신하고, Linear의 경우 MobX observable을 사용
  • issue.save();는 동기화 엔진이 배치 처리해 서버로 플러시할 트랜잭션을 큐에 넣는 동작
  • UI는 로컬 메모리 변경을 기준으로 동기적으로 다시 렌더링되며, 데이터 동기화는 백그라운드에서 진행되므로 스피너가 필요 없음
  • Tuomas는 2024년 컨퍼런스에서 Linear에서 처음 작성한 코드가 동기화 엔진이었다고 말했으며, 스타트업에서 일반적이지 않은 접근이었다는 표현 사용
  • 대부분의 앱은 Linear처럼 자체 동기화 엔진을 만들 필요가 없으며, TanStack QuerySWR의 낙관적 업데이트만으로도 상당히 가까운 체감 속도 구현 가능
  • 낙관적 요청은 불필요한 스피너 제거, 즉시 상태 업데이트, 백그라운드 검증, 필요 시 롤백을 통해 높은 개선 효과 제공
  • UI 반응성은 네트워크 지연에 의존하지 않아야 하며, 사용자가 느끼는 속도는 서버 응답 속도보다 인터페이스 반응 속도에 의해 결정
  • Linear의 스택 엿보기

    • Linear는 React, TypeScript, MobX, Postgres, CDN 같은 단순한 스택 위에 구축
    • 프런트엔드는 React와 react-dom, MobX, TypeScript, Rolldown-Vite와 plugin-react-oxc, ProseMirror와 y-prosemirror, Radix UI primitives, Emotion과 StyleX, Comlink, idb, graphql-request, Sentry, Inter Variable 사용
    • 백엔드는 Node.js와 TypeScript, Cloud SQL 위 PostgreSQL, Memorystore Redis, turbopuffer, GCP의 Kubernetes, Cloudflare Workers 사용
    • 데스크톱 클라이언트는 Electron 기반이며, 모바일은 iOS용 Swift와 Kotlin으로 별도 전체 재구현
    • 마케팅 사이트는 Next.js, styled-components, 인라인 SVG sprite 사용
    • Linear는 클라이언트 사이드 렌더링을 유지하며, 올바른 아키텍처와 디자인이 있으면 CSR도 즉각적으로 느껴질 수 있다는 사례
    • 앱 전체를 클라이언트 사이드로 유지하면 서버·클라이언트 구분, window 접근 가능 여부, 캐시 헤더 설정 같은 복잡성을 줄이는 단순한 정신 모델 확보

첫 로드를 즉각적으로 느끼게 만들기

  • 생산성 도구에서 실제 작업을 시작하기까지 걸리는 시간은 중요한 세부 요소
  • 클라이언트 사이드 앱의 초기 로드는 index.html 요청, JavaScript와 CSS 요청, 인증 처리, 앱 표시를 위한 API 요청 순서로 느려질 수 있음
  • Linear의 번들러 흐름: Parcel, Rollup, Vite, Rolldown

    • 즉각적인 체감 속도는 런타임 이전인 빌드 타임에서 시작하며, 빠른 로드를 위해 전송하는 JavaScript와 CSS 양을 줄이는 작업이 중요
    • Linear는 빌드 파이프라인을 Parcel → Rollup → Vite → Rolldown 순서로 다시 작성했으며, 각 이전은 JavaScript·CSS 양 감소와 개발자 경험 개선을 목표로 함
    • Linear 블로그 기준 개선 수치
    • 전송 코드 50% 감소
    • 압축 후 크기 30% 감소
    • 콜드 캐시 페이지 로드 10~30% 개선
    • Safari에서 active-issues 뷰의 Time-to-first-paint 59% 감소
    • 메모리 사용량 70~80% 감소
    • 개선의 상당 부분은 최신 브라우저만 대상으로 삼는 결정, 더 나은 dead-code elimination, 공격적 코드 분할의 조합에서 발생
    • 레거시 지원 중단은 polyfill, ES5 트랜스파일, nomodule fallback 제거로 이어지는 큰 이점
    • Linear는 최적화 이후에도 약 21MB의 minified JavaScript를 전송하지만, 이를 수백 개의 라우트 수준 청크로 공격적으로 분할해 필요할 때 가져오는 방식
    • 핵심은 특정 번들러 선택이 아니라 레거시 브라우저 제거, 네이티브 ESM 전환, 적극적 코드 분할
    • 이런 단계들이 누적되며 Linear의 첫 로드 JavaScript는 대략 절반으로 줄고, 빌드 시간은 한 자릿수 차원이 아니라 한 규모 단위로 감소
  • 초기 로드 이후 프리로드

    • JavaScript를 작은 청크로 나누면 각 청크가 다른 청크를 import하는 폭포형 로드 문제가 생김
    • Linear는 JavaScript 실행 전 브라우저가 전체 목록을 보고 요청을 병렬로 시작하게 만들어, entry script가 첫 import에 도달할 때 관련 청크가 이미 캐시에 들어가도록 구성
    • <head>의 modulepreload와 entry script의 crossorigin 값을 맞춰, 브라우저가 preload와 import를 별도 리소스로 취급하지 않고 캐시된 fetch를 재사용
    • 콜드 로드 타임라인은 순차 폭포에서 단일 병렬 배치로 바뀌며, 네트워크 작업은 여전히 존재하지만 한꺼번에 수행
    • 사용자가 로그인 페이지에 처음 도달한 시점에 백그라운드에서 이 작업을 수행하고, 몇 초 뒤 전체 앱을 캐시에 저장해 즉시 제공 가능
  • 더 빠른 속도와 오프라인 기능을 위한 서비스 워커

    • 사용자가 아직 방문하지 않은 뷰의 라우트 수준 청크는 서비스 워커가 백그라운드에서 캐시
    • 서비스 워커는 소스에 내장된 precache manifest를 갖고 있으며, 약 1,200개의 해시된 asset이 라우트 청크, 아이콘, 폰트를 포괄
    • 로그인 화면에 도달한 뒤 몇 초 안에 전체 앱이 캐시에 들어가는 구조
    • 이후 탐색은 네트워크를 완전히 건너뛰고, 서비스 워커가 HTTP 캐시를 거치지 않고 자체 캐시에서 직접 응답
    • 로컬 우선 동기화 엔진과 IndexedDB에 이미 저장된 사용자 데이터가 결합되면 Linear는 오프라인에서도 사용 가능
    • 이슈 읽기, 새 이슈 생성, 제목과 설명 편집, 상태 변경 지원
    • 모든 작업은 로컬 트랜잭션 저장소에 큐잉되고, 연결이 돌아오면 플러시
    • modulepreload는 지금 필요한 것을 병렬로 가져와 브라우저가 직렬 import chain에서 막히지 않게 하는 장치
    • 서비스 워커는 다음에 필요할 것을 준비하는 장치
    • Linear의 빠른 로딩 단계는 가능한 많은 코드 제거, 작은 조각으로 분할, 백그라운드 프리캐시이며, 목표는 네트워크 요청을 빠르게 만들거나 완전히 제거하는 것
  • Vendor 번들 구성

    • Linear가 사용하는 각 패키지는 별도 청크로 나뉘고 독립적으로 캐시
    • 전통적인 vendor.js는 의존성 하나만 업데이트해도 전체 의존성 그래프 캐시가 무효화
    • Linear의 청크 분할은 단일 대형 파일 대신 세밀한 vendor 캐싱을 만들며, 특정 의존성 업데이트 시 해당 청크 하나만 무효화되고 나머지는 캐시에 유지
  • 큰 폰트 파일 로딩

    • 잘못된 폰트 로딩은 잠시 보이지 않는 텍스트, 실제 폰트 교체 시 레이아웃 시프트, preload 불일치로 인한 중복 fetch를 만들 수 있음
    • Linear는 <head>에서 Inter Variable 폰트를 preload하고, static.linear.app에 preconnect
    <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>
    • Variable font는 100~900 전체 weight 축을 단일 woff2로 처리해 weight별 요청 제거
    • font-display: swap은 fallback stack을 즉시 렌더링하고, Inter 로드 완료 후 교체
    • preload 태그의 crossorigin="anonymous"는 CSS가 이후 같은 폰트를 참조할 때 브라우저가 캐시된 리소스를 재사용하게 만드는 핵심 설정
    • crossorigin이 없으면 preload와 CSS 참조의 CORS 모드가 달라져 브라우저가 폰트를 다시 가져오는 문제 발생
  • 인라인 앱 셸

    • Linear는 <head> 안에 로딩 상태를 그리기에 충분한 CSS를 인라인으로 넣어 외부 스타일시트 요청 없이 앱 셸을 표시
    • 인라인 JavaScript는 초기 경험에 필요한 분기를 즉시 수행
    • Electron과 Linear user agent를 감지해 electron 클래스 추가
    • localStorage.ApplicationStore가 없으면 logged-out 클래스 추가
    • localStorage.splashScreenConfig에서 사이드바 배경, 사이드바 폭, 다크 모드 같은 shell token 복원
    • 사용자가 데스크톱 앱에서 링크를 열도록 설정한 경우 사이드바 폭을 8px로 조정
    • 첫 JavaScript 번들이 네트워크에서 도착하기 전에 로딩 화면은 로그인 여부에 맞게 테마, 크기, 위치가 이미 맞춰진 상태
    • 사용자가 URL 입력 뒤 Enter를 누르는 즉시 앱이 준비된 것처럼 느끼게 만드는 가장 빠른 방법은 초기 index.html 응답에 앱 셸을 내려보내는 방식
  • 먼저 렌더링하고 나중에 인증

    • 일반적인 인증 흐름은 HTML fetch, 번들 로드, 세션 검증, 사용자 fetch, 워크스페이스 fetch, 렌더 순서로 진행되며, 사용자가 무언가를 보기까지 1~3초가 걸릴 수 있음
    • Linear는 인증도 변경 처리와 같은 방식으로 다루며, 정상 경로를 가정하고 백그라운드에서 검증
    • 대부분의 CRUD 앱은 실제 세션을 HttpOnly 쿠키에 두고, 프런트엔드가 시작 중 로그인 여부를 알 수 있도록 JavaScript에서 읽을 수 있는 별도 쿠키나 /me 요청을 추가
    • Linear의 인라인 부트 스크립트는 병렬 인증 신호 대신 localStorage.ApplicationStore 존재 여부만 확인
    if (localStorage.getItem("ApplicationStore") === null) { document.documentElement.classList.add("logged-out"); }
    • ApplicationStore가 있으면 사용자가 이 브라우저에서 Linear를 사용한 적이 있고, 워크스페이스 데이터가 IndexedDB에 이미 있는 상태
    • 값이 없으면 렌더링할 데이터가 없으므로 shell이 logged-out 레이아웃으로 전환되고 로그인 흐름이 이어짐
    • 실제 세션 토큰은 쿠키에 있으며, 번들은 세션 상태를 미리 판단하지 않음
    • WebSocket handshake, sync delta, HTTP 호출 중 하나가 만료된 세션으로 401을 받으면 클라이언트가 로그인으로 리디렉션
    • 전체 패턴은 로컬 데이터를 신뢰해 즉시 렌더링하고, 서버를 정확성의 출처로 두며, 양쪽을 비동기로 조정하는 구조

동기화 엔진

  • Linear의 속도는 서버를 UI의 source of truth가 아니라 sync target으로 보는 결정에서 출발
  • 속도는 단일 요소가 아니라 세 가지 축이 맞물린 결과
  • 1. 데이터가 이미 있음

    • 앱 부팅 시 서버에서 워크스페이스를 가져오지 않고, IndexedDB에서 메모리 내 MobX 객체 풀로 수화
    • UI의 모든 쿼리는 객체 풀을 먼저 향하며, 이슈가 이미 사용자 기기에 있으므로 “loading issues” 상태가 없음
    • Linear는 확장 과정에서 JavaScript 번들과 비슷한 원리로 동기화 엔진의 데이터를 청크화
    • 가장 무거운 두 테이블인 Issue와 Comment는 한꺼번에 가져오지 않고 필요할 때 lazy-hydrate
    • 이 방식은 데이터 수준 코드 분할이며, 시작 비용이 워크스페이스 크기가 아니라 워크스페이스 구조를 따라가게 만듦
    • 10,000개 이슈가 있는 워크스페이스도 100개 이슈 워크스페이스와 거의 비슷한 속도로 부팅
    • 프로젝트로 들어가면 이슈가 이미 있고, assignee로 필터링하면 인덱스가 이미 구축된 상태
  • 2. 변경은 네트워크를 기다리지 않음

    • 이슈 상태를 바꾸면 세 가지가 거의 동시에 발생
    • MobX observable 업데이트로 UI에 변경 반영
    • IndexedDB의 내구성 있는 트랜잭션 큐에 변경 기록
    • 서버 전송 큐에 변경 추가
    • 이 시점에서 네트워크는 아직 사용되지 않음
    • 사용자는 자신의 변경을 보기 위해 기다리지 않으며, 재시도, 롤백, reload across durability는 모두 백그라운드에서 처리
    • 서버가 거부하면 observable이 되돌아가고 짧은 flicker가 발생하지만, 대부분의 잘못된 변경은 트랜잭션 생성 전에 잡힘
    • Linear의 흐름은 로컬 변경에서 시작하고 서버를 허가 단계가 아니라 확인 단계로 취급
  • 3. 하나의 델타, 하나의 셀

    • 서버가 사용자의 변경이나 다른 사람의 변경을 확인하면, 무엇이 이동했는지 나타내는 작은 JSON envelope가 클라이언트로 돌아옴
    • 클라이언트는 해당 MobX observable에 값을 쓰는 방식으로 변경 적용
    • Linear의 모든 모델 속성은 각각 observable이며, 해당 속성을 읽는 모든 컴포넌트는 observer()로 감싸짐
    • MobX는 어떤 컴포넌트가 어떤 필드에 의존하는지 정확히 알 수 있음
    • 한 이슈의 한 필드 변경은 그 필드를 읽는 컴포넌트만 다시 렌더링하며, 부모 목록이나 사이드바 전체를 다시 렌더링하지 않음
    • 50개 이슈 업데이트는 목록 전체 리렌더링이 아니라 50개 셀 리렌더링
    • 10명이 동시에 편집하는 바쁜 워크스페이스에서도 업데이트 수신 비용은 화면에 있는 전체 항목이 아니라 실제로 바뀐 항목에 맞춰 증가
  • 세 가지가 맞물리는 이유

    • 로컬 데이터베이스만 있고 낙관적 쓰기가 없으면 저장 시 여전히 스피너 발생
    • 낙관적 쓰기만 있고 세밀한 observable이 없으면 모든 업데이트에서 버벅임 발생
    • 세밀한 observable만 있고 로컬 데이터베이스가 없으면 초기 로드에서 여전히 대기 발생
    • Linear의 속도는 단일 계층의 속성이 아니라 시스템 전체의 속성
    • 번들러와 로더 셸은 첫 paint를 빠르게 느끼게 만들고, 동기화 엔진은 사용 시작 이후에도 빠른 느낌을 유지

속도를 위한 디자인

  • 속도는 엔지니어링 문제이면서 디자인 문제
  • 가장 빠른 액션 경로가 마우스, 세 개의 메뉴, 클릭을 요구하면 사용자는 내부 엔진 속도와 무관하게 그 단계를 비용으로 지불
  • Linear 속도의 또 다른 축은 키보드를 탐색과 작업 완료의 주요 도구로 통합한 점
  • 모든 일반적인 작업에는 shortcut이 있고, command palette는 한 번의 키 입력으로 열리며, right-click menu는 커스텀으로 구축
  • 모든 액션에는 shortcut이 있음

    • 단일 문자는 포커스된 이슈를 편집하고, 두 글자 조합은 탐색에 사용되며, modifier는 전역 동작에 사용
    • Linear의 초기 단계부터 shortcut은 기반 요소였고, 동기화 엔진은 어떤 액션도 언제든 수행할 수 있도록 설계된 부분이 있음
    • UI 곳곳에서 shortcut이 보이며, 가장 자주 쓰는 shortcut은 단일 문자
    • 초보자를 배제하지 않기 위해 모든 액션은 마우스로도 수행 가능
  • Command palette는 항상 한 번의 키 입력 거리

    • ⌘ k는 Linear의 거의 모든 액션을 검색할 수 있는 command palette를 열음
    • 검색 대상은 이슈, 프로젝트, label, 상태 변경, 탐색, 이슈 생성, 설정, 테마 토글 등
    • command palette는 서버가 아니라 로컬 MobX 객체 풀을 검색하므로 매우 빠름
    • 전체 앱은 단일 pane에서 접근 가능하며, 탐색, 이슈 생성, 상태 변경이 모두 검색을 통해 수행
    • command palette는 현재 작업 맥락에 맞춰 적응하고, 각 view의 핵심 액션과 shortcut을 가르치는 방식으로 동작
    • 빠른 앱에는 뛰어난 엔지니어링과 디자인이 모두 필요하며, 엔지니어링 속도는 단일 상호작용을 빠르게 만들고 디자인 속도는 상호작용까지의 경로를 짧게 만듦
    • 하루 종일 쓰는 도구에서는 shortcut과 2초짜리 마우스 경로의 차이가 모든 액션에서 누적

애니메이션

  • 나쁜 애니메이션은 초기 로드, 업데이트, 데이터베이스 쿼리 최적화로 줄인 밀리초를 마지막 단계에서 다시 낭비할 수 있음
  • 500ms짜리 height animation 같은 요소는 사용자가 기다리지 않게 하려는 노력을 무너뜨릴 수 있음
  • 애니메이션해야 할 속성은 몇 가지뿐

    • 브라우저의 property change는 렌더링 파이프라인상의 위치에 따라 세 계층의 비용을 가짐
    • composited property인 transform과 opacity는 GPU로 작업을 넘기고 main thread와 독립적으로 실행
    • paint-triggering property인 color, background-color, border-color, fill은 layout을 건너뛰지만 pixel redraw를 발생
    • layout-triggering property인 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; }
    • margin-left를 애니메이션하면 hover된 row 아래 모든 row의 layout이 transition 전체 200ms 동안 매 frame 재계산
    • 긴 이슈 목록에서는 이 차이가 부드러운 화면과 jank를 가르는 요소
    • Linear의 애니메이션 속성은 대부분 transform과 opacity 같은 composited property이며, 때때로 background-color와 border-color 사용
  • 절제할 때를 알아야 함

    • 매일 사용하는 도구에서는 마케팅 사이트에서 보기 좋은 애니메이션이 작업을 방해할 수 있음
    • 잘못된 위치의 작은 hover delay도 사용자가 알아차리는 지점이 될 수 있음
    • Linear의 많은 애니메이션은 origin을 참조하기 때문에 동작이 효과적
    • status popover는 status pill에서 scale out하고, agent panel은 toggle에서 slide in
    • 이런 motion은 장식용 fade가 아니라 새 요소가 어디에서 왔는지 알려주는 공간적 역할 수행
  • 짧고 즉각적인 duration 유지

    --speed-highlightFadeIn: 0s; --speed-highlightFadeOut: .15s; --speed-quickTransition: .1s; --speed-regularTransition: .25s; --speed-slowTransition: .35s;
    • 많은 design system은 기본 duration을 필요 이상으로 길게 둠
    • Material의 standard duration은 200ms이고, iOS spring은 350ms에 가까움
    • Linear의 기본값은 업계 관행보다 짧은 쪽에 위치
    • Linear는 enter와 exit에 비대칭 timing을 사용
    • hover highlight, popover, agent panel은 호출될 때 즉시 나타나고, 닫을 때 150ms 동안 fade out
    • agent window는 즉시 나타나고 macOS와 비슷하게 fade out

Linear가 빠른 방식

  • Linear의 성능은 단일 비밀이나 한 가지 기술이 아니라 올바른 수백 개의 결정이 누적된 결과
  • 접근 방식의 많은 부분은 단순하며, Next, TanStack, 화려한 framework 없이도 사용자에게 맞는 아키텍처를 초기에 정하고 유지한 결과
  • 서버는 UI의 source of truth가 아니라 sync target으로 동작
  • 데이터베이스는 브라우저 안에 있고, 변경은 로컬에 먼저 적용된 뒤 백그라운드에서 조정
  • 첫 로드는 더 적은 코드를 더 많은 조각으로 전송하고, 서비스 워커는 사용자가 로그인 페이지에 있는 동안 나머지를 precache
  • 인증은 로컬 상태를 기반으로 정상 경로를 가정하고 나중에 검증
  • 동기화 엔진은 IndexedDB에서 per-property MobX observable로 수화하므로, 50개 이슈 업데이트가 목록 전체 리렌더링이 아니라 50개 셀 리렌더링으로 처리
  • 입력 모델은 keyboard-first이며, 모든 일반 작업에는 shortcut과 전역 command palette가 있음
  • 애니메이션은 GPU 친화적 속성에 머물고, layout-triggering property는 애니메이션하지 않음
  • 어려운 부분은 구현 자체보다 코드베이스가 성숙하고 확장되며 새로운 제약을 만나는 동안 수년간 세부 품질에 집중하는 태도
Read Entire Article