새로고침할 때 스타일은 왜 깨지는 걸까?
MUI v4 JSS와 SSR Hydration Mismatch 디버깅을 통해 확인해보자
들어가며
예전에 CSS-in-JS의 장단점을 정리한 글을 보면서, 런타임 성능 비용이나 SSR 환경에서의 복잡성이 문제가 될 수 있다는 내용을 공부한 적이 있다. 당시에는 "스타일을 JavaScript로 작성하는 방식의 트레이드오프" 정도로만 이해했다.
그러다 최근에 서버사이드 렌더링을 사용하는 한 페이지에서, 새로고침할 때만 화면의 스타일이 전체적으로 무너지는 문제를 만났다. 배경색 하나가 잘못 적용된 문제가 아니라 폰트, 배경색, 버튼의 크기와 정렬까지 함께 달라졌다.
이 문제를 해결하면서 CSS-in-JS가 SSR과 만났을 때 왜 첫 render의 컴포넌트 트리와 스타일 생성 순서가 중요해지는지 구체적으로 확인할 수 있었다.
CSS-in-JS의 문제점
CSS-in-JS는 JavaScript 또는 TypeScript 안에서 컴포넌트 스타일을 선언하는 방식이다. MUI의 makeStyles, styled-components, Emotion 등이 대표적이다.
// makeStyles
const useStyles = makeStyles((theme) => ({
submitButton: {
backgroundColor: theme.colors.primary,
color: theme.colors.white,
},
}));
// styled-components
const SubmitButton = styled.button`
background-color: #222;
color: #fff;
padding: 8px 12px;
`;
// emotion
function SubmitButton() {
return (
<button
css={css`
background-color: #222;
color: #fff;
padding: 8px 12px;
`}
>
확인
</button>
);
}스타일을 컴포넌트 가까이에 둘 수 있고, theme이나 props 같은 JavaScript 값을 바로 사용할 수 있다. 전역 CSS 클래스명을 관리하는 부담도 줄어든다.
다만 런타임 CSS-in-JS는 스타일을 정적 CSS 파일로 미리 끝내지 않는다. 컴포넌트가 render될 때 CSS 규칙을 만들고, 클래스명을 생성하고, <style> 태그에 CSS를 삽입한다. 스타일 생성 자체가 render 과정의 일부가 되는 셈이다.
이 특성은 SSR에서 특히 중요하다. SSR에서는 서버와 클라이언트가 각각 한 번씩 첫 render를 수행하기 때문이다.
서버 render
→ HTML과 서버 CSS 생성
클라이언트 첫 render
→ 클라이언트 CSS 생성
→ 기존 서버 DOM hydrationHydration은 React가 서버가 만든 HTML을 버리지 않고, 브라우저에서 이벤트와 상태를 연결하는 과정이다. 이 과정에서 서버와 클라이언트가 서로 다른 컴포넌트 트리를 만들면 hydration mismatch가 발생한다.
새로고침 시 발생한 스타일 깨짐 현상
문제는 한 SSR 페이지를 새로고침했을 때 발생했다.

정상화면은 문제없이 잘 나온다. 하지만 새로고침을 했을때는 아래와 같이 렌더링되었다.

- 입력창의 글자 크기와 테두리가 달라졌다.
- 버튼의 크기와 정렬이 달라졌다.
- 빨간색과 초록색 배경이 갑자기 적용됐다.
- 새로고침 직후 잠깐 정상 화면이 보였다가 깨지는 것처럼 보였다.
처음에는 background-color: red와 background-color: green이 잘못 들어간 CSS 문제라고 생각했다. 하지만 색상은 눈에 가장 잘 보이는 증상일 뿐이었지 근본적인 문제인 클래스명이 잘못 매핑된 문제를 해결하진 않는다. (해당 CSS를 제거해도 다른 클래스명이 잘못된 규칙을 가리키면 결국 스타일은 깨짐)
문제 원인 디버깅
가장 먼저 깨진 화면의 DOM과 DevTools의 Styles 패널을 같이 확인했다.
깨진 상태의 입력 영역에는 다음과 비슷한 클래스명이 붙어 있었다.
<div class="MuiBox-root jss7">
<div class="MuiBox-root jss10 jss8">
<input class="MuiInputBase-input" />
<button class="MuiButton-root jss12"></button>
</div>
</div>그런데 같은 시점에 클라이언트가 주입한 JSS 스타일 시트에서는 다음 규칙이 확인됐다.
.jss7 { color: #fff; background-color: red; }
.jss8 { color: #fff; margin-right: 10px; background-color: green; }해당 영역이 bg_red 스타일을 직접 사용한 것이 아니라, 서버가 해당 div의 wrapper에 jss7을 붙여 보냈고, 클라이언트 CSS는 그 jss7을 전혀 다른 공통 스타일의 bg_red로 해석하고 있었다.
정상으로 스타일링이 적용된 클라이언트 후속 render에서는 같은 입력 영역이 jss200대 클래스를 사용했다.
<div class="MuiBox-root jss290">
<div class="MuiBox-root jss291 jss292">...</div>
</div>여기서 jss290이 "CSR이면 항상 입력 영역에 붙는 번호"라는 뜻은 아니다. 클라이언트가 이미 수백 개의 JSS 규칙을 만든 뒤, 해당 컴포넌트가 후속 render에서 받은 번호다.
| 관찰 상태 | DOM 클래스 | 의미 |
|---|---|---|
| 서버가 보낸 초기 DOM | jss7 계열 | 서버 기준 입력 영역 스타일 |
| 깨진 시점의 클라이언트 스타일 시트 | .jss7 = bg_red, .jss8 = bg_green | 서버 DOM 클래스가 다른 스타일로 해석됨 |
| 정상으로 관찰된 클라이언트 후속 render | jss200대 | 클라이언트에서 새로 생성된 입력 영역 스타일 |
아래 사진을 확인해보자.


숫자 자체가 중요한 것이 아니라, 같은 번호가 서버와 클라이언트에서 어떤 스타일 규칙을 뜻하는지가 중요하다.
이 시점에 문제를 다음처럼 정의할 수 있었다.
서버 DOM의 jss7 = 입력 영역 wrapper 스타일
클라이언트 CSS의 jss7 = bg_red 스타일
같은 jss7이 서로 다른 의미를 가진다.그렇다면 jssN은 무엇인가?
JSS는 JavaScript Style Sheets의 약자이자, JavaScript 객체를 CSS 규칙으로 바꾸는 라이브러리다. 스타일 객체를 받아 브라우저가 읽을 수 있는 CSS를 만들고, 그 규칙을 적용할 클래스명도 생성한다.
const styles = {
submitButton: {
backgroundColor: "#222",
color: "#fff",
},
};JSS는 이 객체를 대략 아래와 같은 CSS로 바꿔 <style> 태그에 넣는다.
.jss8 {
background-color: #222;
color: #fff;
}MUI v4의 makeStyles는 JSS를 감싼 API다. 개발자는 makeStyles로 스타일 객체를 작성하고, MUI v4는 내부에서 JSS를 이용해 CSS 규칙과 클래스명을 만든다. (반면 styled-components와 Emotion은 CSS-in-JS에 속하지만 JSS 라이브러리를 사용하지 않는다.)
이제 jss7이 어디서 만들어지는지 확인하기 위해 node_modules의 MUI v4 코드를 열어봤다.
node_modules/@material-ui/styles/createGenerateClassName/createGenerateClassName.js찾다가 위와 같은 수상한 파일을 발견하게 되었다.
MUI v4의 createGenerateClassName이 JSS와 함께 사용될 클래스명을 생성하는 함수이다. production 기본 prefix가 jss이고, ruleCounter가 규칙마다 1씩 증가하는 코드이다.

코드를 보면 jss7과 jss200이 무엇을 뜻하는지 설명할 수 있다.
첫 번째로 생성한 규칙 → jss1
두 번째로 생성한 규칙 → jss2
여덟 번째로 생성한 규칙 → jss8
200번째로 생성한 규칙 → jss200즉 jss7은 컴포넌트의 고유 식별자가 아니다. "현재 generator에서 여덟 번째로 만들어진 CSS 규칙"이라는 순서 정보일 뿐이다.
서버와 클라이언트가 완전히 같은 순서로 규칙을 생성한다면 이 방식도 동작한다. 하지만 한쪽에서 조건부 컴포넌트가 하나 더 mount되면 그 뒤의 모든 번호가 밀릴 수 있는 문제점이 있다.

이 문제점은 JSS issue #665 (새 탭에서 열림)에서도 확인할 수 있다. 이 이슈는 JSS core의 instance counter를 다루므로 이번 MUI v4 rule counter 구현과 완전히 같지는 않지만, SSR에서 카운터 기반 클래스명이 서버와 클라이언트의 생성 순서에 민감하다는 점을 지적하는건 동일하다.
[Unknown block type: link_preview]
근본적인 원인은 hydration mismatch
이제 핵심은 "클라이언트에서 입력 영역보다 먼저 JSS 규칙을 생성한 컴포넌트가 무엇인가"였다.
원인은 처음에는 보이지 않는 모달이었다. 이 모달은 닫혀 있어도 기존 코드에서는 특정 작업 상태가 참이면 mount됐다.
{isFeatureActive && (
<DeferredModal open={isModalOpen} onClose={closeModal} />
)}DeferredModal은 mount되자마자 공통 스타일 hook을 호출한다. 해당 hook의 첫 번째와 두 번째 규칙이 실제로 bg_red, bg_green이었다.
const useCommonStyles = makeStyles(() => ({
bg_red: {
backgroundColor: "red",
color: "#ffffff",
},
bg_green: {
backgroundColor: "green",
color: "#ffffff",
marginRight: "10px",
},
// ...
}));여기서 open={false}는 모달 UI가 사용자에게 보이지 않는다는 의미이지, 바깥의 DeferredModal 컴포넌트 함수가 실행되지 않는다는 의미는 아니다. DeferredModal 자체가 React 트리에 포함되면 그 안에서 호출하는 useCommonStyles도 실행될 수 있다. 내부 MUI Modal이 닫힌 상태에서 DOM을 만들지 않더라도, 바깥 컴포넌트가 이미 소비한 JSS 규칙은 남는다.
문제의 기능 상태는 브라우저 sessionStorage에서 복원됐다. 서버는 sessionStorage에 접근할 수 없으므로 기본값으로 렌더링했지만, 클라이언트는 hydration의 첫 렌더링에서 저장된 값을 읽어 다른 결과를 만들고 있었다.
실제 구조를 단순화하면 다음과 같은 형태였다.
const [isFeatureActive] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return sessionStorage.getItem("feature-active") === "true";
});서버에서는 window와 sessionStorage를 사용할 수 없으므로 false가 된다. 반면 이전 작업의 상태가 남은 브라우저에서는 클라이언트의 첫 렌더링부터 true가 될 수 있다.
만약 sessionStorage를 useEffect 안에서 읽었다면 서버 렌더링과 클라이언트의 hydration 첫 렌더링은 모두 기본값을 사용한다. 그 경우 저장 상태는 hydration 이후의 두 번째 렌더링에 반영되므로, 이 글에서 설명하는 "첫 렌더링 불일치"와는 다른 흐름이 된다. 따라서 브라우저 저장소를 읽었다는 사실뿐 아니라 언제 읽었는지가 중요하다.
서버와 클라이언트의 첫 render

서버는 저장된 기능 상태를 모르기 때문에 모달을 렌더링하지 않았다. 따라서 입력 영역의 스타일이 비교적 이른 순서인 jss7, jss8을 받았다. 서버는 이 클래스가 붙은 HTML과, 각 클래스를 입력 영역 스타일로 정의한 서버 CSS를 함께 브라우저로 보냈다.
서버 첫 렌더링
isFeatureActive = false
→ DeferredModal 없음
→ 입력 영역 스타일 생성
→ 서버 DOM의 jss7, jss8 = 입력 영역 wrapper브라우저는 먼저 서버 HTML과 서버 CSS를 이용해 화면을 그렸다. 이 순간에는 서버 DOM의 클래스와 서버 CSS의 규칙이 서로 일치하므로 화면이 정상으로 보였다.
그다음 JavaScript가 로드되고 hydration이 시작됐다. 클라이언트는 sessionStorage에서 기능 상태를 복원해 첫 렌더링부터 모달을 React 트리에 포함했다. 모달의 공통 스타일 hook이 입력 영역보다 먼저 실행되면서 클라이언트 generator의 jss7, jss8은 각각 bg_red, bg_green을 뜻하게 됐다.
클라이언트 hydration 첫 렌더링
isFeatureActive = true
→ DeferredModal 렌더링
→ useCommonStyles 실행
→ 클라이언트 CSS의 jss7 = bg_red
→ 클라이언트 CSS의 jss8 = bg_green
→ 입력 영역 스타일은 이후의 다른 번호를 받음React hydration은 서버 DOM을 가능한 한 재사용한다. mismatch가 발생했을 때 속성 차이를 반드시 수정한다고 보장하지 않기 때문에, 이 사례에서는 서버가 보낸 입력 영역의 class="jss7", class="jss8"이 DOM에 남았다.
여기에 MUI v4 SSR의 스타일 교체 과정이 겹쳤다. 일반적인 MUI v4 SSR 구성에서는 다음 순서로 스타일이 전환된다.
1. 서버가 추출한 CSS를 <style id="jss-server-side">로 전달
2. 클라이언트가 자신의 JSS CSS를 다시 생성해 주입
3. hydration 이후 서버가 주입한 CSS 제거서버 CSS가 제거되거나 나중에 주입된 클라이언트 CSS가 우선되면, DOM에 남은 jss7과 jss8은 이제 클라이언트 스타일 시트의 의미로 해석된다.
서버에서 남은 DOM의 jss7
→ 클라이언트 CSS에서는 bg_red
서버에서 남은 DOM의 jss8
→ 클라이언트 CSS에서는 bg_green이 과정이 새로고침 직후 잠깐 정상 화면이 보였다가 빨간색과 초록색 스타일로 바뀐 이유를 설명한다. 처음에는 서버 DOM과 서버 CSS가 일치했지만, hydration 과정에서 서로 다른 순서로 생성된 클라이언트 CSS가 적용되면서 같은 클래스명의 의미가 바뀐 것이다.
가설은 브라우저 저장소 값을 바꾸는 A/B 테스트로 확인했다. 스타일이 깨지는 탭의 저장 상태를 비활성으로 바꾼 뒤 새로고침하자 스타일이 정상으로 돌아왔다.
이 실험은 저장 상태가 CSS를 직접 변경한다는 뜻이 아니다.
sessionStorage의 저장 상태
→ hydration 첫 렌더링의 모달 포함 여부 변경
→ 스타일 hook 실행 순서 변경
→ JSS ruleCounter 순서 변경
→ 같은 jssN에 서로 다른 규칙 할당즉 근본 원인은 단순한 CSS 속성 오류가 아니라, 서버와 클라이언트의 초기 컴포넌트 트리 불일치가 JSS 클래스명 생성 순서의 불일치로 이어진 것이었다.
해결
저장된 상태를 false로 바꿨을 때 화면이 정상으로 보였지만, 그 값을 실제 코드에서 항상 false로 둘 수는 없었다. 해당 상태는 기능에 필요한 값이었다. 테스트에서는 원인을 좁히기 위해 바꾼 것이지, 해결 방법으로 쓸 수 있는 값이 아니었다.
그래서 모달이 처음부터 필요한지 다시 봤다. 모달은 사용자가 버튼을 눌렀을 때만 화면에 나타난다. 닫힌 상태에서 미리 mount할 이유가 없었다.
기존에는 기능이 활성화돼 있으면 모달을 항상 render했다.
{isFeatureActive && (
<DeferredModal open={isModalOpen} onClose={closeModal} />
)}모달이 실제로 열렸을 때만 render하도록 바꿨다.
{isFeatureActive && isModalOpen && (
<DeferredModal open onClose={closeModal} />
)}isModalOpen의 초기값은 서버와 클라이언트 모두 false다. 클라이언트에서 저장된 상태가 복원돼도, 첫 render에서는 두 번째 조건이 거짓이므로 모달이 mount되지 않는다.
서버 첫 render: 모달 없음
클라이언트 첫 render: 모달 없음
→ 모달이 소비하던 JSS 순번도 없음
사용자가 모달을 여는 동작 수행
→ isModalOpen = true
→ hydration 이후 모달 mount이후 사용자가 모달을 열면 서버 DOM과 맞춰야 하는 hydration 구간은 이미 끝난 상태다. 저장된 기능 상태는 유지하면서, 첫 화면에서만 발생하던 JSS 순번 충돌을 피할 수 있었다.
마무리
새로고침 시 잘못된 스타일링이 적용되는 문제로 시작했지만, 실제 원인은 CSS 속성 하나가 아니었다. 서버와 클라이언트가 같은 jss7이라는 문자열에 서로 다른 의미를 부여한 SSR hydration mismatch였다.
CSS-in-JS의 런타임 스타일 생성은 편리하다. 하지만 SSR에서 사용한다면 스타일 생성 순서도 render 결과의 일부로 생각해야 한다. 특히 sessionStorage, localStorage, 현재 시간, 난수처럼 서버가 알 수 없는 값을 첫 render의 조건으로 사용하면 서버와 클라이언트의 트리가 달라질 수 있다는점에 유의하자.
Material-UI v4: Server Rendering (새 탭에서 열림)
MUI v4 (새 탭에서 열림)createGenerateClassName (새 탭에서 열림) 구현 (새 탭에서 열림)
(번역) 우리가 CSS-in-JS와 헤어지는 이유 (새 탭에서 열림)