useLayoutEffect가 SSR에서 경고를 내는 이유
React commit phase · browser paint timing · Rules of Hooks · layout thrashing
난이도: ★★★★☆ 연관 노트: sessionStorage 대신 URL에 상태를 담는 이유
핵심 요약
paint 전후 구분, SSR 경고 개념은 이해. 나머지는 설명 못함 → 재학습 필요 ★
- ✅ useLayoutEffect는 paint 이전, useEffect는 paint 이후
- ✅ SSR에서 useLayoutEffect 경고가 뜬다는 것
- ❌ React commit phase 정확한 순서
- ❌ 모듈 레벨 선언 이유 (Rules of Hooks와 연결이 안 됨)
- ❌ browser reflow / layout thrashing 메커니즘
나의 학습 상태 (2026-06-23)
지금 이해하고 있는 것
- useLayoutEffect는 paint 전, useEffect는 paint 후라는 것
- SSR에서 경고가 뜬다는 것 (“서버에서는 useEffect가 동작하지 않아서”라고 답했는데 — 방향은 맞지만 이유가 정확하지 않았음)
아직 모르는 것
- React가 DOM을 변경한 후 paint 전까지 정확히 무슨 일이 일어나는지
- 모듈 레벨에서 선언하는 이유 (Rules of Hooks와 어떻게 연결되는지)
- layout thrashing이 뭔지, 왜 성능에 문제가 되는지
무엇을 배워야 하는가
- 브라우저 Critical Rendering Path — DOM → Layout → Paint → Composite 흐름
- React commit phase 3단계 (beforeMutation → mutation → layout)
- JavaScript 메인 스레드와 브라우저 렌더링이 같은 스레드를 공유한다는 것
기대 효과
- useEffect와 useLayoutEffect 중 어느 것을 쓸지 판단 기준이 생긴다
- “paint 전에 DOM 크기를 읽어야 하면?” 같은 요구사항이 왔을 때 코드를 짤 수 있다
- SSR 경고를 보고 원인을 즉시 파악할 수 있다
- 성능 문제가 layout thrashing에서 오는지 진단할 수 있다
왜 헷갈렸나
“브라우저 렌더링 후에 React가 동작한다”고 생각했음. 실제로는 re-render 시 React commit이 먼저, 브라우저 paint가 나중이다.
초기 로드(SSR)는 예외 — 브라우저가 서버 HTML을 먼저 paint하고, 그 뒤 React가 hydration한다. 이 두 상황을 혼동하면 전체 흐름이 다 꼬인다.
메커니즘
1. 브라우저 렌더링 파이프라인 (Critical Rendering Path)
브라우저가 HTML을 받아 화면에 그리는 과정 전체.
HTML 파싱 → DOM 생성
CSS 파싱 → CSSOM 생성
↓
Render Tree 생성 (DOM + CSSOM 합침)
↓
Layout (= Reflow)
— 각 요소의 위치, 크기 계산
— width, height, top, left 등
↓
Paint
— 픽셀 계산 (색상, 테두리, 그림자 등)
↓
Composite
— 레이어를 합성해 최종 화면 출력
Layout (Reflow): 요소의 geometry(위치/크기)를 계산하는 단계.
offsetHeight, getBoundingClientRect() 같은 값들이 여기서 결정된다.
Paint: 실제 픽셀을 그리는 단계. 이 단계가 끝나야 사용자가 변경된 UI를 본다.
모든 것이 메인 스레드 하나에서 순서대로 실행된다. JavaScript 코드가 실행 중이면 Layout과 Paint는 기다린다.
2. React 렌더링/커밋 과정
React는 두 단계로 동작한다.
[Render Phase — 순수, 부수효과 없음]
컴포넌트 함수 실행
→ 이전 가상 DOM과 새 가상 DOM 비교 (Reconciliation)
→ "무엇이 바뀌어야 하는가" 계산만 함
→ 실제 DOM은 건드리지 않음
[Commit Phase — 실제 DOM 변경]
세 단계:
1. beforeMutation
— DOM 변경 전 스냅샷 수집
2. mutation ← 실제 DOM이 여기서 바뀜
— appendChild, removeChild, style 변경 등 적용
3. layout ← useLayoutEffect 실행
— DOM 변경이 완전히 끝난 직후
— 브라우저 paint 이전
— 동기적으로 실행 (paint를 막는다)
Commit Phase가 끝난 뒤:
- 브라우저 Layout + Paint 실행 (사용자 화면 갱신)
- Paint 완료 후
useEffect실행
3. useLayoutEffect vs useEffect — 타이밍 다이어그램
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[re-render 시 전체 흐름]
State 변경 (setState, store 업데이트 등)
↓
Render Phase
— 컴포넌트 함수 실행
— 가상 DOM 비교
↓
Commit Phase
— beforeMutation
— mutation (DOM 실제 변경)
— layout
★ useLayoutEffect 실행 (동기)
DOM은 이미 바뀌어 있음
브라우저는 아직 그리지 않음
여기서 DOM 크기를 읽으면 최신값을 얻을 수 있음
↓
브라우저 Layout + Paint
— 사용자 화면 갱신
↓
★ useEffect 실행 (비동기, paint 후)
— 사용자는 이미 변경된 화면을 보고 있음
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
실용적 판단 기준:
| useLayoutEffect | useEffect | |
|---|---|---|
| 실행 시점 | DOM 변경 후, paint 전 | paint 후 |
| 사용자 화면 상태 | 아직 갱신 전 | 이미 갱신됨 |
| paint 차단 | ✅ 차단 | ❌ 차단 안 함 |
| DOM 크기 읽기 | 최신값 (mutation 완료) | 최신값 |
| 쓰는 이유 | DOM 측정 후 즉시 반영 — 깜빡임 방지 | API 호출, 이벤트 구독 등 |
useLayoutEffect를 쓰면 사용자가 “중간 상태”를 못 본다.
useEffect를 쓰면 사용자가 “원래 상태 → 변경된 상태” 순서로 깜빡임을 볼 수 있다.
대부분의 경우 useEffect로 충분하다. DOM 크기를 기준으로 UI를 즉시 조정해야 하는 경우(Tooltip 위치, Popover 방향 등)에만 useLayoutEffect를 쓴다.
4. 초기 로드 vs re-render — 순서가 반대다
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[초기 로드 (SSR)]
서버에서 HTML 생성
↓
브라우저: HTML 수신 → paint ← 사용자가 화면을 봄
↓
JS 번들 로드 + 실행
↓
React hydration (Commit Phase)
→ DOM 이벤트 핸들러 연결
→ useLayoutEffect 실행
↓
브라우저 paint (hydration으로 DOM 변경이 있었다면)
↓
useEffect 실행
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[이후 re-render]
React commit → DOM 변경 → useLayoutEffect → paint → useEffect
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
초기 로드는 브라우저가 서버 HTML을 먼저 paint한 뒤 React가 개입하는 구조. 이후 re-render는 React commit이 먼저이고 브라우저 paint가 나중.
5. SSR에서 useLayoutEffect 경고
Warning: useLayoutEffect does nothing on the server because its effect cannot
be encoded into the server renderer's output format.
왜 나오나?
SSR은 Node.js에서 컴포넌트를 실행한다. 서버에는 DOM이 없다. DOM이 없으면 commit phase의 mutation도, layout도 없다.
React 서버 렌더러는:
useEffect→ 아예 실행하지 않음 (클라이언트 전용으로 처리, 경고 없음)useLayoutEffect→ 실행할 수 없음 + “이게 왜 여기 있냐?” 경고 출력
경고가 나왔다는 것 = 이 컴포넌트가 SSR 경로를 탄다는 것. “useEffect가 서버에서 안 돌아서”가 아니라, “useLayoutEffect는 서버에서 실행할 수 없다는 것을 React가 명시적으로 알린다”가 정확한 표현.
6. useIsomorphicLayoutEffect — 모듈 레벨에 선언하는 이유
목표: 클라이언트에서는 useLayoutEffect, 서버에서는 useEffect (경고 없이)
왜 컴포넌트 안에서 조건부 선택하면 안 되나:
React Rules of Hooks — hook은 렌더마다 동일한 순서로, 동일하게 호출되어야 한다.
// ❌ Bad — 컴포넌트 내부에서 조건부 선택
function useMyHook() {
const hook = typeof window !== "undefined"
? useLayoutEffect // 브라우저: 이쪽
: useEffect; // 서버: 이쪽
hook(() => { ... });
// ↑ React 입장에서 렌더마다 다른 함수가 호출됨
// Rules of Hooks 위반
}
문제 시나리오:
SSR: useEffect 호출됨
hydration: useLayoutEffect 호출됨
→ 같은 컴포넌트가 환경에 따라 다른 hook을 호출
→ React가 hook 순서로 내부 상태(클로저, 타이머 ID 등)를 추적하는 구조 붕괴
모듈 레벨로 올리면:
// ✅ Good — 모듈이 로드되는 순간 딱 1번 평가되고 고정됨
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
function useMyHook() {
// 항상 동일한 변수 참조 → React 입장에서 "항상 같은 hook"
useIsomorphicLayoutEffect(() => { ... });
}
서버 환경에서 모듈 로드:
typeof window !== "undefined" → false
→ useIsomorphicLayoutEffect = useEffect (고정)
→ 이후 모든 렌더에서 useEffect 호출
브라우저 환경에서 모듈 로드:
typeof window !== "undefined" → true
→ useIsomorphicLayoutEffect = useLayoutEffect (고정)
→ 이후 모든 렌더에서 useLayoutEffect 호출
핵심: 조건 평가를 “컴포넌트 실행 시점(렌더마다)“에서 “모듈 로드 시점(1번)“으로 끌어올림. 컴포넌트는 조건 없이 항상 같은 변수를 호출 → Rules of Hooks 만족.
7. browser reflow / layout thrashing
layout thrashing = JS에서 DOM을 읽고 쓰는 과정에서 브라우저 레이아웃 계산을 강제로 반복 유발하는 것
브라우저의 정상 동작:
JS 코드가 DOM을 여러 번 변경
→ 브라우저: "변경들을 모아뒀다가 한 번에 Layout + Paint 처리할게"
→ batching으로 효율적으로 처리
강제 reflow가 발생하는 순간:
JS 코드에서 layout 관련 값을 읽음
(offsetHeight, scrollHeight, getBoundingClientRect, clientWidth 등)
→ 브라우저: "지금 당장 Layout을 계산해야 정확한 값을 줄 수 있어"
→ 큐에 쌓인 DOM 변경을 즉시 처리
→ 동기 Layout 계산 (비싼 작업)
→ 값 반환 후 JS 계속 실행
useLayoutEffect 안에서 발생하는 경우:
React commit
→ DOM 변경 (mutation phase)
→ useLayoutEffect 시작
element.getBoundingClientRect() 호출 ← 강제 reflow
브라우저: 즉시 Layout 계산
→ 동기 Layout (메인 스레드 블로킹)
→ 값 반환
setState(계산된 크기) ← 새 render 유발
→ React: 상태 변경 감지 → 다시 commit 시작
→ 또 useLayoutEffect 실행
→ 또 getBoundingClientRect 호출
→ 또 강제 reflow...
→ 브라우저 paint는 한참 뒤
→ 사용자는 화면이 멈춘 것처럼 느낌
왜 그럼에도 useLayoutEffect를 쓰나:
레이아웃을 올바르게 표시하려면 DOM 크기를 알아야 하는 경우가 있음.
- Tooltip: 어느 방향으로 열릴지 결정하려면 버튼과 뷰포트의 크기가 필요
- Popover 위치 계산
- 스크롤 위치 복원
이 경우 reflow 비용 < 깜빡임 UX 비용. 트레이드오프를 알고 선택하는 것.
해결 패턴
// utils/hooks.ts 또는 goi_common/src/hooks/useIsomorphicLayoutEffect.ts
import { useEffect, useLayoutEffect } from "react";
// 모듈 레벨 — 환경에 따라 1번 결정
export const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// 사용
import { useIsomorphicLayoutEffect } from "@/utils/hooks";
function Tooltip({ anchorRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
useIsomorphicLayoutEffect(() => {
if (!anchorRef.current) return;
const rect = anchorRef.current.getBoundingClientRect();
setPosition({ top: rect.bottom, left: rect.left });
// paint 전에 위치가 계산됨 → 사용자가 위치가 이동하는 걸 못 봄
}, []);
return <div style={position}>툴팁 내용</div>;
}
다음에 이 상황을 만나면
-
Warning: useLayoutEffect does nothing on the server→useIsomorphicLayoutEffect로 교체 - “paint 전에 DOM 크기를 기준으로 즉시 UI 조정이 필요하다” →
useLayoutEffect(깜빡임 방지) - 그 외 대부분의 side effect (API 호출, 이벤트 구독, 타이머) →
useEffect -
useLayoutEffect안에서getBoundingClientRect등을 반복 호출하면 → layout thrashing 의심, DevTools Performance 탭으로 확인
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 브라우저 Critical Rendering Path — DOM → Layout → Paint → Composite 흐름. “paint 전”이 어디인지 이해하려면 이 파이프라인을 먼저 알아야 함
- JavaScript 싱글 스레드 모델 — JS 실행과 브라우저 렌더링이 같은 메인 스레드를 공유. 강제 reflow가 paint를 막는 이유의 근거
- React Rules of Hooks — hook이 렌더마다 동일한 순서로 호출되어야 하는 이유. 모듈 레벨 선언의 직접적 근거
- 조건부로 훅은 못 부르는데, 왜 조건부 렌더는 되나 — Rules of Hooks가 “왜” fiber 슬롯 순서 매칭 때문에 필요한지 파고든 노트
- sessionStorage 대신 URL에 상태를 담는 이유 — hydration render → router 초기화 순서 개념이 이 노트의 SSR→hydration 흐름과 연결됨
→ 확장 개념 (여기서 더 나아가면)
- 브라우저 compositor thread — paint 이후 composite 단계는 별도 스레드. CSS
transform/opacity애니메이션이 layout thrashing을 일으키지 않는 이유 requestAnimationFrame— “다음 frame 그리기 직전”에 실행하는 Web API. useLayoutEffect와 타이밍을 비교하면 차이가 명확해짐- React Concurrent Mode — render phase를 중단 가능하게 만든 이유. commit phase는 왜 중단 불가인지 이해하는 배경
will-changeCSS 속성 — 브라우저가 레이어를 미리 분리해 composite 최적화. layout thrashing 완화 방법 중 하나
↔ 같은 원리가 적용되는 곳
- 서버와 브라우저의 ‘오늘’은 다르다 — hydration mismatch 검사는 “첫 렌더”까지, useEffect는 커밋+paint 후. 이 노트의 commit/paint 타임라인이 그대로 적용됨
- 입력창이 사라질 때 값이 멋대로 저장되는 이유 — React commit과 브라우저 이벤트 타이밍의 충돌. “commit 단계 순서”가 겹치는 주제
- CSS
transformvstop/left—top/left는 Layout을 유발,transform은 Composite만 사용. layout thrashing 맥락의 같은 원리 - Intersection Observer — DOM 크기/위치 읽기를 JS에서 직접 하지 않고 브라우저에 위임. layout thrashing 없이 scroll 위치를 감지하는 방법
- 모듈 상단 상수에 쿠키를 읽으면 왜 안 되는가 — “모듈 로드 시점에 1번 평가되어 고정된다”는 동일한 원리. useIsomorphicLayoutEffect 모듈 레벨 선언과 같은 메커니즘
참고
- React 공식: useLayoutEffect — 서버에서 동작하지 않음을 명시
- MDN: Reflow — 강제 reflow 개념
- What forces layout/reflow (Paul Irish) — layout thrashing을 유발하는 DOM 속성 전체 목록