Next.js 16 프로젝트에서 폰트 로드 중 렌더블로킹 waterfall 문제를 만났다. 렌더블로킹에 대해 자세히 알아보고 waterfall 문제를 해결하는 과정을 정리했다.
당시 CSS 파일에서 아래와 같은 방식으로 폰트를 로드하고 있었다.
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,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 를 사용하면 빠른 편에 속한다.