Next.js 랜딩 페이지의 Lighthouse 점수가 폰트 하나로 해결되지 않는 이유

성능 최적화에서 가장 당황스러운 순간은 개발 환경에서는 빠른데 배포 후 측정값만 크게 느릴 때입니다. 이번 랜딩 페이지가 그랬습니다. 개발 환경에서 관찰한 LCP는 약 1.68초였지만 PageSpeed Insights의 첫 측정은 5.5초까지 늘어났습니다. 이후 배포본을 다시 측정했을 때도 성능 점수 72점, LCP 4.8초가 나왔습니다.

Lighthouse는 Google이 제공하는 웹 품질 진단 도구입니다. 제한된 모바일 네트워크와 CPU를 가정한 실험실 환경에서 FCP, LCP, Speed Index, TBT, CLS 같은 지표를 측정하고 성능 점수를 계산합니다. PageSpeed Insights도 이 Lighthouse 진단을 사용합니다. 다만 네트워크 응답과 실행 시점에 따라 값이 흔들리므로, 한 번의 점수보다 같은 조건에서 반복 측정한 중앙값과 리소스 타임라인을 함께 봐야 합니다.

이번 작업에서도 점수 하나만 쫓았다면 원인을 잘못 결론 내렸을 것입니다. Git 커밋은 해결책의 기록이라기보다, 가설을 세우고 틀린 가설을 되돌린 순서의 기록에 가까웠습니다.

인트로가 LCP를 늦춘다고 생각했습니다

가장 먼저 의심한 것은 2.45초 동안 재생되는 인트로였습니다. 첫 화면을 가리고 있으니 인트로를 끄면 점수가 바로 오를 것이라고 생각했습니다. 실제로 인트로를 비활성화하는 실험 커밋을 만들고 측정한 뒤 원래 길이로 되돌렸습니다.

결과는 단순하지 않았습니다. 인트로가 체감 시작 시점을 바꾸기는 했지만, 5초대 LCP의 근본 원인은 아니었습니다. 타이핑 로테이션을 상호작용 이후로 늦추는 실험도 LCP 후보를 줄이는 데는 도움이 됐지만, 랜딩 페이지의 생동감을 잃는 대가가 컸습니다. 결국 인트로, 텍스트 로테이션, 샤이닝 효과는 모두 유지했습니다. 대신 인트로 직후 타이핑을 시작하고 첫 문구는 0.9초만 보여 준 뒤 다음 문구로 넘어가도록 조정했습니다.

인트로 워드마크가 SVG 경로로 그려진다는 점도 중요했습니다. 웹폰트를 기다리는 텍스트가 아니므로 폰트 최적화와 분리해 판단할 수 있었습니다. 브랜드 연출을 없애지 않고도 폰트 경로를 따로 고칠 수 있다는 뜻이었습니다.

폰트 폴백은 작동했지만 다운로드는 너무 늦었습니다

배포본에 네트워크와 CPU 제한을 걸고 리소스 타임라인과 LCP 후보를 함께 관찰했습니다. 당시 핵심 구간은 다음과 같았습니다.

2,538ms 렌더 차단 CSS 66KB 도착
2,780ms 폴백 폰트로 LCP 후보 확정
5,174ms Pretendard core 213KB 도착
5,468ms WantedSans core 148KB 도착

Pretendard와 시스템 폰트의 메트릭을 맞춘 폴백은 제 역할을 하고 있었습니다. 폰트가 오기 전에도 텍스트가 보였고 줄바꿈이나 요소 크기도 크게 흔들리지 않았습니다. 문제는 CSS 뒤에서 두 웹폰트가 361KB나 전송되고, 마지막 폰트가 5초가 지나서야 도착한다는 점이었습니다.

WantedSans는 히어로 제목 한 곳에만 사용되고 있었습니다. 제목을 Pretendard로 통일해 148KB를 제거했습니다. 이어 font-display: optional을 적용하자 제한된 개발 측정에서 폰트 도착은 5,174ms에서 3,556ms로 빨라졌고, LCP는 단일 후보 1,740ms로 잡혔습니다.

수치만 보면 성공이었지만 실제 첫 방문 경험에는 문제가 있었습니다. optional은 짧은 시간 안에 폰트가 준비되지 않으면 그 페이지에서는 폴백 폰트를 계속 사용할 수 있습니다. 캐시가 없는 첫 방문에서도 브랜드 폰트가 다운로드되는 즉시 반영되기를 원했기 때문에 최종 정책으로는 맞지 않았습니다.

그래서 최종 설정은 swap으로 되돌렸습니다.

@font-face {
font-family: "Pretendard";
src: url("/fonts/pretendard-core.woff2?v=1") format("woff2");
font-display: swap;
}

이 선택에는 분명한 트레이드오프가 있습니다. 캐시가 없는 방문에서는 먼저 메트릭을 맞춘 시스템 폰트가 보이고, Pretendard가 준비되면 글자 모양이 바뀝니다. 대신 폰트가 늦었다는 이유로 그 방문 내내 브랜드 폰트를 포기하지 않습니다. core 폰트는 preload하고, 폴백의 ascent-override, descent-override, size-adjust를 조정해 교체 시 레이아웃 이동을 최소화했습니다.

폰트를 고치고 나니 CSS가 그대로 FCP가 됐습니다

폰트 재페인트를 통제한 뒤에는 LCP가 FCP와 거의 같은 시점에 잡혔습니다. 이때부터 병목은 더 분명해졌습니다. 약 67KiB가 전송되는 초기 CSS가 도착해야 첫 화면을 그릴 수 있었습니다. PageSpeed 진단에서는 렌더 차단 리소스를 줄였을 때 약 450ms를 절약할 수 있다고 추정했습니다.

처음에는 Next.js의 자동 CSS 최적화 옵션으로 Critical CSS가 인라인되기를 기대했습니다. 하지만 현재 빌드 산출물에는 인라인 이 생성되지 않았습니다. 효과가 없는 설정을 남기지 않고 되돌린 뒤, 실제 사용 경계를 기준으로 CSS를 나눴습니다.

관리자 화면의 CSS는 관리자 레이아웃에서만 불러오게 했습니다. 랜딩의 모달과 런처 스타일은 해당 동적 컴포넌트 청크로 옮겨 초기 렌더를 막지 않게 했습니다. 분리 전후 AST 노드를 비교해 기존 3,110개 규칙이 모두 어느 한쪽에 남아 있고 누락과 불필요한 추가가 0개인지 확인했습니다.

그 결과 초기 CSS 원본 크기는 366.9KB에서 230.7KB로 줄었고, 압축 전송량은 약 67KiB에서 43.9KiB가 됐습니다. 같은 진단의 CSS 응답 시간은 750ms에서 300ms, 렌더 차단 절감 추정치는 450ms에서 190ms로 낮아졌습니다. 여기서 중요한 것은 CSS를 무작정 삭제하지 않았다는 점입니다. 첫 화면에 필요하지 않은 규칙의 로딩 시점만 사용 시점으로 옮겼습니다.

번역 파일보다 더 큰 다국어 객체가 초기 JavaScript에 있었습니다

마지막으로 초기 로딩에서 네 언어의 텍스트가 모두 들어오는지 확인했습니다. 처음에는 국제화 메시지 파일이 네 언어를 전부 보내고 있다고 의심했습니다. 확인해 보니 next-intl 메시지 JSON은 이미 선택한 언어 하나만 읽고 있었습니다.

진짜 문제는 별도의 사이트 콘텐츠 모듈이었습니다. 약 156KB인 하나의 TypeScript 파일 안에 한국어, 영어, 일본어, 중국어 객체가 함께 있었고, 클라이언트 컴포넌트가 이를 가져오면서 사용하지 않는 세 언어의 문구도 초기 JavaScript에 포함됐습니다. 한국어 페이지의 번들에서 다른 언어의 히어로 문구를 직접 찾아 이 사실을 확인했습니다.

서버에서 현재 언어의 값만 남기는 함수를 두고, 페이지에 필요한 콘텐츠만 SiteContentProvider로 전달했습니다. NextIntlClientProvider에도 전체 33개 네임스페이스 대신 공통 영역과 현재 경로에 필요한 네임스페이스만 넘겼습니다. 공통이지만 번역 대상이 아닌 데이터는 다국어 객체와 분리했습니다.

배포 후에는 각 HTML에 현재 언어만 남아 있는지 확인했고, Lighthouse가 실제로 내려받은 자체 JavaScript 21개에도 나머지 세 언어의 히어로 문구가 없는지 검사했습니다. JavaScript 전송량은 296,741바이트에서 253,515바이트로 43,226바이트 줄었고, 문서 전송량도 53,382바이트에서 49,439바이트로 줄었습니다.

최종 결과는 단일 최고점이 아니라 세 번의 중앙값으로 봤습니다

CSS 분리까지 반영한 배포본과 언어 분리까지 반영한 배포본을 각각 세 번 측정해 중앙값을 비교했습니다.

지표 | CSS 분리 후 | 언어 분리 후 | 변화
Lighthouse 성능 점수 | 82 | 83 | +1
FCP | 2,012ms | 2,151ms | +139ms
LCP | 3,415ms | 3,374ms | -41ms
Speed Index | 7,914ms | 7,217ms | -697ms
TBT | 70ms | 47ms | -23ms
CLS | 0 | 0 | 동일

FCP가 139ms 느려진 것처럼 보이는 구간도 있습니다. 이는 Lighthouse 실행 간 변동 범위 안에서 함께 해석해야 합니다. 반면 전송량 감소는 빌드와 네트워크 기록에서 직접 확인했고, Speed Index와 TBT의 중앙값도 개선됐습니다. 초기 배포 측정의 72점과 LCP 4.8초에 비하면 최종 중앙값은 83점과 LCP 3.37초였지만, 두 값 역시 서로 다른 시점의 실험실 측정이므로 실제 사용자 전체가 정확히 같은 폭으로 빨라졌다는 뜻은 아닙니다.

이번 최적화에서 가장 큰 교훈은 폰트 옵션 하나로 문제를 끝내지 않은 것입니다. 인트로를 꺼 보고, 타이핑을 늦춰 보고, optional로 점수를 지켜 본 뒤 각각의 부작용을 확인해 필요한 연출은 되살렸습니다. 그 다음에 폰트 수, 렌더 차단 CSS, 경로별 스타일, 다국어 JavaScript를 차례로 분리했습니다.

Lighthouse 점수는 방향을 알려 주지만 원인을 대신 설명해 주지는 않습니다. 결국 도움이 된 것은 커밋마다 가설을 하나만 바꾸고, 배포본의 리소스 타임라인과 반복 측정 중앙값으로 그 가설을 검증한 과정이었습니다.