동적 폰트 로딩 전략
- 설계
- 졸업 작품
들어가며
졸업 작품에서 손글씨 커스텀 폰트를 미리 보여주는 서비스를 만들고 있다. 폰트 상세 페이지에 들어가면 클라이언트는 서버에서 폰트 정보를 받아온다. 이 응답에는 .ttf 파일 URL이 들어 있고, 클라이언트는 이 URL로 폰트 파일을 다시 요청해 브라우저에 로드한 뒤 화면에 적용한다. 구현 자체는 문제없이 동작했다.
이때 발생한 문제는 텍스트가 기본 폰트로 먼저 보였다가 커스텀 폰트로 바뀌는 깜빡임이었다. 기본 폰트에서 커스텀 폰트로 전환되는 이 현상을 FOUT(Flash of Unstyled Text)이라 부르는데, font-display: swap 속성 때문에 생기는 자연스러운 동작이다.
개발자 입장에서는 충분히 예상 가능한 동작이었지만, 사용자 입장에서는 폰트를 보러 들어온 페이지에서 폰트가 바뀌는 모습을 보여주는 셈이라 그냥 넘기기 어려웠다. 처음부터 커스텀 폰트가 적용된 화면을 보여줄 수는 없을까?
폰트 적용 시점은 클라이언트 렌더링 이후로 고정돼 있었다
서버가 폰트 URL을 안다면 미리 적용할 수 있지 않을까 하는 질문 하나를 붙잡고 폰트 로딩 방식을 다시 들여다보기 시작했다. 처음 떠올린 방법은 서버 측 렌더링(SSR)이었다. 서버는 어떤 폰트를 보여줘야 하는지, 그 폰트의 URL이 무엇인지 이미 알고 있다. 이 정보를 이용해 폰트가 적용된 상태의 HTML을 만들어 내려주면 클라이언트는 깜빡임 없이 바로 완성된 화면을 받아볼 수 있을 거라 생각했다. 폰트 URL을 서버가 쥐고 있고 HTML도 서버가 만드는 구조이니, 겉보기엔 충분히 시도해볼 만한 접근이었다.
이 프로젝트에서는 폰트를 적용할 때 브라우저의 FontFace API를 쓴다. 정적 CSS에 @font-face를 미리 선언하는 대신, JS 코드로 런타임에 폰트를 만들고 브라우저에 등록하는 방식이다.
const fontFace = new FontFace(`font-${fontId}`, `url(${fontUrl})`, {
style: 'normal',
weight: '400',
})이 코드는 폰트를 서버가 아니라 필요한 시점에 브라우저가 직접 가져오게 해준다. 서버에서 어떤 폰트를 쓸지만 결정해 전달하면 클라이언트가 그때그때 필요한 폰트만 로드할 수 있어서, 폰트 미리보기처럼 여러 폰트를 오가며 봐야 하는 화면에는 잘 맞았다.
문제는 FontFace가 DOM이나 window 객체처럼 브라우저에만 존재하는 객체라는 점이다. MDN 문서에도 이 API는 Window와 Web Worker 환경에서만 쓸 수 있다고 나와 있다. Node.js 같은 서버 환경에는 이 객체 자체가 없다. 아무리 서버가 폰트 URL을 알고 있어도, 그 URL로 실제 폰트를 불러와 등록하는 작업은 브라우저가 HTML과 JS를 모두 읽은 다음에야 일어난다. SSR로 HTML을 아무리 빨리 내려보내도, 폰트가 적용되는 시점 자체는 클라이언트 렌더링 이후로 고정돼 있었던 셈이다.
깜빡임을 아예 없앨 수 없다면 최소한 줄이거나 감출 방법이 필요했다. 결국 클라이언트에서 최대한 빨리 보여주는 방법 말고는 남지 않아, 방향을 클라이언트 쪽으로 완전히 돌렸다. 이후 적용한 네 가지는 이 갈림길에서 갈라진 두 갈래에 속한다. 폰트 자체를 더 빨리 받아오는 쪽 셋, 폰트가 아직 없는 순간을 자연스럽게 처리하는 쪽 하나다.
TTF를 WOFF2로 바꿔 전송량부터 줄였다
첫 번째 갈래, 폰트 자체를 더 빨리 받아오는 작업에서 가장 먼저 손댄 지점은 파일 크기였다. 처음에는 .ttf 형식을 그대로 썼다. .woff2는 .ttf보다 압축률이 높은 대신 품질이 조금 떨어질 수 있는데, 폰트 미리보기 용도로는 이 정도 손실은 감수할 만하다고 판단했다.
직접 측정한 결과, 이 변환만으로 폰트 파일 크기가 6.3MB에서 3.0MB로 줄었다.


파일 크기가 줄어든 만큼 네트워크 다운로드 속도도 4.59ms에서 2.74ms로 줄었다.

파일 크기를 줄인 뒤에도 같은 폰트를 여러 번 오가며 봐야 하는 화면에서는, 오갈 때마다 같은 폰트를 매번 새로 받아오는 문제가 남아 있었다.
같은 폰트를 다시 받아오지 않도록 등록해뒀다
기존 방식에서 캐싱을 얹었다.
const fontFace = new FontFace(fontFamily, `url(${fontUrl})`)
await fontFace.load()
document.fonts.add(fontFace) // ← FontFaceSet에 등록document.fonts(FontFaceSet)에 한 번 등록해둔 폰트는 같은 document가 떠 있는 동안 계속 남아 있다. 같은 font-family와 URL 조합을 다른 페이지나 컴포넌트에서 다시 쓸 때, 이미 등록된 폰트인지 확인한 뒤 그 객체를 그대로 재사용하면 새로 FontFace를 만들고 load()를 다시 호출할 필요가 없다. 최초 1회 로드 비용만 감수하면, 그다음부터는 같은 폰트로 이동할 때 깜빡임 없이 바로 렌더링됐다. 다만 이 절감분을 ms 단위로 재보지는 않았다.
폰트 하나를 오가는 비용은 줄었지만, 페이지를 이동하거나 새로고침할 때마다 폰트 URL 자체를 다시 요청하는 문제는 그대로 남아 있었다.
페이지를 오갈 때마다 새로 요청하지 않도록 TanStack Query로 캐싱 범위를 나눴다
TanStack Query로 캐싱 전략을 나눠 적용했다. staleTime은 데이터를 신선하다고 볼 기간을, gcTime은 쓰이지 않는 캐시를 메모리에 얼마나 들고 있을지를 정한다.
전체 폰트 리스트를 요청하는 홈, 둘러보기 화면에는 짧은 staleTime을 줬다. 사용자가 폰트를 새로 올렸을 때 리스트에 바로 반영돼야 하기 때문이다.
useQuery({
queryKey: ['font-list'],
queryFn: () => fetchFontList(),
staleTime: 1000 * 60, // ← 1분: 업로드 직후 리스트 반영을 위해 짧게
gcTime: 1000 * 60 * 5,
})반대로 개별 폰트의 상세 URL은 자주 바뀌지 않으므로 staleTime을 길게 뒀다.
useQuery({
queryKey: ['font-url', fontId],
queryFn: () => fetchFontUrl(fontId),
staleTime: 1000 * 60 * 5, // ← 5분: 상세 정보는 자주 안 바뀜
gcTime: 1000 * 60 * 30,
})이 두 캐시로 폰트 URL 요청 자체가 줄었고, FontFace 캐싱과 맞물려 같은 폰트를 다시 보여줄 때 네트워크 탭에 요청이 거의 잡히지 않는 것까지는 확인했다. 다만 이 역시 깜빡임이 몇 ms 줄었는지까지 재지는 않았다.
로딩되는 순간은 스켈레톤 UI로 가렸다
두 번째 갈래는 폰트가 아직 없는 그 짧은 순간을 자연스럽게 보여주는 쪽이었다. woff2 변환과 캐싱으로 로딩은 빨라졌지만, 폰트가 브라우저에 로드되고 등록되는 그 짧은 순간 자체는 여전히 남는다. 이 순간을 없애는 대신 가리기로 했다. 폰트가 로드되기 전까지는 스켈레톤 UI로 자리를 잡아두고, 로드가 끝나면 실제 텍스트를 보여주는 방식이다. 아직 적용 초기 단계라 실제로 깜빡임 체감이 얼마나 줄어드는지는 검증하지 못했다.
남은 한계
이번에 적용한 네 가지 중 숫자로 확인한 것은 TTF를 WOFF2로 바꾼 첫 번째 작업 하나뿐이다. FontFace 캐싱과 TanStack Query 캐싱은 네트워크 탭에서 요청이 줄어드는 것까지는 확인했지만, 그로 인해 깜빡임 자체가 몇 ms 줄었는지는 재지 않았다. 스켈레톤 UI는 적용한 지 얼마 안 돼, 실제로 깜빡임 체감이 줄었는지조차 아직 검증하지 못했다.
정리하며
처음 던진 질문은 처음부터 커스텀 폰트가 적용된 화면을 보여줄 수는 없을까였다. 답은 여전히 안 된다는 쪽에 가깝다. FontFace가 브라우저에만 있는 객체인 이상, 폰트가 적용되는 시점을 서버 렌더링으로 앞당길 방법은 없었다.
다만 그 사이 순간을 얼마나 줄이고 가릴 수 있는지는 계속 남아 있는 문제였다. 그런데 네 가지를 적용하고 나서 보니, 정작 숫자로 확인한 건 파일 크기를 줄인 작업 하나뿐이었다. 나머지 세 가지는 콘솔에서 요청 수가 줄어드는 걸 보고 효과가 있다고 판단했을 뿐, 깜빡임 자체가 얼마나 줄었는지는 재보지 않은 채로 넘어갔다.
로딩을 빠르게 하는 일과 로딩되지 않은 순간을 감추는 일은 애초에 다른 축이라, 하나의 지표로 함께 검증하기 어려웠다. 다음에 비슷한 문제를 마주치면, 기법을 적용하는 순서만큼 그 효과를 무엇으로 확인할지도 먼저 정해두려 한다.