Lighthouse 모바일 점수가 74점이었던 진짜 이유: 범인은 폰트였다

캔버스 애니메이션을 의심하고 고쳤는데 점수는 1점밖에 안 올랐다. 진짜 범인은 따로 있었고, 실제 트레이스를 떠보고 나서야 잡았다.

2026년 7월 24일 · 8 min read


블로그를 모바일 Lighthouse로 돌려봤더니 Accessibility·Best Practices·SEO는 다 100점인데 Performance만 74점이 나왔다. 나머지가 죄다 만점이다 보니 이 74점이 유독 눈에 밟혔다. 분명 뭔가 하나가 확실하게 발목을 잡고 있다는 뜻이었다.

코드부터 훑었다

숫자만 봐서는 답이 안 나오니 랜딩 페이지 코드를 위에서부터 읽어 내려갔다. 그러다 보니 그럴듯한 후보가 셋 나왔다. 히어로에 깔린 ShootingStars 캔버스가 마운트되자마자 requestAnimationFrame 루프를 돌리고 있었는데, 하필 이 구간이 Lighthouse가 TBT(Total Blocking Time)를 재는 시점과 정확히 겹쳤다. 코드블록에서만 쓰는 Geist Mono는 정작 layout.tsx에서 기본 옵션으로 로드되면서 모든 페이지에서 preload되고 있었고, 랜딩의 최신글 카드는 3장 전부 priority를 받아 커버 이미지 3개가 한꺼번에 preload되고 있었다.

셋 다 그럴듯한 병목이라 브랜치를 하나씩 나눠 고쳤다. 캔버스는 requestIdleCallback으로 애니메이션 시작을 첫 페인트 뒤로 미루면서 모바일에서는 별·유성 개수까지 줄였고, Geist Monopreload: false로 돌렸고, 카드는 첫 번째 것만 priority를 남겼다.

// ShootingStars.tsx — 정적 프레임을 먼저 그리고, 시작은 미룬다
resize();
drawScene(scene); // 빈 캔버스로 보이지 않게 일단 한 번 그림
 
if (typeof window.requestIdleCallback === "function") {
  idleId = window.requestIdleCallback(onDeferralDone, { timeout: 1200 });
} else {
  idleTimeoutId = setTimeout(onDeferralDone, 200);
}

typecheck·lint·빌드까지 통과시키고 develop에 병합하고 나니 여기까지는 순조로웠다.

프로덕션으로 다시 재봐도 1점

npm run build && npm run start로 다시 띄우고 재측정했다. 이번엔 진짜 프로덕션 빌드니 기대를 좀 했는데, 결과는 74에서 75로 딱 1점 오르는 데 그쳤다. 세 가지나 고쳤는데 이 정도밖에 안 움직인다는 게 아무래도 이상해서, 여기서 더 추측하는 대신 실제 트레이스를 떠보기로 했다.

진짜 범인을 찾으러 트레이스를 떴다

Lighthouse CLI를 로컬 프로덕션 서버에 직접 붙여서 돌렸다.

npx lighthouse http://localhost:3001/ \
  --only-categories=performance \
  --form-factor=mobile --screenEmulation.mobile \
  --throttling-method=simulate \
  --output=json --chrome-flags="--headless=new"

JSON 결과를 까봤더니 TBT는 이미 0ms였다. 캔버스 수정이 제대로 동작한다는 거였는데 LCP(Largest Contentful Paint)가 12.2초였다. 점수를 깎아 먹는 진짜 범인은 처음에 의심했던 셋이 아니라 이거였다.

페이지 무게를 까보니

network-requests 항목을 전송 크기 순으로 정렬해서 훑어보니 답이 바로 보였다. public/fonts에 있는 Pretendard 폰트 두 개, Bold 800KB와 Medium 788KB가 페이지 전체 전송량 1.95MB 중 82%를 차지하고 있었다. 한글 완성형 11,172자를 통째로 담고 있는 서브셋 미적용 폰트라 원래 크다는 건 알고 있었는데, 이 정도로 결정적인 비중일 줄은 몰랐다.

next/font/local은 기본값이 preload: true라 이 두 파일이 모든 페이지에서 고우선순위로 preload된다. 그런데 Lighthouse 모바일 시뮬레이션은 다운로드 속도를 1.6Mbps(≈184KB/s)로 잡아두기 때문에, 1.6MB를 그 속도로 받으면 계산상 9초 가까이 걸린다. LCP가 12.2초로 튄 이유가 여기서 거의 다 설명됐다.

display: swap이 걸려 있어서 폴백 폰트로 텍스트 자체는 바로 보인다. 문제는 "보이느냐"가 아니라, 이 두 파일이 고우선순위 preload로 걸려서 정작 LCP 이미지 같은 다른 리소스가 대역폭을 못 받고 뒤로 밀린다는 점이었다.

고친 건 한 줄이었다

Geist Mono에 이미 해뒀던 것과 똑같이 Pretendard도 preload: false로 바꿨다.

const pretendard = localFont({
  src: [
    { path: "../../public/fonts/Pretendard-Medium.woff2", weight: "500", style: "normal" },
    { path: "../../public/fonts/Pretendard-Bold.woff2", weight: "700", style: "normal" },
  ],
  variable: "--font-pretendard",
  display: "swap",
  preload: false, // 여기 한 줄
});

이미 display: swap이 있었던 덕분에 시각적으로는 아무것도 안 바뀐다. 폴백 폰트로 텍스트가 바로 보이고 폰트가 도착하면 자연스럽게 교체되는 흐름은 그대로고, 달라지는 건 이 1.6MB가 더 이상 초기 로드 경로에서 다른 리소스와 대역폭을 다투지 않는다는 것뿐이다.

다시 빌드하고 재측정하니 Performance가 75에서 95로, LCP는 12.2초에서 2.9초로, 총 전송량은 1.95MB에서 361KB로 떨어졌다. 한 줄 고쳤다고 이렇게까지 바뀌나 싶을 정도였다. 처음에 진짜 범인이 아니었던 셋을 먼저 고쳤던 것도 헛수고는 아니었던 게, TBT가 이미 0ms였던 덕분에 이번 수정 효과가 LCP 개선으로 고스란히 드러날 수 있었다.

서브셋까지는 안 갔다

95점이면 이미 충분히 좋은 점수였다. 남은 5점을 더 짜내려면 폰트 자체를 서브셋해서 800KB를 수십~수백KB로 줄이는 작업이 남아있긴 한데, 로컬에 fonttools를 새로 설치하고 실제 쓰이는 글자만 추려서 폰트를 재생성해야 하는 손이 더 가는 작업이라 지금 당장 필요한 정도는 아니라고 판단해 여기서 멈췄다. 나중에 다시 손댈 일이 있으면 그때 하기로.

총평

세 가지를 먼저 고쳤는데 점수가 안 움직여서 당황했지만 알고 보니 테스트 방법이 잘못됐던 거였고, 방법을 고쳐도 겨우 1점만 올라서 또 당황했지만 이번엔 진짜 범인이 따로 있었다. 추측만으로 두 번이나 헛다리를 짚을 뻔했는데, 실제 트레이스를 한 번 떠보고 나서야 정확한 숫자로 원인을 짚을 수 있었다. 코드를 읽고 "이게 문제겠지" 하고 넘겨짚는 것과, 실제로 뭐가 몇 바이트를 몇 초에 받아오는지 찍어보는 건 역시 다른 이야기다.

관련 글