tw.heo Github

폰트 로드 중 렌더블로킹 waterfall 문제 해결하기

Next.js 16 프로젝트에서 폰트 로드 중 렌더블로킹 waterfall 문제를 만났다. 렌더블로킹에 대해 자세히 알아보고 waterfall 문제를 해결하는 과정을 정리했다.

Mar 7, 2026

#Next.js

Next.js 16 프로젝트에서 폰트 로드 중 렌더블로킹 waterfall 문제를 만났다. 렌더블로킹에 대해 자세히 알아보고 waterfall 문제를 해결하는 과정을 정리했다.

당시 CSS 파일에서 아래와 같은 방식으로 폰트를 로드하고 있었다.

@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200&display=swap");

CSS 파일은 HTML 에서 로드한다. 브라우저는 <link> 를 만나면 네트워크 스레드에 요청을 보낸다. 이후 다운로드된 CSS 파일을 읽기 시작하는데, 이때 @import 를 만나야만 새로운 URL 로 폰트 CSS 요청을 보낸다.

  • HTML 파싱 → CSS 다운로드 -> CSS 해석 -> 폰트 CSS 요청

즉, CSS 파일을 로드하면서 렌더 블로킹이 일어나며, 해당 파일 내 폰트 요청 시 또 한 번의 렌더 블로킹이 waterfall 로 발생한다.

<!-- index.html 예시 -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>여행 장바구니</title>
<!-- 여기서 브라우저는 globals.css를 다운로드할 때까지 화면 그리기를 멈춤. -->
<link rel="stylesheet" href="/styles/globals.css" />
</head>
<body>
<!-- 브라우저는 아직 여기까지 도달하지 못함. 화면은 백지 상태 -->
<h1>안녕하세요!</h1>
</body>
</html>

render blocking

렌더 블로킹이란 브라우저에서 렌더링이 막히는 것을 말한다. 브라우저에서 렌더링이 되기 위해선 HTML, CSS 파싱 후 Render Tree 를 만들어야 한다.

위 상황에서 CSS 파일을 읽기 위해 렌더블로킹이 일어난 건 이러한 브라우저의 기본 동작 때문이다.

만약 CSS 를 무시하고 HTML 만으로 먼저 렌더링한다면? 스타일이 적용되지 않은 내용이 순간적으로 보였다가 다시 스타일이 적용되는 깜빡임을 보게 될 것이다. 이를 FOUC(Flash Of Unstyled Content) 라고 한다.

참고로 JS 도 블로킹을 발생시키지만, 렌더블로킹이 아닌 파서블로킹이라는 차이가 있다. 파서블로킹은 파싱 즉, 읽는 과정 자체를 중단시키는 것이다. 브라우저가 HTML 을 읽다가 <script> 태그를 만나면 그 지점에서 파싱을 중단하고 JS 를 로드하게 되는 것을 말한다. 이렇게 설계된 이유는 JS 에서 HTML 과 CSS 를 모두 제어할 수 있기 때문이다.

비동기 로드

JS 는 async, defer 를 통해 파서 블로킹을 피할 수 있다.

  • async 는 비동기로 JS 를 다운로드한다. 이때는 파싱이 중단되지 않는다. 또한 다운로드가 끝나자마자 실행되며, 실행 시 HTML 파싱이 중단된다.
  • defer 또한 비동기로 JS 를 다운로드한다. 다만 HTML 이 파싱된 이후 실행되기 때문에 파서블로킹이 없다.

그렇다면 CSS 에도 비동기 로드가 있을까? 바로 media="print" 이다. media="print" 로 선언하면 브라우저가 렌더링에 해당 CSS 파일이 필요없다고 판단하고 블로킹하지 않는다(우회). 다만 트레이드오프가 있다면, CSS 없이 HTML 만으로 render 하기 때문에 FOUC 문제가 발생할 수 있다.

해결

waterfall 제거

waterfall 문제부터 해결해야 한다. 방법은 간단하다. 폰트를 <head> 에서 CSS 파일과 함께 선언하면 된다. 또한 preconnect 를 추가하여 더 빠르게 받아올 수 있도록 하였다.

<!-- index.html 예시 (layout.tsx에 적용할 경우의 결과물) -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>여행 장바구니</title>
<!-- preconnect -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin="anonymous"
/>
<!-- 구글 폰트 다운로드와 globals.css 다운로드를 병렬로 진행 -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined&display=swap"
/>
<link rel="stylesheet" href="/styles/globals.css" />
</head>
<body>
<h1>안녕하세요!</h1>
</body>
</html>

preconnect 란 브라우저에게 “리소스를 받아올 서버와 미리 연결해라” 라고 알려주는 것과 같다. DNS, TCP handshake 등 네트워크 연결에 필요한 과정을 미리 수행한다. 이때 crossorigin 속성은 반드시 필요하다.

폰트 파일(.woff2 등)은 보안상의 이유로 CORS 정책을 따르는데, crossorigin 이 없다면, 브라우저는 미리 연결을 맺어놓고도 기존 연결을 버리고 새로 연결을 맺는다. 이러면 결국 preconnect 는 무용지물이 된다.

또한 폰트를 보면 display=swap 을 볼 수 있다. display 속성은 폰트 로드가 늦을 경우 폰트를 어떻게 보여줄 지에 대한 설정이다.

  • block: 폰트가 로드될 때까지 멈춤
  • swap: 기본 폰트로 먼저 보여주고 로드되면 교체
    • LCP 는 개선되지만 레이아웃 시프트를 주의(UX)
  • fallback: 짧게 대기(100ms) 후 기본 폰트 표시, 로드되면 교체
  • optional: 아주 짧게 대기, 못받으면 기본 폰트로 확정

렌더블로킹 최소화

그렇다면 렌더블로킹은 어떻게 최소화 할 수 있을까? 이는 폰트를 빠르게 로드해야 하는 문제다. 그래서 next/font 를 사용했다.

next/font 는 빌드에 폰트파일을 포함시켜 웹서버에서 함께 서빙하는 방식이다. 외부 서버 연결이 없어 추가적인 연결비용이 제거된다는 장점이 있다.

  • 추가 장점으론 size-adjust 를 생성해준다는 것
  • size-adjust 란 기본폰트와 커스텀폰트 간 크기 차이로 인해 발생하는 레이아웃 시프트를 방지하기 위한 속성
  • 기본폰트를 커스텀 폰트 크기에 맞춰 조정하는 역할
import { MaterialSymbols } from "next/font/google";
const icons = MaterialSymbols({
weight: ["400", "700"],
display: "swap",
});

다만, 항상 빌드에 폰트를 포함시키는게 정답은 아니다. 만약 웹서버와 물리적으로 거리가 먼 곳에서 요청할 경우 오히려 CDN 서버가 전세계에 구성된 Google Fonts 를 다운로드하는게 더 빠르기 때문이다. 물론 Vercel 또한 CDN 서버가 전세계에 구성되어 있기에 next/font 를 사용하면 빠른 편에 속한다.