지난 즐겨찾기 페이지의 렌더링 전략 개선을 진행하면서 LightHouse를 재측정해보게 되었다. 오랜만에 확인해봐서 그런건지 점수가 생각보다 많이 낮았다.
Largest Contentful Paint가 3.5초라고 표시되고 있었는데, 이건 사용자가 페이지에 들어왔을 때 가장 큰 콘텐츠가 3.5초 뒤에야 렌더링된다는 뜻이다.
이건 사용자 경험과 직결되는 문제이기 때문에 무시하고 넘어갈 수가 없었다.
이 글은 그 과정에서 웹폰트가 LCP에 얼마나 큰 영향을 미치는지 알게 된 경험, 그리고 동적 서브셋을 통해 1st party 전송량을 2,128KB에서 119KB로 줄인 과정을 정리한 글이다.
개선 전후 수치
개선 결과부터 간단히 요약하면 다음과 같다.
![]() 개선 전 |
![]() 개선 후 |
![]() 개선 전 |
![]() 개선 후 |
![]() 개선 전 |
![]() 개선 후 |
전송량이 절반 가까이 줄었고, LCP와 Metrix도 눈에 띄게 개선되었다.
이번 개선의 핵심은 웹폰트 처리 방식을 바꾼 것이었다.
잘못된 가설, 그리고 LCP breakdown 분석
처음에 Lighthouse 결과를 보고 메인스레드 문제라고 판단했다. TBT(Total Blocking Time)가 630ms로 높았기 때문이다. 그런데 LCP breakdown을 4단계로 쪼개서 보면서 생각이 달라졌다.

LCP는 크게 네 단계로 구성된다.
- TTFB: 서버 응답 시간
- Resource Load Delay: 리소스 로드 시작 지연
- Resource Load Duration: 리소스 실제 다운로드 시간
- Render Delay: 렌더링 지연
여기서 Lighthouse가 보고하는 LCP 숫자가 두 개라는 점이 중요하다. 하나는 실제 측정값이고, 다른 하나는 Lantern 시뮬레이션 기반 값이다. 두 값 사이에 괴리가 크다면 대역폭 병목을 의심해야 한다. Lantern은 네트워크 조건을 시뮬레이션하기 때문에, 로컬에서 빠르게 로드되더라도 느린 네트워크 조건을 가정하면 전송량이 클수록 불이익을 받는다.
실측 render delay는 130ms였다. 메인스레드 문제였다면 이 숫자가 컸어야 한다. 범인은 따로 있었다.

네트워크 탭에서 전송량 기준으로 정렬해보니 PretendardVariable.woff2 하나가 2MB였다. 페이지 전체 전송량 4,087KiB의 약 50%를 폰트 파일 하나가 차지하고 있었던 것이다.
핵심 진단 흐름을 다시 정리해보자면:
- LCP 요소가 무엇인지 확인한다
- breakdown 4단계 중 어느 단계가 긴지 본다
- 실측 LCP와 Lantern 시뮬레이션 LCP의 괴리를 확인한다
- 네트워크 탭에서 전송량 기준으로 정렬해 범인을 찾는다
왜 동적 서브셋인가
폰트 용량을 줄이는 방법은 크게 정적 서브셋과 동적 서브셋으로 나뉜다. 정적 서브셋은 미리 사용할 글자를 정해두고 그 글자만 포함한 폰트 파일을 만드는 방식이다.
이 서비스를 동아리 서비스라는 점에서 생각해보면, 사용자가 직접 글을 쓰고 동아리를 등록하는 UGC(User Generated Content) 구조다. 어떤 글자가 등장할지 예측할 수 없다. 정적 서브셋으로 커버리지를 보장하려면 결국 대부분의 글자를 포함해야 하고, 그러면 용량 절감 효과가 거의 없어진다.
동적 서브셋은 전체 폰트를 여러 조각으로 분할하고, 브라우저가 페이지에서 실제로 필요한 unicode-range의 조각만 요청하는 방식이다. Pretendard 공식 배포본은 이 조각을 92개로 나눠 제공하는데, 단순히 균등 분할한 게 아니라 빈도 기반 그룹핑으로 최적화되어 있다. 자주 쓰이는 글자일수록 초반 조각에 몰려 있어, 첫 화면에서 실제로 내려받는 양이 줄어드는 구조다.

이런 방식으로 첫 화면에서 실제 전송된 폰트 용량을 줄였다.
코드 변경 [Next.js]
기존 코드는 next/font/local로 단일 woff2 파일을 로드하는 방식이었다.
// Before — src/app/layout.tsx
import localFont from 'next/font/local';
const pretendard = localFont({
src: [{ path: '../shared/fonts/PretendardVariable.woff2' }],
variable: '--font-pretendard',
display: 'swap',
});
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body className={`${pretendard.className} scrollbar-hide`}>
{children}
</body>
</html>
);
}
이를 next/font/local 없이 CSS의 @font-face를 직접 작성하는 방식으로 교체했다. 92개의 @font-face 블록을 CSS 파일에 직접 선언하고, 각각 unicode-range를 지정해 브라우저가 필요한 조각만 내려받도록 했다.
/* src/app/fonts.css — @font-face 동적 서브셋 예시 */
@font-face {
font-family: 'Pretendard Variable';
font-style: normal;
font-display: swap;
font-weight: 45 920;
src: url(/fonts/pretendard/PretendardVariable.subset.91.woff2) format('woff2-variations');
unicode-range: U+20-22, U+27-2a, U+2c-39, U+41-4e, U+61-7b, U+ac00, U+ace0, U+ae30, U+b2e4, U+b85c, U+c0ac, U+c2a4, U+c9c0, U+d558;
}
@font-face {
font-family: 'Pretendard Fallback';
src: local('Arial');
ascent-override: 93.76%;
descent-override: 23.75%;
line-gap-override: 0.00%;
size-adjust: 101.55%;
}
Pretendard Fallback 폰트 정의가 중요하다. next/font/local이 자동으로 처리해주던 폴백 폰트 메트릭 오버라이드를 직접 이식한 것이다. ascent-override, descent-override, size-adjust 같은 속성으로 폴백 폰트(Arial)의 크기와 높이를 Pretendard에 맞춰 보정해, 폰트가 교체될 때 레이아웃이 밀리지 않도록 한다. 덕분에 CLS가 0.001에서 0으로 개선되었다.
Tailwind v4 기반 프로젝트라 토큰으로 폰트를 등록하고, layout.tsx는 단순하게 유지했다.
/* src/app/theme.css — Tailwind v4 토큰 기반 */
@theme {
--font-sans: 'Pretendard Variable', 'Pretendard Fallback', system-ui, sans-serif;
}
/* src/app/layout.tsx — After */
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body className="scrollbar-hide">
{children}
</body>
</html>
);
}
트레이드오프
좋은 점만 있었던 건 아니다.
FCP 0.2초 증가. FCP가 1.2초에서 1.4초로 늘었다. 단일 파일을 한 번에 받던 것을 92개 조각으로 쪼개다 보니, HTTP/1.1 환경의 동시 연결 제한이 영향을 미친 것으로 보인다.
레포 용량 증가. 92개 woff2 파일을 저장소에 직접 보관하게 되어 폰트 관련 용량이 2,009KB에서 2,888KB로 늘었다.
TBT 증가. TBT가 630ms에서 700ms로 늘었다. 대역폭 병목이 제거되면서 그동안 가려져 있던 메인스레드 비용이 측정에 드러난 것으로 보인다. 대역폭이 병목일 때는 다운로드를 기다리는 시간이 지배적이라 메인스레드 점유 시간이 상대적으로 묻혀버린다. TBT 증가는 다음 개선 대상이 메인스레드임을 알려주는 신호라고 볼 수 있다.
로컬 Lighthouse의 한계
이번 측정은 모두 로컬 Lighthouse 기준이라는 점을 밝혀둔다. 프로덕션에서는 두 가지 문제가 추가로 존재하는데 이번 측정으로는 잡아내지 못했다.
하나는 캐싱 차단 문제다. 비로그인 사용자도 cookies()를 호출하는 곳이 있어 Next.js가 해당 라우트를 dynamic으로 강제 처리하고, 결과적으로 캐싱이 막힌다.
다른 하나는 함수 리전 문제다. 서울 엣지로 배포되어 있지만 실제 함수는 버지니아 리전에서 실행되고 있어 TTFB에 불필요한 레이턴시가 붙는다.
이 두 문제는 다음 개선 회차에서 다뤄볼 예정이다.
정리
이번 개선을 간단히 요약하면 이렇다.
- LCP 병목의 원인은 메인스레드가 아니라 2MB 폰트 파일이었다
- 동적 서브셋 적용으로 첫 화면 폰트 전송량을 2MB에서 526KB로 줄였다
- UGC 서비스 특성상 정적 서브셋 대신 동적 서브셋을 선택했다
- next/font/local 제거 시 폴백 폰트 메트릭 오버라이드를 직접 이식해야 CLS를 지킬 수 있다
- TBT 증가는 회귀가 아니라 숨어 있던 메인스레드 비용이 드러난 것이다
- 실측 render delay 130ms → 140ms로 거의 변화 없음을 통해, localhost 환경에서 폰트 크기는 대역폭 시뮬레이션 단계에서만 영향을 미친다는 점을 확인할 수 있었다
'React' 카테고리의 다른 글
| [Next.js] 성능 개선기 (2) - 하이드레이션·서드파티 최적화로 TBT 개선하기 (0) | 2026.08.11 |
|---|---|
| [Next.js] 즐겨찾기 페이지의 렌더링 전략 전환기: 하이드레이션 워터폴과 이중 요청을 React Query Prefetch로 해결하기 (0) | 2026.07.29 |
| [Next.js] Next.js 프로젝트를 위한 Docker 배포 가이드 (0) | 2026.01.28 |
| [Next.js] Next.js에서의 라우팅은 어떻게 다를까? (0) | 2026.01.12 |
| [Next.js] 페이지 이동을 위한 Link와 useRouter(), 그리고 form submit (0) | 2025.07.28 |





