홈을 바꾼 뒤에도 느렸던 LCP
블로그 홈의 첫 화면을 사진 히어로에서 작은 SVG 에디터로 바꾸면서 Lighthouse로 전후를 쟀습니다. 모바일 LCP는 17.4초에서 13.7초로 줄었지만, 13초대는 여전히 느렸습니다.
측정 결과를 보면 LCP 요소는 히어로의 소개 문단이었고, 시간 대부분이 렌더 지연(render delay)에 들어가 있었습니다. LCP 요소가 텍스트이고 그리기까지 오래 걸린다면, 그 텍스트를 그릴 글꼴부터 확인해 볼 만합니다.
이 블로그는 본문 글꼴로 Pretendard를 직접 호스팅하고 있었습니다. 굵기마다 전체 글리프가 들어 있는 woff2 파일 하나씩이었고, 크기는 이랬습니다.
| 파일 | 크기 |
|---|---|
| Pretendard-Regular.woff2 | 766KB |
| Pretendard-Medium.woff2 | 778KB |
| Pretendard-SemiBold.woff2 | 786KB |
| Pretendard-Bold.woff2 | 791KB |
홈 첫 화면은 이 중 세 굵기를 썼고, Lighthouse 기록에는 폰트 요청 3개, 전송량 2,276KB가 찍혀 있었습니다. 첫 화면에 쓰이는 한글은 수백 자인데, 한글 11,172자와 라틴·기호 글리프가 모두 든 파일을 굵기별로 받고 있었습니다.
앞선 히어로 작업에서도 같은 문제를 한 번 겪었습니다. 처음에 H1을 font-bold로 두었더니 홈이 원래 쓰지 않던 Bold 파일(약 770KB)을 새로 받으면서 LCP가 오히려 18.6초로 늘었습니다. H1을 이미 쓰고 있던 semibold로 바꿔 요청을 하나 줄이는 것으로 그때는 넘어갔습니다. 굵기 하나를 추가할 때마다 수백 KB가 붙는 구조 자체를 바꿀 필요가 있었습니다.
서브셋 방식 고르기
글꼴 파일을 줄이는 방법은 크게 두 가지를 봤습니다.
| 방식 | 파일 | 굵기당 크기 | 특징 |
|---|---|---|---|
| KS X 1001 서브셋 | 굵기당 1개 | 약 267KB | 자주 쓰는 한글 2,350자만 남김. 빠진 글자는 대체 글꼴로 보임 |
| dynamic subset | 굵기당 92조각 | 조각당 수 KB~수십 KB | unicode-range로 나눈 조각 중 페이지에 쓰인 글자가 든 조각만 받음 |
KS X 1001 서브셋은 파일이 하나라 다루기 쉽지만, 첫 화면에서 굵기 세 개를 쓰면 여전히 800KB 가까이 받아야 합니다. 글에 드문 글자가 나오면 그 글자만 다른 글꼴로 보이는 문제도 있습니다.
dynamic subset은 Pretendard가 공식으로 제공하는 방식입니다. 전체 글리프를 92개 조각으로 나누고, 조각마다 @font-face에 unicode-range를 붙여 둡니다. 브라우저는 페이지에 실제로 쓰인 글자가 들어 있는 조각만 내려받습니다. 글자가 빠지는 문제가 없고, 페이지마다 필요한 만큼만 받습니다. 이쪽으로 정했습니다.
조각은 어떻게 나뉘어 있나
처음에는 사이트에 쓰인 글자를 미리 뽑아서 받을 조각을 정하는 방식인 줄 알았는데, 그렇지 않았습니다. 나누는 일은 Pretendard 배포본에 이미 되어 있고, 어떤 조각을 받을지는 브라우저가 페이지를 그릴 때 정합니다.
조각 파일을 열어 보면 자주 쓰는 글자일수록 번호가 큰 조각에 모여 있습니다.
| 조각 | 글자 수 | 크기(Regular) | 들어 있는 글자 |
|---|---|---|---|
| 91번 | 107자 | 19KB | 영문, 숫자, 문장 부호 |
| 88~90번 | 95~100자 | 9~11KB | 가, 개, 거, 것, 고, 과처럼 자주 쓰는 한글 (코드 순서와 상관없이 흩어져 있음) |
| 80번 | 100자 | 12KB | à, 그리스 문자, ①, ■ 같은 특수 문자 |
| 1~40번대 | 150자 안팎 | 12KB 안팎 | 연속된 코드 범위의 드문 한글 |
| 0번 | 154자 | 18KB | 한자 호환 문자, 전각 기호 |
fonts.css의 각 @font-face에는 그 조각이 담당하는 글자 범위가 unicode-range로 적혀 있습니다. 브라우저는 페이지에 실제로 쓰인 글자를 보고, 그 글자를 담당하는 조각만 내려받습니다. 실제 파일 기준으로 확인해 보면 이렇습니다.
- "가나다" → 90번, 91번 조각
- "프론트엔드" → 87번, 90번 조각
- "뷁", "똠" → 38번, 49번 조각
보통의 한국어 문장은 대부분 80~91번 조각에서 해결되고, 드문 글자가 나온 페이지에서만 앞쪽 조각을 추가로 받습니다. 한 번 받은 조각은 캐시돼서 다른 페이지에서는 다시 받지 않습니다.
사이트에 쓰인 글자만 미리 뽑아 폰트를 만드는 방식도 있습니다. 빌드할 때 모든 글의 글자를 모아 pyftsubset 같은 도구로 그 글자만 든 파일을 만드는 방식입니다.
| 빌드 때 글자 추출 | dynamic subset | |
|---|---|---|
| 파일 크기 | 가장 작음 | 조금 더 큼 (조각에 안 쓰는 글자도 섞여 있음) |
| 글을 추가·수정할 때 | 폰트를 다시 만들어야 함 | 할 일 없음 |
| 댓글처럼 미리 알 수 없는 글자 | 그 글자만 다른 글꼴로 보임 | 문제없음 |
이 블로그는 글을 계속 추가하고, 댓글로 미리 알 수 없는 글자도 들어옵니다. 그래서 파일이 조금 더 크더라도 관리할 것이 없는 dynamic subset을 그대로 썼습니다.
적용
파일 가져오기
외부 CDN의 CSS를 그대로 쓰는 방법도 있지만, 기존처럼 직접 호스팅을 유지했습니다. 글꼴 요청이 다른 도메인으로 나가지 않고, 배포와 같이 버전이 고정됩니다.
npm의 pretendard 패키지(v1.3.9)를 받아 dist/web/static/woff2-dynamic-subset/의 조각 파일을 public/fonts/pretendard/로 옮겼습니다. 이 블로그는 400~700 네 굵기만 쓰기 때문에 그 굵기의 조각만 가져왔습니다. 92조각 × 4굵기 = 368개 파일입니다.
@font-face 생성
패키지에 들어 있는 pretendard-dynamic-subset.css에는 Thin부터 Black까지 아홉 굵기의 규칙이 모두 있고, woff 경로까지 함께 적혀 있습니다. 필요한 굵기만 남기고 경로를 바꾸는 스크립트로 fonts.css를 새로 만들었습니다.
const keep = new Set(["400", "500", "600", "700"]);
const faces = [...css.matchAll(/@font-face\s*\{([^}]*)\}/g)].map((m) => m[1]);
const rules = faces
.filter((face) => keep.has(face.match(/font-weight:\s*(\d+)/)[1]))
.map((face) => {
const weight = face.match(/font-weight:\s*(\d+)/)[1];
const file = face.match(/woff2-dynamic-subset\/([^)]+\.woff2)/)[1];
const range = face.match(/unicode-range:\s*([^;]+);/)[1];
return `@font-face{font-family:"Pretendard";font-weight:${weight};font-display:swap;` +
`src:url("/fonts/pretendard/${file}") format("woff2");unicode-range:${range}}`;
});
결과물은 이런 규칙 368개입니다.
@font-face {
font-family: "Pretendard";
font-weight: 400;
font-display: swap;
src: url("/fonts/pretendard/Pretendard-Regular.subset.91.woff2") format("woff2");
unicode-range: U+20-22, U+27-2a, U+2c-39, U+41-4e, U+61-7b /* … 라틴 기본 글자와 문장 부호 */;
}
fonts.css는 200KB가 됐습니다. CSS가 커지면 렌더링을 막는 시간이 늘어나니 압축 크기를 따로 확인했는데, brotli로 9.3KB였습니다. 굵기마다 같은 unicode-range 문자열이 반복돼서 압축이 잘 됩니다.
쓰지 않게 된 전체 글리프 파일 9개(Thin~Black, 약 6.9MB)는 저장소에서 지웠습니다.
측정 결과
같은 조건에서 전후를 쟀습니다. 로컬 프로덕션 빌드(next start), Lighthouse 12.8.2 모바일 시뮬레이션 스로틀링, 홈을 3번씩 재서 중앙값을 썼습니다.
| 측정 | 변경 전 | 변경 후 |
|---|---|---|
| LCP | 13,667ms | 4,540ms |
| FCP | 12,613ms | 3,619ms |
| 폰트 요청 | 3개 | 25개 |
| 폰트 전송량 | 2,276KB | 297KB |
| CLS | 0.000 | 0.012 |
요청 수는 3개에서 25개로 늘었지만 전송량은 8분의 1로 줄었습니다. 홈 첫 화면에 쓰인 글자가 든 조각만 받았기 때문입니다. 브라우저에서 직접 열어 보니 Regular 13조각, Medium 1조각, SemiBold 11조각을 받았고, 글꼴 로딩 오류나 글자가 빠진 요소는 없었습니다.
변경 후 3회 측정값은 LCP 2,882 / 4,540 / 4,672ms로 변경 전(13,772 / 13,664 / 13,667ms)보다 흔들림이 컸습니다. 조각 요청이 여러 개로 나뉘면서 도착 순서에 따라 결과가 달라진 것으로 보입니다.
새로 생긴 CLS 0.012
변경 전에는 0이던 CLS가 0.012가 됐습니다. Lighthouse의 레이아웃 이동 항목을 보면 원인으로 Pretendard 조각 파일들이 "Web font loaded"로 찍혀 있었고, 움직인 요소는 히어로의 H1이었습니다.
글꼴을 기다리는 시간이 짧아지면서, H1이 대체 글꼴로 먼저 그려졌다가 Pretendard 조각이 도착한 뒤 다시 그려지며 줄 너비가 바뀐 것입니다. 0.012는 Google이 "좋음"으로 보는 기준(0.1)보다 한참 낮아서 그대로 두었습니다. 더 줄이려면 대체 글꼴에 size-adjust를 맞춘 @font-face를 따로 정의해 두 글꼴의 너비 차이를 줄이는 방법이 있습니다.
다음에 할 것
- 대체 글꼴의
size-adjust를 맞춰 CLS를 다시 0에 가깝게 만들기 - 4.5초도 모바일 기준으로는 느린 편이라, 렌더링을 막는 다른 리소스(CSS, 스크립트)를 같은 방식으로 다시 측정하기
- 실제 사용자 환경의 수치는 Vercel Speed Insights에서 따로 확인하기