동적 폰트 로딩 전략
- 졸업 작품
- 폰트 로딩

졸업 작품에서 프론트엔드를 맡아 개발 중이다. 주제는 손글씨 기반의 커스텀 폰트를 제작하고, 사용자가 이를 둘러보며 마음에 드는 폰트를 다운로드할 수 있는 서비스다. 리액트를 기반으로 기능을 구현하고 있으며, 그중 핵심은 사용자가 만든 커스텀 폰트를 웹페이지에 적용해 미리 볼 수 있도록 하는 것이다.
폰트 로딩 전략 고민
현재의 개발 흐름을 살펴보면, 사용자가 직접 만든 폰트는 서버에 저장된다. 폰트 정보가 보여지는 페이지에 진입하면 클라이언트는 서버에 요청을 보내 해당 폰트의 정보를 가져온다. 이 응답에는 .ttf URL이 포함되어 있으며, 클라이언트는 이 URL을 통해 폰트 파일을 추가로 요청한 뒤 브라우저에 로드하여 화면에 적용한다.
React를 사용한 기본적인 구현은 문제없이 동작했지만, 실제로 페이지를 열어보면 눈에 띄는 문제가 있었다. 처음 불러오는 폰트의 경우, 텍스트가 기본 폰트로 먼저 표시되었다가, 이후 커스텀 폰트로 전환되는 ‘깜빡임(FOUT)’ 현상이 발생하는 것이다. 이는 font-display: swap 속성에 의한 자연스러운 현상이지만, 사용자 입장에서는 부자연스럽고 산만하게 느껴질 수 있었다. 개발자로서야 충분히 예측 가능한 일이었고 어느 정도는 감수할 수 있었지만, 최종 사용자 경험을 생각하면 마냥 방치할 수는 없었다. 그래서 고민했다.
“처음부터 커스텀 폰트를 적용한 상태로 보여줄 수는 없을까?”
폰트를 더 빠르게 불러와 사용자에게 매끄러운 시각적 경험을 제공하는 방법이 궁금했다.이 과정에서 문득 이런 생각이 들었다.
“SSR을 활용하면, 서버에서 미리 폰트 URL을 알고 있는 상태니까 브라우저가 HTML을 받기 전에 폰트를 먼저 불러오고 적용된 상태로 내려줄 수 있지 않을까?”
즉, 서버 측 렌더링(SSR)을 활용하면, 사용자 맞춤 폰트 주소를 기반으로 클라이언트가 폰트가 이미 적용된 상태의 HTML을 받아 더 빠르고 깔끔한 초기 렌더링을 구현할 수 있을 거라는 기대였다.
겉보기엔 타당한 접근이었다. 폰트 URL을 서버가 알고 있고, 서버에서 HTML을 만들어 내려주니 폰트도 같이 처리할 수 있을 것처럼 보였다. 하지만 이 아이디어는 생각보다 빨리 기술적인 벽에 부딪혔다.
FontFace API
먼저, 내가 사용하고 있는 방식은 브라우저의 FontFace API를 활용하는 것이었다. 이 API는 JavaScript를 통해 런타임에 폰트를 생성하고, 브라우저의 메모리에 등록할 수 있게 해준다. 정적 CSS에 @font-face를 선언하는 것이 아니라, JS 코드로 동적으로 선언하고 적용할 수 있다는 점이 특징이다.
실제로 작성된 코드는 다음과 같다.
const fontFace = new FontFace(`font-${fontId}`, `url(${fontUrl})`, {
style: 'normal',
weight: '400',
})이 과정을 통해 브라우저는 해당 폰트를 네트워크에서 가져온다. 이후 해당 font-family를 CSS에서 사용할 수 있다.
이 방식의 장점은 유연성이다. 서버에서 어떤 폰트를 쓸지 결정해 클라이언트에 전달하면, 클라이언트는 그때그때 필요한 폰트만 로드할 수 있다. 실제로 내 프로젝트에서는 이 방식이 잘 작동했다. 폰트 미리보기에서 필요한 폰트들을 즉시 렌더링할 수 있었다.
하지만 이 방식의 제약점도 분명했다. 이 API는 브라우저 환경에서만 동작한다. 즉, 서버에서는 이 API를 사용할 수 없다. FontFace는 DOM이나 window 객체처럼 서버 사이드 렌더링 환경에서는 존재하지 않는 브라우저 전용 객체다.
이 말은 결국, 서버에서 아무리 폰트 URL을 알고 있다 하더라도 실제로 폰트를 불러오고 적용하는 작업은 브라우저가 렌더링 이후에 수행해야 한다는 뜻이다. 브라우저의 FontFace API를 사용해야겠다는 생각에 사로잡혀, SSR과 브라우저 간의 관계를 놓쳤던 것 같다.
SSR은 왜 이 문제를 해결하지 못하는가
처음 기대했던 것처럼, SSR을 사용하면 폰트를 미리 불러오고 렌더링된 HTML에 적용된 상태로 사용자에게 전달할 수 있을 것 같았다. 그러나 현실은 달랐다. 가장 핵심적인 이유는 바로 브라우저 전용 API와 CSS 폰트 적용 과정이 클라이언트에서만 작동한다는 점이다.
서버는 HTML을 렌더링할 수는 있지만, 폰트 파일을 메모리에 로드하고 그것을 CSS로 실제 적용한 화면까지 만들어낼 수는 없다. FontFace API처럼 동적으로 폰트를 생성하고 등록하는 작업은 브라우저가 HTML과 JavaScript를 모두 읽은 이후에나 가능한 작업이다.
즉, SSR 환경에서는 다음과 같은 한계가 있다:
FontFace는 서버에서 사용할 수 없다 (브라우저 전용 객체)결국 아무리 SSR로 HTML을 빠르게 전달하더라도, 폰트가 적용되는 시점은 여전히 클라이언트 렌더링 이후라는 사실은 변하지 않는다. 이로 인해 기대했던 "처음부터 커스텀 폰트가 적용된 상태로 보여지는 화면"은 SSR만으로는 구현할 수 없었다.
이러한 한계를 인식하고, 나는 다음과 같은 전략을 고민하게 되었다.
폰트가 로드되기 전까지는 스켈레톤 UI를 활용해 화면을 잠시 감춘 뒤, 폰트가 성공적으로 로드된 이후에 실제 콘텐츠를 보여주자.
이렇게 하면 폰트가 적용되기 전의 ‘깜빡임’ 현상을 줄이고, 사용자 경험을 보다 자연스럽게 만들 수 있다고 판단했다. 하지만 UI 레벨에서의 보완만으로는 부족하다고 느꼈고, 폰트를 더 빠르게 로드하고 적용할 수 있는 구조적인 방법에 대해서도 함께 고민하게 되었다.
실제 적용 전략
클라이언트 환경에서 더 나은 사용자 경험을 만들기 위해 여러 전략을 조합했다. 이 전략들은 단순히 폰트를 불러오는 과정을 넘어서, "어떻게 더 빠르고 자연스럽게 사용자에게 폰트를 적용된 상태를 보여줄 것인가"에 집중하였다.
1. TTF → WOFF2 변환
초기에는 .ttf 형식의 폰트 파일을 사용했지만, 팀원들과 협의하여 .woff2로 변환하여 적용했다. .woff2는 .ttf에 비해 압축률이 높아 품질이 조금 저하될 수 있지만, 용량이 크게 줄어들어 로딩 속도를 개선할 수 있다는 장점이 있었다. 이 변경만으로도 폰트 파일 크기가 6.3MB → 3.0MB로 절반 이상 줄었다.


이를 통하여 네트워크 다운로드 속도도 4.59ms → 2.74ms로 향상되었다.

2. FontFace API를 통한 동적 로딩 + 메모리 캐싱 활용
클라이언트에서는 FontFace API를 통해 동적으로 폰트를 로드하고 있다.
const fontFace = new FontFace(fontFamily, `url(${fontUrl})`)
await fontFace.load()
document.fonts.add(fontFace)이 방식에 새로운 부분을 추가하였다. document.fonts.add()를 통해 폰트를 한 번 메모리에 등록해두면, 브라우저는 동일한 font-family와 URL에 대해서는 다시 요청하지 않는다. 즉, 같은 폰트를 다른 페이지나 컴포넌트에서 재사용할 경우 네트워크 요청 없이 메모리에서 바로 적용되므로 렌더링 속도와 안정성이 크게 향상된다.
이는 브라우저 내부의 FontFaceSet이 캐싱 기능을 제공하기 때문에 가능한 일이다. 덕분에 최초 1회만 로드 비용을 감수하면, 이후 렌더링에서는 깜빡임 없이 부드러운 전환이 가능했다.
3. TanStack Query의 캐싱 기능 활용
초기에는 페이지 이동이나 새로고침이 일어날 때마다 폰트 URL 요청이 반복되었고, 그에 따라 불필요한 네트워크 비용이 발생했다. 이를 해결하기 위해 TanStack Query를 사용하여 다음과 같은 구조로 캐싱 전략을 수립했다.
전체 폰트 리스트 요청 (둘러보기 / 홈 화면 등)
useQuery({
queryKey: ['font-list'],
queryFn: () => fetchFontList(),
staleTime: 1000 * 60, // 1분
gcTime: 1000 * 60 * 5 // 5분
})사용자가 폰트를 업로드했을 때 리스트에 즉시 반영되도록 빠르게 stale 처리를 하였다.
상세 보기 페이지 URL 요청
useQuery({
queryKey: ['font-url', fontId],
queryFn: () => fetchFontUrl(fontId),
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 30, // 30분
})개별 폰트의 상세 정보는 자주 변경되지 않으므로, 캐시를 오래 유지해도 무방하다고 생각하여 전체 폰트 리스트 요청보다 길게 설정하였다.
이를 통해 폰트 요청 결과(woff 파일 URL 등)를 메모리에 저장하고, 일정 시간 동안 재요청 없이 캐시된 데이터를 그대로 사용할 수 있도록 구성했다. 네트워크 부담은 줄이고, 렌더링 성능은 더욱 안정적으로 유지할 수 있었다.
4. 스켈레톤 UI를 통한 FOUT 완화
위 전략들은 폰트를 빠르게 불러오기 위한 기술적 접근이었지만, 깜빡임(FOUT)을 완전히 제거하지는 못한다. 이를 보완하기 위해 텍스트를 즉시 보여주지 않고, 스켈레톤 UI를 통해 자리만 미리 차지해두고 폰트가 완전히 로드되었을 때 콘텐츠를 렌더링하는 방식을 적용할 예정이다.
이 방식은 사용자가 느끼는 시각적 전환을 최소화하며, 기다림을 느끼지 못하게 하면서도 깔끔한 렌더링 경험을 제공할 수 있을 것으로 기대된다.
폰트 로딩 방식에 대한 고찰
이 프로젝트를 진행하며 가장 많이 바뀐 생각은 “빠르게 데이터를 처리하는 기술”이 전부가 아니라는 점이었다. 처음엔 속도 자체에만 집중했다. 브라우저가 커스텀 폰트를 최대한 빨리 받아야, 사용자도 더 좋은 경험을 하게 될 거라 믿었다. 그래서 SSR을 적극적으로 활용해보려 했고, 폰트 URL을 HTML에 직접 넣는 식으로 성능을 높이려 했다.
그러나 SSR로는 해결되지 않는 부분이기도 했고, 개발을 진행하면서 점차 깨달은 점이 있었다. 빠르기만 한 데이터 처리 과정이 사용자 경험을 무조건 향상시키는 것은 아니다. 로딩 UI처럼 부가적인 부분들이 공존해야지만 비로소 사용자 경험이 향상된다고 느꼈다. 단순히 데이터를 빠르게 가져오는 기술만으로는 부족했고, UI적인 보완과 함께 작동해야 효과를 발휘할 수 있었다. 이번 프로젝트에서도 UI적인 처리가 없다면 FOUT처럼 아무리 빠르게 폰트를 가져와도 폰트가 바뀌는 순간의 깜빡임이 사용자 경험을 해치기 때문이다.
그때부터 관점을 바꿔 보기 시작했다. 폰트를 빨리 불러오는 것도 중요하지만, 그보다 먼저 언제, 어떤 타이밍에 어떻게 보여줄지를 설계하는 것이 핵심이라고 생각했다. 스켈레톤 UI, 캐싱 전략, 폰트 적용 시점의 컨트롤 같은 다양한 방법들이 병행되어야만 했다.
결국 데이터 처리는 기술의 영역이지만, 사용자가 불편함을 느끼지 않도록 하는 것은 설계의 영역이라는 것을 깨달았다. 이번에 폰트에 대한 고민을 해보며 단순한 기술적 이슈를 넘어서, 사용자에게 자연스럽고 몰입감 있는 경험을 제공하기 위한 설계에 대한 시간이었다.