04. Next.js에서 Emotion사용하기

flee | 플리·2026년 3월 17일

recode-Emotion

목록 보기
4/5
post-thumbnail

CSR(Client-Side-Rendering) 환경에서 신나게 Emotion을 사용하다가,
Next.js SSR(Server-Side-Rendering) 환경에 아무 설정 없이 사용려고 하면 문제가 발생해서 띠용한 경험 있지 않으신가요?
( 맞아요... 제 경험이에요... )

대부분은 SSR 환경에서 CSS-in-JS가 동작하는 방식이 원인입니다..
(나 자신!) 이제 SSR 환경의 작동 원리를 다시 이해해보자!

SSR 환경에서 Emotion의 문제점

1. CSS-in-JS의 SSR 동작 원리

Emotion은 기본적으로 런타임에 스타일을 생성한다.

런타임 : 컴파일 후 코드가 실제로 실행되는 순간/환경

컴포넌트가 랜더링될 때 스타일을 계산하고, 해당 스타일을 <style> 태그로 DOM에 동적으로 삽입하는 방식이다.

CSR 환경에서는 이 방식이 문제가 없다.
브라우저에서 JS가 실행되면서 스타일도 함께 삽입되기 때문이다.

그런데,
SSR에선 문제가 발생한다.
서버에서는 HTML 문자열을 만들어 응답하는데,
기본 설정에는 Emotion이 생성한 스타일이 해당 HTML에 포함되지 않는다.
( 당연하다! JS가 실행되기 전이니 포함이 안된다! )

그 결과 브라우저는 스타일이 없는 HTML을 먼저 받아 화면에 그리고, 이후 JS가 실행되며 뒤늦게 스타일이 적용된다.


2. Critical CSS 누락 문제 - FOUC

위의 상황은 실제 사용자에게 '스타일이 없는 날것의 HTML'이 순간적으로 노출된다.
이를 FOUC(Flash of Unstyled Content)라고 한다.
( 완전...밤티 이미지... )

JS 번들이 로드되고 Hydration이 완료되기 전까지, 사용자는 레이아웃이 깨진 화면을 보게 된다.
네트워크가 느릴수록 이 현상은 더 두드러진다.

그런데, Hydration? 얘는 또뭘까?


3. Hydration

SSR 환경에서 React가 동작하는 방식을 이해하려면 Hydration 개념이 필수다.

3-1. Hydration 개념 정의

" ReactSSR로 생성된 HTML을 재사용하기 위해 서버에서 생성된 DOM클라이언트에서 렌더링한 결과가 일치하는지 검증하는 과정 "

SSR은 서버에서 완성된 HTML을 만들어 브라우저에 전달한다.
덕분에 사용자는 JS가 실행되기 전에도 화면을 볼 수 있다.
그런데, 이 HTML은 정적인 문자열일 뿐이다. 버튼을 눌러도 반응이 없고, 상태도 없다.

이후 JS 번들이 로드되면 React가 개입한다.
React서버에서 받은 HTML을 버리고 새로 그리는게 아니라, 기존 DOM에 이벤트 핸들러와 상태를 붙여 App으로 만든다.

이 과정을 수화, Hydration이라고 한다.

3-2. Hydration 성립 조건

서버가 만든 HTML과 클라이언트 React가 렌더링하는 결과가 100% 일치해야 한다.

React는 둘을 비교하여 검증하기 때문이다.

조금이라도 다르면,
React는 경고를 띄우거나, 아예 SSR 결과를 버리고 클라이언트에서 전체를 다시 렌더링한다.

이 전제 조건 때문에 Emotion 같은 CSS-in-JS에서 className 불일치 문제가 발생한다.


4. Hydration 불일치 문제

Hydration 불일치는 FOUC보다 더 심각한 문제이다.
FOUC는 UI가 추후 제대로 작동하지만, Hydration은 React가 SSR 결과물을 아예 폐기하고 클라이언트에서 전체를 다시 렌더링하여서 SSR을 선택하는 장점 자체가 사라지는 셈이다!

에러 발생 원인

Emotion은 스타일을 기반으로 className 해시값을 런타임에 생성한다.

해시값(Hash Value)
임의의 데이터를 고정된 길이의 문자열로 변환한 결과값
ex. css-abc123 에서 abc123이 해시값이다.

별도의 설정이 없으면 서버와 클라이언트가 각자 독립적인 카운터로 해시를 계산하는데, 렌더링 순서나 타이밍에 따라 동일한 스타일이더라도 서로 다른 className이 나올 수 있다.

	Warning: Prop `className` did not match.
	Server: "css-abc123" Client: "css-xyz789"

이 에러가 콘솔에 출력되면,
(심한 경우)ReactSSR 결과물을 버리고 전체를 클라이언트에서 다시 렌더링한다.

원인은 크게 두 가지다.

원인1. 캐시 인스턴스 따로 증가

"서버와 클라이언트가 각자 해시값을 만드는 문제"

Emotion은 스타일을 만들 때마다 고유한 이름표(className)을 붙인다.
css-1, css-2, css-3 이런식으로, 만들어진 순서대로 번호가 올라간다.

번호를 관리하는 장부가 바로 캐시(Cache)이다.
쉽게 말해 "나는 지금까지 몇 번째 스타일까지 만들었다"를 기억하는 메모장이다.

문제는 SSR 환경에서 캐시(메모장)가 서버용, 클라이언트용 따로따로 존재한다는 점이다.

클라이언트에서만 추가로 렌더링 되는 스타일이 끼어들면(ex. 'use client' 사용한 컴포넌트 스타일) 번호가 어긋나기 시작한다.
( 문제 발생 시작!! )

서버는 헤더에 css-1이라고 붙여 HTML을 만들어도, 클라이언트가 헤더에 css-2라고 알고 있는 상황이다.
React가 둘을 비교하면, 에러를 낸다.

정리하면,
공유된 하나의 캐시(메모장)를 같이 보지 않고, 각자 처음부터 번호를 새로 매기기 떄문에 생기는 문제다.

이를 해결하려면
1. 서버에서 만든 메모장의 상태를
2. 클라이언트에 그대로 전달하여
3. 클라이언트가 이어서 번호를 매기도록 만들어야 한다.

원인2. @emotion/babel-plugin 미적용

className이 렌더링 순서에 따라 결정되는 숫자 해시라는 점이 근본 원인이다.

플러그인 없이 빌드하면 클래스명에 안정적인 식별자가 붙지 않아 순서에만 의존하게 된다.
조건부 렌더링이나 동적 스타일이 있을 때 특히 불일치가 잘 발생한다.


5. Hydration 에러 해결하기

원인이 두 가지였으니, 해결책도 그에 맞게 맞춰 작성해봅니당~

5-1. @emotion/cache로 캐시 분리

@emotion/cacheEmotion이 스타일을 저장하고 관리하는 인스턴스다.

기본적으로 전역 단일 인스턴스를 사용하는데,
SSR 환경에서는 요청마다 독립된 캐시 인스턴스를 생성해야한다.

그렇지 않으면 이전 요청의 스타일이 다음 요청에 오염되는 문제가 생긴다.

오염을 방지 하기 위해 key 옵션을 사용하면 된다.
key는 생성되는 className의 접두사(prefix)가 된다. (css-abc123에서 css 부분)
이로 인해 요청마다 안정되고 독립된 캐시 인스턴스를 생성할 수 있게 된다.

여러 캐시 인스턴스를 구분해야 하는 경우 key를 다르게 지정하면 된다.
( 좋은 전략 방법! )

추가 설정
" prepend: true로 스타일 삽입 순서 제어 하기!! "
prepend:true를 설정하면 Emotion이 생성한 <style> 태그를 <head>의 맨 앞에 삽입한다.
이렇게 설정해두면 전역 CSS나 다른 라이브러리 스타일보다 Emotion의 스타일이 먼저 선언되어,
나중에 선언된 스타일이 덮어쓸 수있는 구조가 만들어진다.
스타일 우선순위를 예측 가능하게 유지하는 데 도움이 된다!!!

5-2. @emotion/babel-plugin/SWC로 클래스명 안정화

Hydration 불일치의 근본 원인 중 하나는 className이 렌더링 순서에 따라 결정되는 숫자, 해시라는 점이다.

빌드 타임에 '각 스타일에 컴포넌트 이름 + 파일 경로 기반의 안정적인 레이블'을 추가한다.

서버와 클라이언트가 동일한 컴포넌트를 렌더링하면 항상 같은 className을 생성하게 되어 Hydration 불일치가 해소된다.

	// 플러그인 없음 — 순서에 의존
	css-1, css-2, css-3 ...

	// 플러그인 적용 후 — 안정적인 식별자
	css-Button-abc123, css-Header-xyz789 ...

Babel을 사용하는 경우

@emotion/babel-plugin을 사용하면 이 문제를 해결할 수 있다.

Next.js에서 SWC 컴파일러를 사용하는 경우

Babel 대신 next.config.js에서 설정한다.

SWC 컴파일러란?
컴파일러 : 사람이 작성한 코드를 브라우저가 읽을 수 있게 변환해주는 도구

우리가 작성한 코드(TypeScript,.jsx, .tsx)는 브라우저가 바로 이해하지 못한다.
브라우저는 순수한 JavaScript만 읽을 수 있기 때문에, 작성한 코드를 브라우저가 읽을 수 있는 JavaScript로 변환하는 과정이 필요하다.
변환을 해주는 도구를 컴파일러 라고 부른다.
기존에는 이 역할을 Babel이 담당했다.
그런데, Babel은 JavaScript로 만들어져 있어 변환 속도가 느리다는 단점이 있다.

SWC는 이 Babel을 대체하기 위해 만들어진 컴파일러이다.
Rust로 작성되어 있어 Babel보다 훨씬 빠르다.
Next.js 12부터 기본 컴파일러로 채택됬다.

@emotion/babel-plugin은 말그대로 Babel 전용 플러그인이다.
프로젝트가 SWC를 사용하고 있으면, 이 플러그인을 그대로 쓸 수 없고,
대신 next.config.js에서 SWC용 Emotion 옵션을 따로 설정해줘야 한다.

5-3. App Router : useServerInsertedHTML로 스타일 동기화

App Router에서는 _document.tsx 방식을 사용할 수 없다.
대신 React 18에서 추가된 useServerInsertedHTML 훅을 활용한다.

useServerInsertedHTML
: 서버 렌더링 중 생성된 스타일을 HTML에 직접 삽입할 수 있게 해준다.

흐름을 정리하면 아래와 같다.

  1. 서버 렌더링 시작
  2. EmotionCacheProvider가 캐시 인스턴스 생성
  3. 컴포넌트 트리 렌더링 ( 스타일이 cache.inserted에 누적 )
  4. useServerInsertedHTML 콜백 실행
  5. 누적된 스타일을 <style> 태그로 HTML에 삽입
  6. 브라우저에 스타일이 포함된 HTML 전달
  7. Hydration 시 서버/클라이언트 className 일치

6. SSR + Emotion 설정 순서

일단 심호흡 한번 하자...
이 글을 정리하며 당시 에러 상황이 생각나서 너무 호흡이 딸렸다..

다시는 이런 아찔상황을 겪지 않기 위해 아래 순서대로 설정을 진행하는 걸 잊지 말자.
( 컨시컨브 편하게 하기 위해 이미지가 아닌 점.. 참고 부탁드립니다..^0^~ )

6-1. 패키지 설치

npm install @emotion/react @emotion/styled @emotion/cache @emotion/server

6-2. 컴파일러 설정 - SWC(Next.js 기본)

// next.config.js
const nextConfig = {
	compiler: {
    	emotion:true, // 클래스명 안정화
    },
};

module.exports = nextConfig;

6-3. 캐시 생성 유틸 작성

// lib/emotionCache.ts
import createCache from '@emotion/cache';

export function createEmotionCache() {
	return createCache({ key:'css', prepend:true });
}

파일 주소는 임의로 저렇게 적어놨는데, 파일 구조 형식에 맞게 위치하면 됩니다~

6-4. EmotionCacheProvider 작성(App Router)

// lib/EmotionCacheProvider.tsx
'use client';

import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider } from '@emotion/react';
import { createEmotionCache } from './emotionCache';
import { useState } from 'react';

export default function EmotionCacheProvider({ children} : { children:React.ReactNode}) {
	const [cache] = useState(()=>{
    	const c = createEmotionCache();
        c.compat = true;
        return c;
    });
    
    useServerInsertedHTML(()=>(
    	<style 
        	data-emotion={`${cache.key} ${Object.keys(cache.inserted).join(' ')}`}
            dangerouslySetInnerHTML={{__html:Object.values(cache.inserted).join(' ')}}
        />
    ));
    
    return <CacheProvider value={cache}>{children}</CacheProvider>
}

6-5. layout.tsx 적용

// app/layout.tsx
import EmotionCacheProvider from '@/lib/EmotionCacheProvider';

export default function RootLayout({ children }:{ children: React.ReactNode}) {
	return(
    	<html>
        	<body>
            	<EmotionCacheProvider>{children}</EmotionCacheProvider>
            </body>
        </html>
    );
}

설정을 완료한다면, 아래와 같은 흐름으로 동작한다.
( 그래도 에러 해결이 안된다면...다시 검토해보자 )

  1. 서버 렌더링 시작
  2. EmotionCacheProvider가 캐시 인스턴스 생성
  3. 컴포넌트 트리 렌더링 (스타일이 cache.inserted에 누적)
  4. useServerInsertedHTML 콜백 실행
  5. 누적된 스타일을 <style> 태그로 HTML에 삽입
  6. 브라우저에 스타일이 포함된 HTML 전달
  7. Hydration 시 서버/클라이언트 className 일치

7. 흔히 겪는 Hydration 에러 사례 및 디버깅

7-1. @emotion/babel-plugin 미적용

가장 흔한 원인.
플러그인 없이 사용하면, className이 렌더링 순서에 의존하게 되어 조건부 렌더링이나 동적 스타일이 있을 때 불일치가 발생한다.

@emotion/babel-plugin or SWC emotion 옵션 적용으로 해결.

7-2. 캐시 인스턴스가 요청 간 공유됨

createEmotionCache()를 모듈 최상단에서 한 번만 호출하면,
모든 요청이 같은 캐시를 공유한다.
이전 요청의 스타일이 섞여 들어온다.

→ 요청마다 새 인스턴스를 생성하도록 수정.

7-3. App Router에서 useServerInsertedHTML 미적용

EmotionCacheProvider 없이 사용하면 서버에서 생성된 스타일이 HTML에 포함되지 않아 HydrationclassName은 일치해도 스타일이 없는 상태로 시작된다.
6. SSR + Emotion 설정 순서를 다시 적용해보자.

디버깅 방법

  1. 브라우저 콘솔에서 className did not match 에러 확인
  2. 서버 응답 HTML(view-source:)에서 <style data-emotion> 태그 존재 여부 확인
  3. React DevTools에서 Hydration 에러 컴포넌트 추적.

오늘도 긴 글 읽어주셔서 정말 감사드립니다...
방전된 저는 이만..

profile
바라는 색이 있다면 눈이 멀도록 바라볼 것. 가능한 온몸으로 부서질 것.

0개의 댓글