JIN.PROC // v3.1
~/ / 프로젝트 / 2026-04-29-lazyclaude-deep-3-first-paint

[LazyClaude 심층 3] 첫 페인트 차단 풀기 — gzip · defer · dedupe

1.12MB → 270KB. CDN 600KB는 첫 페인트 뒤로 미루기

DATE 2026.04.29 UPDATED 2026.04.29 READ ~ 3 MIN WORDS 531

LazyClaude 심층 시리즈 3편. “단일 HTML로 출시한다”는 결정을 내렸을 때 가장 먼저 부딪히는 벽 — 첫 페인트(FCP/LCP) 를 어떻게 지킬 것인가.

출발점

LazyClaude는 의도적으로 단일 dist/index.html을 배포 단위로 한다. 이유:

  • 패키지·번들러 의존성을 0으로 가져가고 싶다
  • 사용자가 python3 server.py 한 번이면 끝나야 한다
  • 바이너리/패키지 매니저 없이 그냥 파일 로 배포

대신 그 파일이 1.12MB 까지 자랐다. 첫 페인트가 차단되기 시작했다.

세 가지 레버

해결을 한 번에 시도하지 않고 독립적인 세 레버 로 분리했다. 각각이 책임이 다르고, 측정도 따로 한다.

1) 서버 사이드 gzip + mtime 캐시

import gzip, os

_GZ_CACHE = {}    # path -> (mtime, gz_bytes)

def serve_index(path: str) -> bytes:
    mt = os.path.getmtime(path)
    cached = _GZ_CACHE.get(path)
    if cached and cached[0] == mt:
        return cached[1]
    with open(path, "rb") as f:
        gz = gzip.compress(f.read(), compresslevel=6)
    _GZ_CACHE[path] = (mt, gz)
    return gz

응답 헤더:

Content-Encoding: gzip
Vary: Accept-Encoding
Cache-Control: public, max-age=60

결과: 1.12MB → 270KB 와이어 전송. 메모리에는 gz 바이트만 들고 있다가 mtime이 바뀔 때만 다시 압축. 1편의 mtime 캐시 패턴 그대로.

주의: 매 요청마다 gzip 압축을 새로 하지 마라. 같은 파일이라면 압축 결과도 같다.

2) CDN 스크립트 defer

Chart.js, vis-network, marked 등 약 600KB의 외부 스크립트가 첫 페인트를 막고 있었다. 이 라이브러리들은 첫 화면에는 필요 없다:

  • Chart.js — 토큰 사용량 탭에서만
  • vis-network — 워크플로우 캔버스에서만
  • marked — 마크다운 미리보기에서만
<!-- before: blocking -->
<script src="https://cdn.example/chart.js"></script>

<!-- after: defer -->
<script defer src="https://cdn.example/chart.js"></script>

defer의 의미:

속성 다운로드 실행 시점 DOMContentLoaded와의 관계
(없음) 즉시 즉시 (HTML 파싱 차단) 이전
async 즉시 다운로드 완료 직후 비동기
defer 즉시 HTML 파싱 끝난 뒤 직전

차단되지 않으면서도, 사용 시점에는 이미 로드되어 있다.

3) In-flight GET dedupe

같은 GET 요청이 짧은 시간에 여러 번 발사되는 패턴이 있었다. 사이드바 재렌더 + 탭 전환 + 자동 폴링이 겹치면 같은 endpoint를 3~5번 호출.

const inflight = new Map();   // url -> Promise

function getJSON(url) {
  if (inflight.has(url)) return inflight.get(url);
  const p = fetch(url)
    .then(r => r.json())
    .finally(() => inflight.delete(url));
  inflight.set(url, p);
  return p;
}

핵심: 진행 중인 프라미스를 공유 한다. 응답이 끝나면 맵에서 제거. 동일 URL의 동시 요청이 한 번만 네트워크에 도달한다.

측정 — 차단 시간이 핵심

페이지 무게(byte)만 보면 안 된다. 첫 페인트에 영향을 주는 차단 리소스 의 비율이 더 중요하다.

메트릭 이전 이후
index.html 와이어 1.12MB 270KB
첫 페인트 차단 JS ~600KB ~0KB
동일 endpoint 동시 요청 3~5×
LCP (3G slow) 4.8s 1.6s

LCP는 Lighthouse Mobile 3G slow 프로파일 기준. 느린 환경 에서 측정해야 차이가 보인다.

첫 페인트 후의 로딩 — Lazy fetch

첫 페인트가 빨라지면, 진짜 무거운 데이터 를 어디로 미룰지가 다음 문제다. LazyClaude는 두 가지 패턴을 쓴다:

  1. First paint 후 인젝션: 무거운 패널은 빈 placeholder만 그리고, _renderRecentBlocksPanel() 같은 함수가 첫 페인트 뒤에 호출되어 데이터를 끼워 넣는다.
  2. 탭 onActivate: 탭이 처음 활성화될 때만 fetch. 안 누른 탭의 데이터는 영원히 받지 않는다.

원칙: 사용자가 본 적 없는 데이터를 미리 받지 마라.

단일 HTML의 장점이 사라지지 않게

이 모든 최적화를 적용해도, 배포 단위는 여전히 하나의 HTML 파일 이다. 내부에 임포트가 들어가면 그 순간 빌드 도구가 필요해진다. LazyClaude는 그 선을 의도적으로 안 넘는다 — 외부 스크립트는 CDN(<script defer src=…>), 내부 코드는 인라인.

한 줄 요약

첫 페인트는 바이트 수 가 아니라 차단 시간 의 문제다. 줄이고, 미루고, 합쳐라.


다음 편: LazyClaude 심층 4 — PWA + macOS .app 번들 (zero-deps 배포) 이전 편: LazyClaude 심층 2 — 워크플로우 캔버스 최적화