[LazyClaude 심층 3] 첫 페인트 차단 풀기 — gzip · defer · dedupe
1.12MB → 270KB. CDN 600KB는 첫 페인트 뒤로 미루기
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× | 1× |
| LCP (3G slow) | 4.8s | 1.6s |
LCP는 Lighthouse Mobile 3G slow 프로파일 기준. 느린 환경 에서 측정해야 차이가 보인다.
첫 페인트 후의 로딩 — Lazy fetch
첫 페인트가 빨라지면, 진짜 무거운 데이터 를 어디로 미룰지가 다음 문제다. LazyClaude는 두 가지 패턴을 쓴다:
- First paint 후 인젝션: 무거운 패널은 빈 placeholder만 그리고,
_renderRecentBlocksPanel()같은 함수가 첫 페인트 뒤에 호출되어 데이터를 끼워 넣는다. - 탭 onActivate: 탭이 처음 활성화될 때만 fetch. 안 누른 탭의 데이터는 영원히 받지 않는다.
원칙: 사용자가 본 적 없는 데이터를 미리 받지 마라.
단일 HTML의 장점이 사라지지 않게
이 모든 최적화를 적용해도, 배포 단위는 여전히 하나의 HTML 파일 이다. 내부에 임포트가 들어가면 그 순간 빌드 도구가 필요해진다. LazyClaude는 그 선을 의도적으로 안 넘는다 — 외부 스크립트는 CDN(<script defer src=…>), 내부 코드는 인라인.
한 줄 요약
첫 페인트는 바이트 수 가 아니라 차단 시간 의 문제다. 줄이고, 미루고, 합쳐라.
다음 편: LazyClaude 심층 4 — PWA + macOS .app 번들 (zero-deps 배포) 이전 편: LazyClaude 심층 2 — 워크플로우 캔버스 최적화