useLayoutEffect가 SSR에서 경고를 내는 이유

React commit phase · browser paint timing · Rules of Hooks · layout thrashing

reactbrowserrenderingssrhooksperformance

난이도: ★★★★☆ 연관 노트: 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이 뭔지, 왜 성능에 문제가 되는지

무엇을 배워야 하는가

  1. 브라우저 Critical Rendering Path — DOM → Layout → Paint → Composite 흐름
  2. React commit phase 3단계 (beforeMutation → mutation → layout)
  3. 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 후)
  — 사용자는 이미 변경된 화면을 보고 있음

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

실용적 판단 기준:

useLayoutEffectuseEffect
실행 시점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 serveruseIsomorphicLayoutEffect로 교체
  • “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-change CSS 속성 — 브라우저가 레이어를 미리 분리해 composite 최적화. layout thrashing 완화 방법 중 하나

↔ 같은 원리가 적용되는 곳

  • 서버와 브라우저의 ‘오늘’은 다르다 — hydration mismatch 검사는 “첫 렌더”까지, useEffect는 커밋+paint 후. 이 노트의 commit/paint 타임라인이 그대로 적용됨
  • 입력창이 사라질 때 값이 멋대로 저장되는 이유 — React commit과 브라우저 이벤트 타이밍의 충돌. “commit 단계 순서”가 겹치는 주제
  • CSS transform vs top/lefttop/left는 Layout을 유발, transform은 Composite만 사용. layout thrashing 맥락의 같은 원리
  • Intersection Observer — DOM 크기/위치 읽기를 JS에서 직접 하지 않고 브라우저에 위임. layout thrashing 없이 scroll 위치를 감지하는 방법
  • 모듈 상단 상수에 쿠키를 읽으면 왜 안 되는가 — “모듈 로드 시점에 1번 평가되어 고정된다”는 동일한 원리. useIsomorphicLayoutEffect 모듈 레벨 선언과 같은 메커니즘

참고