폰트 로딩은 왜 페이지를 옆으로 미는가
한글 웹폰트는 영문보다 몇 배는 무거워 LCP를 늦추고, 폰트가 뒤늦게 도착하면 레이아웃이 통째로 흔들리며 CLS 점수를 깎는다. 이 글은 불필요한 글리프를 덜어내는 subset, 리소스 우선순위를 조정하는 preload, 표시 전략을 결정하는 font-display를 실제 기준과 함께 다루고, Next.js next/font가 알아서 해주지 못하는 부분까지 짚는다. 핵심은 폰트 최적화를 디자인이 아니라 성능 계획의 일부로 설계하는 데 있다.
DevInsight에서 발행해요
한글 웹폰트 한 개가 페이지에 추가되는 순간, 브라우저가 받아야 하는 데이터는 대개 영문 글꼴의 열 배 안팎으로 뛴다. 완성형 한글 11,172자를 통째로 담은 WOFF2가 수십 KB에 그치는 영문 파일과 같은 무게일 리 없다. 정작 큰 문제는 그 무거운 파일이 뒤늦게 도착했을 때 터진다. LCP가 밀리고, 폰트가 도착한 순간 화면이 통째로 흔들린다.
브라우저는 스타일시트에 선언된 폰트가 아직 없으면 대체 폰트로 텍스트를 미리 그려둔다. 웹폰트가 도착하면 그 자리를 다시 그린다. 대체 폰트와 웹폰트의 글자 폭이 다를수록 이 교체는 격해진다. 한글은 완성형과 조합형의 글자 폭이 제법 달라서, 영문에 비해 눈에 띄게 화면이 밀린다. 이게 CLS(레이아웃 시프트)다. Core Web Vitals의 CLS 임계값은 0.1이다. 폰트 하나가 바꿔 끼워지는 것만으로 이 수치를 넘기는 일은 흔하다. 굵기가 두 개 이상 로드되면 시프트가 두 번 일어나니, 임계값을 넘길 확률도 그만큼 커진다.
무거운 것보다 늦은 게 더 나쁘다
font-display는 이 교체의 타이밍을 정하는 스위치다. swap은 대체 폰트로 즉시 그린 뒤 폰트가 도착하면 갈아끼운다. 텍스트가 빨리 보여 LCP에는 유리하지만, 교체 순간 CLS가 만들어진다. optional은 네트워크 상황을 보고 곧 도착할 폰트에만 이 정책을 적용해서, 느린 환경에서는 폰트를 아예 다음 방문에서 쓴다. CLS는 사라지지만 첫 방문에서 디자인은 적용되지 않는다. block과 fallback은 텍스트가 보이지 않는 구간을 만들어 회복하기 어려운 LCP 손해를 준다.
swap을 고르면 CLS가, optional을 고르면 LCP가 위험해진다. 어느 쪽도 매끄럽지 않으니, 늦게 도착하는 상황 자체를 없애는 방향으로 문제를 옮기는 게 현실적이다. 파일을 줄이는 것과 도착 시점을 당기는 것은 해결하는 지점이 다르다. 전자만으로는 우선순위가 밀려 폰트가 끝까지 늦는 문제가 남고, 후자만으로는 무거운 파일이 대역폭을 오래 점유하는 문제가 남는다. 지연이 길어질수록 font-display는 어느 정책이든 손해를 본다. swap조차 교체 시점의 시프트가 반복되니, 네트워크 지연을 가정한 판단은 최대 지연 기준으로 잡아야 한다.
폰트 지연이 오래 갈수록 교체가 두 번 일어난다
브라우저 캐시가 비워진 첫 방문에서는, 웹폰트가 도착할 때까지 화면에 대체 폰트 텍스트가 남는다. swap으로 폰트가 도착해도, 페이지의 폰트 로드가 끝나기 전에 다른 요소가 렌더링되면 시프트가 또 발생한다. 단 한 개의 폰트라도 지연이 길어지면 CLS가 두 번에 걸쳐 쌓인다. 0.1 임계값을 넘기기 위해 두 번 쌓일 필요는 없다. 한 번의 큰 시프트로도 충분하다.
font-display: optional은 이 교체를 아예 건너뛴다. 폰트가 정해진 시간 안에 도착하지 않으면 그 방문에서는 대체 폰트로 끝까지 그린다. 이건 CLS 문제를 폰트 문제와 분리해주는 유일한 정책이다. 다만 단점이 뚜렷하다. 첫 방문에서 디자인 폰트가 보이지 않아, 브랜드 표현에 민감한 사이트라면 수용하기 어렵다. 그런 사이트는 optional 대신, 폰트 파일 자체를 작게 만들어 도착 시간을 임계값 안으로 끌어들인다. 그래야 swap을 써도 시프트가 작게 일어난다.
subset, 파일을 절반 이하로
11,172자를 실은 파일에서 실제 쓰는 글자는 극히 일부다. 한국어 사이트가 소비하는 글자는 상위 2,000여 자면 대부분 덮이고, 페이지 하나로 한정하면 수백 자 안팎까지 내려간다. subset은 글꼴 파일에서 이 글리프만 남겨 다시 만드는 작업이다. fonttools 계열의 pyftsubset이 표준 진입점이다.
pyftsubset NotoSansKR-Regular.otf \
--text-file=page-text.txt \
--flavor=woff2 \
--layout-features='*'
이 방법은 페이지 텍스트를 미리 알 수 있을 때만 통한다. 댓글, 유저 이름, 환율 표처럼 예측 불가능한 텍스트가 섞이면 빠진 글자가 대체 폰트로 튀거나 네모 박스로 나온다. 그런 서비스라면 완성형 전부를 담는 대신, 굵기 수를 줄이는 쪽이 안전하다. 브라우저는 없는 굵기를 이웃 굵기로 대신 그리고 폭이 어긋난다. 다시 CLS다. 서비스할 굵기는 regular와 semibold 정도 두 개면 충분하고, 나머지는 시스템 폰트에 맡긴다.
unicode-range로 글자 구간을 쪼개 필요한 구간만 내려받는 방식도 있다. 한글은 글리프가 구간 전체에 골고루 퍼져 있어 영문만큼 이득이 크지 않다. 파일이 쪼개진 만큼 다운로드 수도 늘어나고, HTTP/2가 없는 구형 인프라에서는 병렬 연결 제한에 걸려 역효과가 날 수 있다. 동적 subset은 서버가 렌더링할 텍스트를 받아 그 글리프만 뽑아 보내는 방식으로, 페이지마다 텍스트가 크게 바뀌는 서비스에 맞는 대안이다.
preload, 걸 위치는 한 곳뿐이다
preload는 폰트의 우선순위를 높여 첫 화면에 필요한 글꼴을 일찍 받게 한다. 걸 위치는 한 곳이다. LCP 요소가 쓰는 폰트. 폴드 아래 콘텐츠나 스크롤해야 보이는 위젯에 preload를 걸면, 느린 네트워크에서 남는 대역폭을 폰트가 다 먹고 다른 리소스가 늦어진다. 다운로드도 스타일시트 적용보다 앞서 나가야 의미가 있다. <link rel="preload">를 스타일시트 태그보다 위에 두는 게 권장 패턴이다. 반대로 preload가 없으면 폰트는 CSS 파서가 글꼴 선언을 만날 때 비로소 출발한다.
preload 대상이 둘 이상이면 우선순위가 희석된다. LCP 폰트 하나로 고정하고, 나머지는 기본 로드 순서에 맡긴다. 캐시 유효기간도 길게 잡는다. 폰트는 버전이 바뀌는 일이 드물어서, Cache-Control 헤더에 1년 단위를 줘도 무방하다. 다음 방문에서 폰트가 캐시에서 나오면 preload가 필요 없어진다. 다만 캐시 정책만으로 첫 방문 문제는 해결되지 않으니, 파일 크기와 우선순위 조정은 그대로 남는다.
next/font가 덜어내지 못하는 부분
Next.js의 next/font는 로컬 폰트를 자체 호스팅하고 preload와 font-display: swap을 자동으로 붙이며, fallback 폰트의 메트릭까지 추정해 교체 시의 흔들림을 줄여준다. 영문 폰트라면 이 기본 동작만으로 상당 부분 해결된다. 단, next/font가 만드는 subset은 주로 unicode-range가 정의된 Latin 계열 폰트에 효과가 있다. 한글 폰트는 사실상 그대로 번들에 실려 들어간다. 한글 웹폰트를 그냥 next/font로 넣으면 자동화의 이점이 절반으로 줄고, WOFF2가 통째로 초기 로드에 포함된다.
그래서 실무에서는 두 단계를 섞는다. pyftsubset으로 만든 자체 subset 폰트를 로컬에 두고 next/font로 로드하는 것. Google Fonts CDN 참조 방식은 preload를 걸어도 HTTPS 연결이 하나 더 생겨 TTFB가 길어지는 경우가 많아 피한다. 다만 이 조합도 만능은 아니다. 콘텐츠가 매번 뒤바뀌는 페이지가 많다면, 동적 subset이나 완성형 전체를 자체 호스팅하는 쪽이 단순하다. subset 폰트의 단점은 텍스트가 추가될 때마다 파일을 다시 뽑아야 하고, 빠진 글자 감지를 별도로 해야 한다는 것이다.
검증은 대체 폰트 메트릭부터
적용 결과는 숫자로 확인해야 한다. 먼저 대체 폰트의 메트릭을 웹폰트와 비슷하게 맞춰둔다. ascent, descent, lineGap 같은 값이 맞으면 교체 시 줄 높이 차이가 줄어든다. 이렇게 맞춰두는 작업이 CLS 수치에 즉시 반영된다. 주의할 점은 이 흉내가 전반적인 폭만 비슷하게 할 뿐, 글자마다 제각각인 폭까지 따라가지 못한다는 것이다. 메트릭 보정은 보조 수단이고, 폰트가 도착하는 시점 자체를 당기는 subset과 preload가 본전이다.
그다음 web-vitals 라이브러리나 검사 도구로 CLS를 재고, 네트워크 탭에서 각 폰트의 transferred 크기와 완료 시점을 비교한다. 폰트 파일은 무겁지만 빨리 도착하거나, 가볍지만 늦게 도착하는 경우가 흔하다. 숫자로 보기 전까지 어느 쪽이 병목인지 알 수 없다. 폰트 최적화를 디자인 마감 단계가 아니라 성능 계획의 일부로 넣어야 하는 이유가 여기 있다. 첫 화면에 쓰는 폰트를 정하고, subset을 만들고, preload 대상을 한 줄로 고정하는 것부터 시작하면 된다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
Next.js SEO, 아무도 에러를 내지 않는 실패들
Next.js App Router에서 generateMetadata, OG 이미지, JSON-LD, sitemap이 경고 하나 없이 조용히 실패하는 패턴을 파헤친다. metadataBase 누락, 스트리밍 메타데이터가 body로 빠지는 함정, JSON-LD 스크립트 탈출, sitemap 5만 건 자동 절단까지 실무에서 반드시 알아야 할 모든 케이스를 정리한다.
Next.js 블로그를 운영한 지 3개월, 아직도 구글에 제대로 노출되지 않는다면
App Router 기반 블로그에서 generateMetadata 누락, OG 이미지 경로 오류, JSON-LD 렌더링 실패, sitemap 구성 실수 등 실제 배포 후에야 드러나는 SEO 취약 지점을 진단하고 수정하는 실전 체크리스트.
next/image sizes 한 줄이 LCP를 0.5초 당긴다
LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.
이전 글
json_object가 끝이 아니라 시작인 이유
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.