정의
자식 컴포넌트에서 "부모가 넘겨준 DOM 요소"에 속성을 붙이거나 크기를 재려는데, 분명히 화면에는 그려졌는데도 그 DOM이 null로 잡힐 때가 있다. 이 개념은 그 타이밍 문제를 이해하고 안전하게 피하려 할 때 쓴다.
React commit(가상 DOM 계산이 끝난 뒤 실제 브라우저 DOM에 변경을 반영하는 단계)은 일반적으로 자식 DOM 노드부터 부모 방향(child-first, 트리의 아래쪽 노드부터 위로 붙임)으로 연결한다. 따라서 자식 컴포넌트의 useLayoutEffect(DOM 변경 직후·화면에 그리기 전에 동기로 실행되는 effect)가 실행되는 시점에는 부모가 넘긴 ref.current(부모 DOM 요소를 가리키는 참조 상자)가 아직 null 일 수 있다. 조상 DOM 요소에 속성을 쓰거나 측정하려면 useEffect(화면에 그린 뒤 실행되는 passive effect)로 미루거나, 콜백 ref·자기 DOM ref로 대상을 바꿔야 한다.
맥락에서 이해하기
자식 컴포넌트가 “문서가 비었는지”를 알고 부모가 넘긴 container ref에 data-empty 같은 속성을 붙이려 할 때, store에는 값이 있는데 ref.current만 null인 경우가 있다. React는 DOM을 아래에서 위로 붙이기 때문에, 자식 useLayoutEffect가 돌 때 부모 ref는 아직 연결 전일 수 있다. 버그처럼 보이지만 commit 순서 문제다.
왜 필요한가
자식이 useLayoutEffect에서 containerRef.current.setAttribute('data-empty', …)처럼 부모 ref에 쓰기를 하면 commit 순서 때문에 silent no-op이 된다. 데이터·store 스냅샷은 있는데 el === null인 디버그 로그가 남는다. “layout effect가 더 빠르니까” 조상 ref에 쓰는 것은 흔한 함정이다.
동작 원리
React 업데이트는 대략 다음 순서다.
- Trigger — state/props 변경
- Render — Virtual DOM 계산
- Commit — 실제 DOM 변경 (보통 child-first)
- Layout effects —
useLayoutEffect(paint 전, 동기) - Paint
- Passive effects —
useEffect(commit 완료 후)
| 단계 | 조상 ref.current | 적합한 용도 |
|---|---|---|
자식 useLayoutEffect | 종종 null | 자기 DOM 측정·동기 조정 |
자식 useEffect | 보통 연결됨 | 조상 ref에 쓰기·비긴급 부수효과 |
| 콜백 ref (부모) | 부모가 직접 제어 | 부모가 자식 신호를 받아 DOM 페인트 |
한 프레임 늦게 attribute가 붙을 수 있다. 시각적으로 critical하지 않은 플래그(빈 문서 표시 등)에는 허용되는 경우가 많다.
실무 적용
- 조상/컨테이너 ref에 쓰기는 자식
useEffect에서 한다. - 동기 측정이 꼭 필요하면 ref 대상을 자기 DOM으로 옮기거나, 부모가 콜백 ref로 직접 페인트한다.
- 컨테이너 리렌더를 피하려면
return null리프 + external store 구독으로 플래그만 분리한다. - Strict Mode double-mount 시 effect cleanup으로 attribute를 지운다.
트레이드오프
useEffect는 paint 이후라 attribute·측정이 한 틱 늦다 — 깜빡임이 보이면 콜백 ref나 부모 측 로직을 검토한다.useLayoutEffect는 메인 스레드를 블록한다 — 조상 ref 접근용으로 쓰면 안 된다.- Portal 자식은 다른 commit 서브트리 — 조상 ref 규칙이 달라질 수 있다.
사용하면 안 되는 경우
- 자기 DOM의 크기를 paint 전에 반드시 읽어야 하는 경우 — 대상 ref를 자기 노드로 옮긴 뒤
useLayoutEffect사용. - 부모 ref가 이미 callback ref로 연결된 직후라는 보장이 API 계약에 있는 특수 래퍼(드물다).
흔한 실수
- “빨리 그리려고” 조상 ref에
useLayoutEffect사용. containerRef.current가 null인데 early return 없이 로직만 스킵해 원인 파악을 미룸.- 자식 layout effect에서 조상 scrollHeight 측정 — 잘못된 0 또는 예외.
- Context로 변하는 doc을 Provider value에 넣어 컨테이너가 매 commit마다 리렌더 — ref 타이밍과 별개로 성능 문제.
관련 개념
- critical-rendering-path — DOM·스타일·레이아웃·페인트 순서
- react-context-render-granularity — 컨테이너 vs 리프 구독자 분리
- fixed-position-floating-ui-scroll — ref 기반 좌표 UI의 다른 타이밍 이슈