DevInsight

개발하며 겪은 판단을 적어요

Frontend
조회 6약 5분 읽기

폰트가 보이기 전에 페이지는 이미 흔들리고 있다

한글 웹 폰트는 글리프 하나당 용량이 커서 로딩을 지연시키고, 폰트가 늦게 뜰수록 CLS 지표가 요동친다. 이 글에서는 subset으로 글리프를 걸러내고 preload 타이밍을 잡고 font-display 전략을 고르는 과정을 실제 수치와 함께 살펴본다. Next.js의 next/font가 자체적으로 처리하는 일과 처리하지 않는 일을 구분해, 퍼포먼스 예산을 넘지 않으면서 텍스트가 안정적으로 렌더링되는 폰트 파이프라인을 설계하는 방법을 다룬다.

DevInsight에서 발행해요

#웹 폰트#CLS#Core Web Vitals#Next.js#next/font#font-display#preload#subset#웹 성능

한글 웹 폰트 한 벌의 woff2 파일은 대개 12MB를 넘긴다. 라틴 폰트가 2050KB 수준인 것과 비교하면 두 자릿수 차이다. 원인은 간단하다. 한글은 초성 19, 중성 21, 종성 28의 조합이 완성형 음절로 미리 조립되어 있고, 그 개수가 11,172개에 이른다. 라틴처럼 문자 몇십 개를 말아넣고 끝나는 방식과 처음부터 스케일이 다르다. ttf에서 woff2로 압축해도 글리프 수가 그대로이니 감당할 용량이 애초에 크다.

이게 화면에 어떻게 드러나느냐가 진짜 문제다. 용량이 크니 다운로드가 길어지고, 폰트가 늦게 도착할수록 브라우저는 대체 폰트로 텍스트를 그린다. 그리고 폰트가 도착하는 순간 대체 폰트를 밀어내며 페이지가 한 번 흔들린다. 이 흔들림이 웹 성능의 핵심 지표인 Core Web Vitals, 그중에서도 CLS에 그대로 잡힌다. 제목이 시사하는 바가 이것이다. 폰트가 눈에 보이기도 전에 렌더링은 이미 대체 폰트 기준으로 진행 중이고, 실제 폰트가 오면 그 기준 자체가 무너진다.

폰트가 CLS를 키우는 경로는 두 갈래로 갈린다. font-display: block은 폰트가 올 때까지 텍스트를 숨겨두니 첫인상은 고정되어 있지만, 늦게 로드되면 그만큼 빈 텍스트가 길어지고 교체 순간에 레이아웃이 다시 계산된다. swap은 처음부터 대체 폰트를 보여주고 폰트가 오면 바꾼다. 어느 쪽이든 대체 폰트와 실제 폰트의 글꼴 메트릭이 다르면 시프트가 발생한다. CLS는 요소가 움직인 픽셀 면적을 뷰포트 대비로 환산해 합산한다. 폰트 하나가 줄 높이를 2px 바꿔도 본문 수십 줄이면 시프트 면적이 커진다.

subset은 한글에서 반만 통한다

subset은 폰트에서 쓰지 않는 글리프를 빼는 작업이다. 라틴 폰트에서는 유니코드 범위로 쪼개는 게 잘 통한다. A~Z, 기호, 악센트가 각각의 범위로 나뉘어 있으니 unicode-range로 분할하면 브라우저가 필요한 조각만 내려받는다. 구글 폰트가 latin, latin-ext, korean으로 나누는 것도 같은 원리다.

한글은 이 방식이 반만 먹힌다. 완성형 음절이 U+AC00U+D7A3에 연속으로 붙어 있어서, 범위를 4등분 하든 10등분 하든 실제 쓰는 글리프가 각 조각에 골고루 흩어져 있다. 전체 11,172음절을 서비스하는 한 분할로 얻는 이득은 라틴에서 얻는 것보다 훨씬 작다. 그래서 실무에서는 페이지에서 실제로 사용하는 음절만 추려내는 동적 subset 쪽으로 기운다. 일반적인 한국어 기사 한두 편이면 사용 음절이 천여 개 안팎이고, 이렇게 줄이면 1.5MB짜리 폰트가 100300KB 안팎으로 내려온다. 완성형 폰트를 통째로 내려받던 시절에는 폰트 하나가 5MB를 넘기는 것도 드물지 않았다.

동적 subset에는 대가가 붙는다. "쓰는 글리프"를 어디서 뽑느냐가 곧 실패 조건이다. 기사 본문 텍스트로만 추리면 새 콘텐츠가 올라올 때마다 빠진 음절이 생기고, 그 자리에 네모 토후가 뜬다. 추출 소스를 전체 콘텐츠에서 잡든, 주기적으로 재생성하든, 이모지·특수문자·쌍자음 종성 같은 변두리를 별도 목록으로 보강해야 한다. 배포 환경에서 실제로 토후가 나오는지 확인하는 스모크 테스트를 CI에 돌려두면 안심된다.

preload는 LCP 텍스트의 폰트만

preload는 모든 폰트에 붙이는 게 아니다. 첫 화면에서 가장 먼저 그려질 텍스트, 즉 LCP 요소가 쓰는 폰트만 프리로드 대상으로 삼는 게 맞다. 헤딩이나 첫 화면 문단이면 LCP 후보이니 프리로드하고, 스크롤해야 보이는 본문 폰트는 프리로드하지 않는 편이 오히려 낫다. preload는 미리 내려받기라는 특성상 다른 리소스의 대역폭을 뺏어온다.

순수 HTML에서 프리로드를 다룰 때 가장 흔한 함정은 crossorigin 누락이다. 폰트 요청은 기본적으로 CORS 모드로 일어난다. crossorigin 없이 붙인 preload 링크는 폰트로 인정받지 못해서, 결국 프리로드와 별도 요청이 중복으로 나간다. <link rel="preload" as="font" type="font/woff2" crossorigin> 형태가 되어야 한다.

여기까지 오면 폰트를 두 계층으로 나누는 정책이 자연스럽게 따라온다. 첫 화면용 폰트는 프리로드하고, 아래쪽 콘텐츠용 폰트는 문서 로딩이 끝난 뒤 자바스크립트로 불러오거나 IntersectionObserver로 뷰포트에 들어올 때만 발동시키는 식이다. 이 지연 전략과 font-display의 조합이 폰트 파이프라인 설계의 골격이다.

font-display는 트레이드오프를 고르는 일

font-display의 네 값은 각기 다른 비용을 청구한다.

  • block: 폰트가 오기 전까지 텍스트를 숨긴다. 최대 3초의 빈 텍스트 시간을 지불하고, 로드가 끝나면 바로 적용한다.
  • swap: 대체 폰트로 즉시 그리고, 폰트가 도착하면 교체한다. 텍스트는 바로 보이지만 시프트 가능성이 가장 크다.
  • fallback: 100ms만 숨기고, 이후 짧은 시간 동안만 교체를 허용한다. 서버가 빠르면 늦게 뜬 폰트의 교체를 차단한다.
  • optional: 폰트가 초기 100ms 안에 준비되면 쓰고, 아니면 이번 페이지 로드는 아예 포기한다. 시프트는 거의 없지만 느린 네트워크에서는 폰트가 아예 안 보일 수 있다.

CLS를 최우선으로 두는 페이지라면 optional이 사실상 유일한 선택지다. 그 대가로 "첫 방문의 느린 사용자는 폰트가 안 보인다"는 사실을 받아들여야 한다. 폰트 자체가 브랜드인 페이지라면 swap을 쓰되, 대체 폰트 메트릭 보정을 함께 넣어야 한다.

여기서 next/font의 장단이 갈린다. Next.js의 next/font는 폰트를 빌드 시점에 내려받아 자체 호스팅하고, preload 링크를 붙이고, font-display: swap을 기본값으로 깔아준다. 자체 호스팅은 구글 폰트 CDN과의 연결 지연을 없애고, 해시 파일명은 캐시 갱신 문제를 제거한다. 하지만 swap이 고정값이라 optional로 바꿀 옵션이 없다. CLS가 최우선이라 optional을 쓰고 싶다면 next/font를 포기하고 @font-face를 직접 작성해야 한다. next/font가 폰트를 가져다 쓰기 좋게 만들어주는 도구이지, 폰트 로딩 전략 전체를 대신 정해주는 도구는 아니라는 말이다.

대체 폰트 메트릭 보정도 마찬가지다. size-adjust와 ascent-override, descent-override, line-gap-override로 대체 폰트를 실제 폰트의 메트릭에 맞추면, 교체가 일어나도 시프트가 거의 없다. next/font는 이 보정값을 라틴 중심의 계산으로 자동 생성해주지만, 한글 폰트에서 그 값이 정확하다고 단정해선 안 된다. 폰트마다 줄 높이가 크게 다른 한글 특성상, 직접 대체 폰트를 정하고 보정값을 넣은 뒤 폰트 로드 전후의 텍스트 블록 높이를 getBoundingClientRect로 재서 시프트가 0에 수렴하는지 확인해야 한다.

예산 없이 최적화는 끝나지 않는다

폰트 파이프라인은 성능 예산을 세워야 설계가 닫힌다. 흔한 기준으로 첫 화면 폰트 전송량 100KB 안팎, 총 리소스 300KB 이내 같은 숫자를 먼저 정해두지 않으면 "조금만 더 줄이면..."이 무한히 이어진다.

검증은 빌드 산출물에서 시작한다. performance.getEntriesByType('resource')로 폰트별 transferSize와 다운로드 구간을 찍어보면 preload가 실제로 작동하는지, 폰트가 LCP 이후에 도착하는지가 드러난다. 라이트하우스의 font-display 감사도 참고 기준이 된다. 필드에서는 CrUX의 CLS 백분위가 기준이다. 단, 개발 환경이나 로컬은 폰트가 이미 캐시되어 있어 시프트가 거의 보이지 않으니, 캐시를 비우고 시뮬레이션된 느린 네트워크에서 봐야 한다.

필드에서 CLS가 터지는 타이밍은 폰트 로드 그 자체보다 스크롤과 겹칠 때다. 사용자가 스크롤하는 중 폰트가 도착해 레이아웃이 바뀌면, 그 시점의 시프트까지 합산된다. 스크롤이 몰리는 본문에서 대체 폰트와 실제 폰트의 줄 높이 차가 크면 이 상황에서 CLS가 치솟는다. 한글 폰트에서 메트릭 보정이 라틴보다 중요한 이유가 여기에 있다. 본문 수십 줄이 한 번에 줄 높이를 바꾸는 상황은 라틴 텍스트보다 훨씬 빈번하다.

마지막으로, 동적 subset을 도입했다면 콘텐츠 증가가 곧 폰트 유지보수다. 새 콘텐츠에서 사용 음절의 누락을 잡는 검사 스크립트를 CI에 넣거나, 운영 중인 콘텐츠의 음절 합집합으로 폰트를 정기 재생성하거나, 둘 중 하나는 반드시 걸어둔다. 폰트 최적화가 배포 파이프라인에 들어가는 순간부터 CLS는 대응 가능한 문제가 된다. 첫 화면 폰트를 optional로, 지연 폰트를 스크롤 트리거로, 메트릭 보정을 직접 측정으로 — 이 세 가지만 순서대로 정리해도 페이지의 흔들림은 눈에 띄게 줄어든다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Frontend 전체 보기
Frontend

use client 한 줄로 갈리는 서버와 클라이언트, 번들 크기부터 상태 관리까지 흔들린다

Next.js App Router에서 'use client' 한 줄이 서버·클라이언트 영역을 가르면 번들 크기, 데이터 페칭, 상태 관리 전략이 전부 흔들린다. 경계를 잘못 그으면 번들 비대화와 중복 페칭이 뒤늦게 한꺼번에 터진다. 이 메모는 계층별 책임과 데이터 소유권을 기준으로 경계를 다시 그리는 판단 원칙과, 이미 잘못 그어진 경계를 리팩터링하는 우선순위를 정리한다.

#React#Next.js#Server Components#Client Components
Frontend

next/image sizes 한 줄이 LCP를 0.5초 당긴다

LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.

#LCP#이미지최적화#next-image#CoreWebVitals

이전 글

any 지옥에서 strict로 승급한 어느 프로젝트의 사투기

다음 글

만들어 둔 인덱스를 쿼리가 외면하는 순간들, EXPLAIN으로 사인을 읽는 법

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독