FOUC 방지 패턴
웹 페이지를 열었을 때 스타일이 적용되지 않은 날것의 HTML이 순간적으로 보였다가 스타일이 뒤늦게 입혀지는 현상을 본 적이 있을 것이다. 이걸 FOUC(Flash of Unstyled Content)라고 한다. 사용자 입장에서는 페이지가 "깜빡"하면서 레이아웃이 뒤틀렸다 원래대로 돌아오는 것처럼 보이는데, 이 짧은 순간이 사용자 경험을 크게 해친다.
FOUC는 단순한 CSS 로딩 문제를 넘어서, 다크모드 전환, CSS-in-JS hydration, 웹폰트 렌더링, SPA 라우팅 등 다양한 맥락에서 발생한다. 각각의 원인이 다르기 때문에 해결 방법도 다르다.
FOUC는 왜 발생하는가
브라우저가 HTML을 파싱하고 화면에 그리는 과정을 이해해야 한다. 기본 렌더링 파이프라인은 이렇다:
- HTML 파싱 → DOM 트리 구성
- CSS 파싱 → CSSOM 트리 구성
- DOM + CSSOM → 렌더 트리 구성
- 레이아웃 계산 → 페인트
핵심은 CSS가 렌더 블로킹 리소스라는 점이다. <link rel="stylesheet">가 <head>에 있으면 브라우저는 해당 CSS를 다운로드하고 파싱할 때까지 렌더링을 멈춘다. 이건 의도적인 설계다. 스타일 없이 먼저 그려버리면 FOUC가 발생하니까.
그런데 FOUC가 발생한다는 건, 이 "렌더 블로킹" 메커니즘이 어딘가에서 깨졌다는 뜻이다.
원인 1: 스타일시트가 <body>에 위치
<!-- 잘못된 배치 -->
<body>
<h1>Hello</h1>
<link rel="stylesheet" href="styles.css">
</body>
<head>가 아닌 <body> 안에 스타일시트가 있으면, 브라우저는 해당 <link> 태그를 만나기 전까지의 HTML을 스타일 없이 먼저 렌더링할 수 있다. 특히 점진적 렌더링(progressive rendering)을 하는 브라우저에서 이 문제가 두드러진다.
원인 2: @import 사용
/* main.css */
@import url("reset.css");
@import url("theme.css");
body { color: #333; }
@import는 CSS 파일 안에서 다른 CSS를 로드한다. 문제는 브라우저가 main.css를 다운로드하고 파싱한 후에야 reset.css와 theme.css의 존재를 알게 된다는 것이다. 병렬 다운로드가 불가능해지면서 워터폴(waterfall) 현상이 발생한다.
main.css ████████
reset.css ████████
theme.css ████████
→ 렌더 시작
<link> 태그를 여러 개 쓰면 병렬 다운로드가 가능하다:
main.css ████████
reset.css ████████
theme.css ████████
→ 렌더 시작
같은 CSS를 로드하는데 시간 차이가 크다. @import를 빌드 타임에 번들링하거나, <link> 태그로 변환하는 게 좋다.
원인 3: JavaScript로 동적 스타일 주입
SPA 프레임워크나 CSS-in-JS 라이브러리(styled-components, Emotion 등)는 JavaScript가 실행되면서 스타일을 <style> 태그로 DOM에 삽입한다. 문제는 JavaScript가 실행되기 전까지는 해당 스타일이 존재하지 않는다는 점이다.
HTML 도착 → DOM 렌더 (스타일 없음) → JS 로드 → JS 실행 → 스타일 삽입 → 리페인트
↑ FOUC 발생 구간
SSR을 하면 서버에서 HTML과 함께 CSS를 미리 추출해서 <head>에 넣어줄 수 있다. 하지만 hydration 과정에서 클라이언트와 서버의 스타일 ID가 불일치하면 또다시 깜빡임이 생긴다.
원인 4: 웹폰트 로딩 (FOUT/FOIT)
FOUC의 변형으로 FOUT(Flash of Unstyled Text)와 FOIT(Flash of Invisible Text)가 있다.
- FOUT: 시스템 폰트로 먼저 텍스트를 보여주다가, 웹폰트가 로드되면 교체. 글자 크기가 달라서 레이아웃이 흔들린다.
- FOIT: 웹폰트가 로드될 때까지 텍스트를 아예 안 보여줌. 폰트 로딩이 느리면 빈 화면이 오래 지속된다.
@font-face {
font-family: "Pretendard";
src: url("/fonts/Pretendard.woff2") format("woff2");
/* font-display로 동작을 제어할 수 있다 */
}
방지 패턴 1: 올바른 CSS 로딩 순서
가장 기본적이지만 효과적인 방법이다.
<head>
<!-- 크리티컬 CSS는 인라인으로 -->
<style>
body { margin: 0; font-family: system-ui; }
.layout { display: grid; grid-template-columns: 1fr 3fr; }
</style>
<!-- 나머지 CSS는 link로 -->
<link rel="stylesheet" href="/css/main.css">
<!-- @import 사용 금지 → 별도 link 태그로 분리 -->
<link rel="stylesheet" href="/css/reset.css">
<link rel="stylesheet" href="/css/theme.css">
</head>
크리티컬 CSS 인라이닝이 핵심이다. 첫 화면(above-the-fold)에 필요한 최소한의 스타일만 <style> 태그로 HTML에 직접 삽입한다. 추가 HTTP 요청 없이 즉시 적용되기 때문에 FOUC가 원천적으로 발생하지 않는다.
나머지 CSS는 비동기로 로드할 수도 있다:
<link rel="preload" href="/css/non-critical.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript>
<link rel="stylesheet" href="/css/non-critical.css">
</noscript>
preload로 다운로드를 시작하되, onload에서 rel을 stylesheet로 바꿔서 비렌더 블로킹으로 로드한다. 첫 화면에 필요 없는 CSS가 전체 렌더링을 지연시키는 걸 막는다.
방지 패턴 2: 다크모드 FOUC 방지 (블로킹 스크립트)
다크모드에서 FOUC가 발생하는 전형적인 시나리오:
- 사용자가 다크모드를 선택함 →
localStorage에 저장 - 페이지 새로고침 → HTML이 기본 라이트모드로 렌더링
- JavaScript 로드 →
localStorage읽음 → 다크모드 적용 - 화면이 흰색 → 검은색으로 깜빡
해결책은 <head> 안에 블로킹 스크립트를 넣는 것이다:
<head>
<script>
// 이 스크립트는 렌더링 전에 동기적으로 실행된다
(function() {
const theme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && prefersDark)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
<style>
/* 다크모드 기본 스타일 */
:root { --bg: #ffffff; --text: #1a1a1a; }
.dark { --bg: #0a0a0a; --text: #e5e5e5; }
body { background: var(--bg); color: var(--text); }
</style>
</head>
왜 이게 동작하는가? <script> 태그(async/defer 없이)는 파서 블로킹이다. 브라우저는 이 스크립트의 실행이 끝날 때까지 이후 HTML 파싱과 렌더링을 중단한다. 즉, 화면에 아무것도 그려지기 전에 document.documentElement에 dark 클래스가 추가된다.
"블로킹 스크립트는 성능에 나쁘다"고 배웠을 수 있다. 맞다. 하지만 이 경우는 예외다. 이 스크립트는:
- 외부 파일이 아니라 인라인이므로 네트워크 요청이 없다
localStorage접근과 클래스 토글뿐이므로 실행 시간이 1ms 미만이다- FOUC를 완전히 제거하므로 사용자 경험 이득이 크다
Next.js에서의 적용
Next.js App Router에서는 layout.tsx의 <html> 태그에 직접 적용하기 어렵다. next-themes 같은 라이브러리가 이 패턴을 자동으로 처리해준다. 내부적으로 <script dangerouslySetInnerHTML>을 사용해서 블로킹 스크립트를 주입한다.
// next-themes가 내부적으로 하는 일의 단순화 버전
<head>
<script
dangerouslySetInnerHTML={{
__html: `
try {
let theme = localStorage.getItem('theme');
if (!theme) theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
} catch(e) {}
`,
}}
/>
</head>
colorScheme 속성도 같이 설정하는 게 중요하다. 이걸 빠뜨리면 스크롤바, 폼 요소 등 브라우저 기본 UI가 라이트모드로 먼저 렌더링된 후 전환된다.
방지 패턴 3: CSS-in-JS SSR 스타일 추출
CSS-in-JS 라이브러리에서 SSR 없이 사용하면 FOUC가 필연적으로 발생한다. 서버에서 스타일을 미리 추출해서 HTML에 포함시켜야 한다.
styled-components
// _document.tsx (Next.js Pages Router)
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
};
} finally {
sheet.seal();
}
}
}
ServerStyleSheet가 렌더링 과정에서 생성되는 모든 스타일을 수집한다. getStyleElement()로 <style> 태그로 변환해서 <head>에 포함시킨다. 클라이언트에서 hydration이 일어날 때, 이미 스타일이 적용되어 있으므로 FOUC가 없다.
Emotion (+ MUI 등)
// app/layout.tsx (Next.js App Router)
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { useServerInsertedHTML } from 'next/navigation';
import { useState } from 'react';
function EmotionRegistry({ children }: { children: React.ReactNode }) {
const [cache] = useState(() => {
const c = createCache({ key: 'css' });
c.compat = true;
return c;
});
useServerInsertedHTML(() => {
const entries = Object.entries(cache.inserted);
if (entries.length === 0) return null;
const names = entries.map(([n]) => n).join(' ');
const styles = entries.map(([, s]) => s).join('');
// 추출 후 캐시 비우기
cache.inserted = {};
return (
<style
data-emotion={`${cache.key} ${names}`}
dangerouslySetInnerHTML={{ __html: styles }}
/>
);
});
return <CacheProvider value={cache}>{children}</CacheProvider>;
}
원리는 같다. 서버에서 렌더링하면서 발생한 스타일을 모아서 HTML에 인라인한다.
방지 패턴 4: 웹폰트 FOUT/FOIT 제어
font-display 속성
@font-face {
font-family: "Pretendard";
src: url("/fonts/Pretendard-Regular.woff2") format("woff2");
font-display: swap;
}
font-display는 폰트 로딩 중 텍스트를 어떻게 보여줄지 결정한다:
| 값 | 동작 | 적합한 상황 |
|---|---|---|
auto | 브라우저 기본값 (대부분 block과 유사) | — |
block | 최대 3초간 텍스트 숨김(FOIT), 이후 폴백 | 아이콘 폰트 |
swap | 즉시 폴백 폰트 표시, 로드 후 교체(FOUT) | 본문 텍스트 |
fallback | 100ms 숨김 후 폴백, 3초 내 로드 안 되면 폴백 유지 | 균형잡힌 선택 |
optional | 100ms 숨김 후 폴백, 이미 캐시에 있을 때만 사용 | 성능 최우선 |
swap이 가장 많이 쓰인다. 텍스트가 즉시 보이니 사용자가 콘텐츠를 읽을 수 있고, 폰트가 로드되면 교체된다. 다만 교체 시 레이아웃 시프트(CLS)가 발생할 수 있다.
프리로드로 로딩 시간 단축
<head>
<link rel="preload" href="/fonts/Pretendard-Regular.woff2"
as="font" type="font/woff2" crossorigin>
</head>
preload는 브라우저에게 "이 리소스가 곧 필요하니 미리 다운로드해"라고 알려준다. CSS 파일을 파싱해서 @font-face를 발견하기 전에 다운로드가 시작되므로, 폰트 로딩 시간이 크게 단축된다.
crossorigin 속성은 반드시 필요하다. 폰트 파일은 CORS 모드로 요청되는데, preload에서 crossorigin을 빠뜨리면 non-CORS 모드로 요청이 가서 중복 다운로드가 발생한다.
size-adjust로 레이아웃 시프트 최소화
@font-face {
font-family: "Pretendard Fallback";
src: local("Arial");
size-adjust: 98.5%;
ascent-override: 105%;
descent-override: 20%;
line-gap-override: 0%;
}
body {
font-family: "Pretendard", "Pretendard Fallback", sans-serif;
}
폴백 폰트의 메트릭스를 웹폰트와 최대한 일치시키는 기법이다. size-adjust로 글자 크기를, ascent-override/descent-override로 줄 높이를 맞추면 폰트 교체 시 레이아웃 변화가 거의 없어진다.
Next.js의 next/font는 이 작업을 자동으로 해준다:
import { Noto_Sans_KR } from 'next/font/google';
const notoSansKr = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap',
// 자동으로 size-adjust가 적용된 폴백 폰트를 생성
});
방지 패턴 5: hydration 불일치 방지
React SSR/SSG에서 서버가 렌더링한 HTML과 클라이언트 hydration 결과가 다르면 FOUC가 발생한다. 대표적인 케이스가 "클라이언트에서만 알 수 있는 상태"에 의존하는 UI다.
// ❌ hydration 불일치 발생
function Greeting() {
const hour = new Date().getHours();
return <div className={hour > 18 ? 'dark-bg' : 'light-bg'}>
Hello
</div>;
}
서버는 서버 시간 기준으로 렌더링하고, 클라이언트는 사용자 로컬 시간 기준으로 hydrate한다. 시간대가 다르면 클래스가 바뀌면서 깜빡인다.
해결: 2단계 렌더링
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// 마운트 전에는 서버와 동일한 기본값 사용
if (!mounted) {
return <div style={{ visibility: 'hidden' }}>{children}</div>;
}
return <div data-theme={getClientTheme()}>{children}</div>;
}
서버 렌더링 시에는 기본값을 사용하고, 클라이언트에서 useEffect가 실행된 후에야 클라이언트 전용 값을 적용한다. visibility: hidden으로 서버 렌더링 결과를 숨기되 레이아웃은 유지한다(display: none과 다르게 공간을 차지).
하지만 이 방식은 여전히 깜빡임이 있을 수 있다. hydration이 완료되기 전까지 콘텐츠가 안 보이기 때문이다. 그래서 다크모드 같은 경우에는 패턴 2의 블로킹 스크립트가 더 효과적이다.
suppressHydrationWarning
<html lang="ko" suppressHydrationWarning>
블로킹 스크립트로 <html>에 클래스나 속성을 추가하면, 서버 렌더링 결과와 클라이언트 DOM이 달라서 React가 hydration 경고를 띄운다. suppressHydrationWarning은 해당 요소(자식 제외)의 경고만 무시한다.
이건 "경고를 무시해도 괜찮은 상황"에서만 써야 한다. 블로킹 스크립트가 서버 HTML과 클라이언트 DOM의 차이를 의도적으로 만드는 거니까 문제없다.
방지 패턴 6: requestAnimationFrame 배칭
JavaScript로 여러 DOM 변경을 할 때, 중간 상태가 화면에 잠깐 보이는 문제가 있다.
// ❌ 중간 상태가 보일 수 있음
element.classList.remove('old-theme');
// 이 시점에서 스타일 없는 상태가 순간적으로 렌더링될 수 있음
element.classList.add('new-theme');
브라우저는 보통 JavaScript 실행이 끝난 후에 렌더링하지만, 긴 작업이나 강제 리플로우(reflow)를 트리거하는 코드가 중간에 있으면 중간 상태가 보일 수 있다.
// ✅ requestAnimationFrame으로 배칭
requestAnimationFrame(() => {
document.documentElement.setAttribute('data-theme', newTheme);
document.documentElement.style.colorScheme = newTheme;
});
requestAnimationFrame 콜백은 다음 렌더링 프레임 직전에 실행된다. 콜백 안의 모든 DOM 변경이 한 번에 적용되므로 중간 상태가 노출되지 않는다.
더 강력한 패턴은 이중 requestAnimationFrame이다:
// 현재 프레임의 렌더링을 기다린 후, 다음 프레임에서 변경
requestAnimationFrame(() => {
requestAnimationFrame(() => {
element.classList.add('visible');
});
});
첫 번째 rAF에서 초기 렌더링이 확정되고, 두 번째 rAF에서 변경을 적용한다. CSS 트랜지션을 확실히 트리거하고 싶을 때 유용하다. 첫 프레임에서 초기 상태가 렌더링되어야 트랜지션의 시작점이 존재하기 때문이다.
실전 체크리스트
FOUC 방지는 한 가지 기법만으로 해결되지 않는다. 여러 패턴을 조합해야 한다:
1. CSS 로딩
├─ 크리티컬 CSS 인라이닝 (above-the-fold)
├─ <head>에 <link rel="stylesheet"> 배치
├─ @import 제거 또는 빌드 타임 번들링
└─ 비크리티컬 CSS는 preload + onload 패턴
2. 다크모드 / 테마
├─ <head>에 인라인 블로킹 스크립트
├─ colorScheme 속성 동시 설정
└─ suppressHydrationWarning
3. CSS-in-JS (styled-components, Emotion 등)
├─ 서버 스타일 추출 (ServerStyleSheet 등)
└─ hydration ID 일치 확인
4. 웹폰트
├─ font-display: swap (또는 optional)
├─ <link rel="preload"> + crossorigin
├─ size-adjust 폴백 폰트 (또는 next/font)
└─ 서브셋 최적화 (불필요한 글리프 제거)
5. hydration
├─ 클라이언트 전용 상태 → useEffect 후 적용
├─ 서버/클라이언트 렌더링 결과 일치 확인
└─ visibility: hidden으로 전환 중 숨기기
FOUC가 발생하면 당황하지 말고, 원인이 어느 단계에 있는지부터 파악하자. CSS 로딩 자체의 문제인지, JavaScript 의존적 스타일 적용의 문제인지, hydration 불일치의 문제인지. 원인을 특정하면 위 패턴 중 적절한 것을 적용할 수 있다.
정리
- FOUC는 CSS 로딩 순서, JS 의존 스타일, hydration 불일치, 웹폰트 등 원인이 다양하므로 먼저 어느 단계에서 발생하는지 특정해야 한다
- 다크모드 FOUC는
<head>인라인 블로킹 스크립트 + colorScheme 동시 설정으로 1ms 이내에 해결할 수 있고, 이 패턴이 next-themes 등 라이브러리의 내부 구현이기도 하다 - 웹폰트는 font-display: swap + preload + size-adjust 폴백 조합으로 FOUT/FOIT와 레이아웃 시프트를 동시에 억제한다
관련 문서
- CSS Custom Properties 런타임 테마 - 변수 기반 테마 시스템 설계
- next-themes - Next.js 다크모드 FOUC 자동 처리
- Pretendard 웹폰트 - 한글 웹폰트 최적화