| Hook | 실행 시점 | 특징 | 사용 케이스 |
|---|---|---|---|
| useEffect | 브라우저 페인트 후 | 비동기적, non-blocking | API 호출, 이벤트 리스너 |
| useLayoutEffect | 페인트 전 | 동기적, blocking | DOM 측정, 스크롤 복원 |
useLayoutEffect는 화면이 그려지기 전에 실행되므로 깜빡임을 방지할 수 있지만, 성능에 영향을 줄 수 있다.
┌─────────────────────────────────────────┐
│ 1. React 렌더 함수 실행 │
│ (가상 DOM 계산, 변경사항 결정) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. React Commit Phase │
│ (실제 DOM에 변경사항 적용) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ ⭐ useLayoutEffect 실행 (동기적, blocking) │
│ - DOM 측정 가능 │
│ - 상태 업데이트 가능 │
│ - 이 시간동안 페인트 지연됨 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 3. 브라우저 Layout (Reflow) │
│ - 각 요소의 크기/위치 계산 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 4. 브라우저 Paint (Repaint) │
│ - 실제 화면에 그리기 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 5. 브라우저 Composite │
│ - 여러 레이어 합성 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ ⭐ useEffect 실행 (비동기, non-blocking) │
│ - 시간 제약 없음 │
│ - 메인 스레드 블로킹 없음 │
└─────────────────────────────────────────┘
핵심: useLayoutEffect가 실행되는 동안 브라우저는 대기 중이다. 따라서 이 시간이 길어지면 페인트가 지연되어 화면이 멈춘 것처럼 보인다.
// ❌ useEffect 사용 - 깜빡임 발생!
function Component() {
const [position, setPosition] = useState(0);
const elementRef = useRef();
useEffect(() => {
// 1단계: 화면이 먼저 그려짐 (position = 0)
// 유저가 잠깐 초기 상태를 봄 ← 깜빡임!
const rect = elementRef.current.getBoundingClientRect();
setPosition(rect.left); // 2단계: 이제 상태 업데이트
// 3단계: 리렌더 → 화면 재그리기
}, []);
return <div ref={elementRef} style={{ marginLeft: position }}>Content</div>;
}
결과: 사용자가 보는 화면
1. 첫 번째 프레임: marginLeft: 0 (초기값)
2. 두 번째 프레임: marginLeft: 123px (계산 후 값)
→ 요소가 움직이는 것처럼 보임 (깜빡임)
// ✅ useLayoutEffect 사용 - 깜빡임 없음!
function Component() {
const [position, setPosition] = useState(0);
const elementRef = useRef();
useLayoutEffect(() => {
// useLayoutEffect 내의 모든 작업이 끝날 때까지 페인트 지연
const rect = elementRef.current.getBoundingClientRect();
setPosition(rect.left); // 상태 업데이트
// 이 상태 업데이트로 인한 리렌더가 바로 반영됨
}, []);
return <div ref={elementRef} style={{ marginLeft: position }}>Content</div>;
}
결과: 사용자가 보는 화면
1. 페인트가 한 번만 발생
2. marginLeft: 123px로 바로 렌더됨
→ 깜빡임 없이 자연스러운 화면
// 탭 인디케이터 예제
function Tabs() {
const [activeTab, setActiveTab] = useState(0);
const [indicatorStyle, setIndicatorStyle] = useState({});
const tabRefs = useRef([]);
// ✅ useLayoutEffect - 정확한 위치 계산
useLayoutEffect(() => {
const activeElement = tabRefs.current[activeTab];
if (!activeElement) return;
setIndicatorStyle({
left: activeElement.offsetLeft,
width: activeElement.offsetWidth,
});
}, [activeTab]);
return (
<div style={{ position: 'relative' }}>
{['Tab 1', 'Tab 2', 'Tab 3'].map((label, i) => (
<button
key={i}
ref={(el) => (tabRefs.current[i] = el)}
onClick={() => setActiveTab(i)}
style={{ padding: '10px 20px' }}
>
{label}
</button>
))}
{/* 인디케이터 바 */}
<div
style={{
position: 'absolute',
bottom: 0,
height: '2px',
backgroundColor: 'blue',
transition: 'all 0.3s ease',
...indicatorStyle,
}}
/>
</div>
);
}
이유: offsetLeft와 offsetWidth는 DOM이 실제로 렌더된 후에만 정확한 값을 반환한다. useLayoutEffect에서 이 값을 읽고 상태를 업데이트하면, 페인트 전에 모든 계산이 완료되어 깜빡임 없이 정확한 위치에 인디케이터가 나타난다.
// 뒤로가기 시 이전 스크롤 위치 복원
function useScrollRestoration(routeKey) {
const scrollPositions = useRef(new Map());
// 떠나기 전 현재 스크롤 위치 저장
useEffect(() => {
return () => {
scrollPositions.current.set(routeKey, window.scrollY);
};
}, [routeKey]);
// ✅ 새 페이지 진입 시 이전 스크롤 위치 복원
useLayoutEffect(() => {
const savedPosition = scrollPositions.current.get(routeKey);
if (savedPosition !== undefined) {
window.scrollTo(0, savedPosition);
}
}, [routeKey]);
}
이유: 만약 useEffect를 사용하면:
1. 페이지가 top에서 렌더됨
2. 사용자가 처음으로 그려진 페이지를 봄 (스크롤 위치 0)
3. 그 후 useEffect 실행 → scrollTo() 호출
4. 페이지가 이전 위치로 스크롤됨
→ 사용자가 튀는 것을 봄
useLayoutEffect를 사용하면 페인트 전에 스크롤 위치가 설정되므로 깜빡임 없다.
// 다크모드 테마 적용 예제
function App() {
const [isDark, setIsDark] = useState(false);
// ❌ useEffect - 테마 튀는 현상 발생
// useEffect(() => {
// const theme = localStorage.getItem('theme');
// setIsDark(theme === 'dark');
// }, []);
// ✅ useLayoutEffect - 튀는 현상 없음
useLayoutEffect(() => {
const theme = localStorage.getItem('theme');
setIsDark(theme === 'dark');
}, []);
return (
<div style={{
backgroundColor: isDark ? '#000' : '#fff',
color: isDark ? '#fff' : '#000',
minHeight: '100vh',
transition: 'background-color 0.3s'
}}>
{/* 내용 */}
</div>
);
}
이유: useEffect 사용 시 흰 배경에서 검은 배경으로 깜빡이는 현상이 발생한다. useLayoutEffect를 사용하면 페인트 전에 테마가 적용되어 처음부터 올바른 색상으로 표시된다.
// 컨테이너 크기에 따라 동적 레이아웃
function ResponsiveGrid() {
const [columnCount, setColumnCount] = useState(3);
const containerRef = useRef();
useLayoutEffect(() => {
const handleResize = () => {
const width = containerRef.current.offsetWidth;
const newCount = width > 1200 ? 4 : width > 768 ? 2 : 1;
setColumnCount(newCount);
};
handleResize(); // 초기 계산
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div
ref={containerRef}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columnCount}, 1fr)`,
gap: '1rem'
}}
>
{/* 그리드 아이템 */}
</div>
);
}
// ❌ 나쁜 예: 무거운 계산 + useLayoutEffect
useLayoutEffect(() => {
// 이 반복문이 끝날 때까지 페인트가 지연됨!
for (let i = 0; i < 1000000; i++) {
// 복잡한 계산
Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
}, []);
// 결과:
// - 약 100-200ms 지연 (디바이스에 따라 다름)
// - 60fps 기준 6-12 프레임 건너뜀
// - 사용자는 화면이 멈춘 것처럼 느낌
// - 모바일에서는 더 심각함 (데스크톱의 3-4배 느림)
// 데스크톱: 괜찮음 (버벅거림 거의 없음)
// 모바일: 심각한 버벅거림
useLayoutEffect(() => {
// 단순해 보이는 작업도 모바일에서는 느릴 수 있음
const rects = Array.from(document.querySelectorAll('.item'))
.map(el => el.getBoundingClientRect());
setPositions(rects);
}, []);
React 팀 권고
기본값은 항상
useEffect를 사용하세요. visual flickering이 실제로 발생하는 경우에만useLayoutEffect로 변경하세요.
// ❌ SSR에서 에러 발생
useLayoutEffect(() => {
const width = window.innerWidth; // 서버에 window가 없음!
setWidth(width);
}, []);
// 결과: "ReferenceError: window is not defined"
// ✅ 클라이언트/서버 모두에서 작동
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
function Component() {
const [width, setWidth] = useState(0);
useIsomorphicLayoutEffect(() => {
setWidth(window.innerWidth);
}, []);
return <div>Width: {width}</div>;
}
동작 원리
window가 undefined이므로 useEffect 실행window가 존재하므로 useLayoutEffect 실행// ✅ Hydration 완료 후에만 실행
function Component() {
const [isHydrated, setIsHydrated] = useState(false);
useLayoutEffect(() => {
setIsHydrated(true);
}, []);
if (!isHydrated) {
return null; // 또는 서버 렌더링과 동일한 폴백
}
return <div>Only client content</div>;
}
// ✅ Next.js 14+ 권장 패턴
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
useEffect(() => {
// localStorage에서 테마 읽기
const saved = localStorage.getItem('theme') ?? 'light';
setTheme(saved);
}, []);
return (
<div
data-theme={theme}
style={{
// CSS 변수로 초기값 설정 (깜빡임 방지)
'--bg': theme === 'dark' ? '#000' : '#fff',
'--text': theme === 'dark' ? '#fff' : '#000',
} as React.CSSProperties}
>
{children}
</div>
);
}
// CSS (globals.css)
// [data-theme="light"] { background: var(--bg); color: var(--text); }
// [data-theme="dark"] { background: var(--bg); color: var(--text); }
이 방식의 장점
// React 내부 (simplified)
// ======= Commit Phase =======
flushSync(() => {
// DOM 업데이트 적용
updateDOM();
// 🔴 useLayoutEffect 큐 실행 (blocking)
// 모든 useLayoutEffect가 여기서 완료될 때까지 대기
flushLayoutEffects();
});
// ======= Browser Layout Phase =======
// 브라우저 reflow 계산
// 정확한 크기/위치 계산
// ======= Browser Paint Phase =======
// 실제 화면에 픽셀 그리기
// ======= Passive Phase =======
// 🟢 useEffect 큐 스케줄 (non-blocking)
// 메인 스레드가 여유있을 때 실행
scheduleCallback(() => {
flushPassiveEffects(); // useEffect 실행
});
| 측면 | useLayoutEffect | useEffect |
|---|---|---|
| 실행 시점 | 동기적, 즉시 | 비동기적, 유연 |
| 메인 스레드 블로킹 | O (페인트 지연) | X |
| DOM 측정 정확도 | 최고 (확정된 레이아웃) | 중간 (변경 가능) |
| 성능 영향 | 직접적 | 간접적 |
| 사용 빈도 | 낮음 (필요할 때만) | 높음 (대부분의 경우) |
// 성능 차이를 시각적으로 확인할 수 있는 코드
// ❌ useEffect + 무거운 계산
function SlowEffectComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// Heavy computation
console.time('useEffect');
for (let i = 0; i < 10000000; i++) {
Math.sqrt(i);
}
console.timeEnd('useEffect');
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment (useEffect)
</button>
</div>
);
}
// ✅ useLayoutEffect + 무거운 계산
function SlowLayoutEffectComponent() {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
console.time('useLayoutEffect');
for (let i = 0; i < 10000000; i++) {
Math.sqrt(i);
}
console.timeEnd('useLayoutEffect');
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>
Increment (useLayoutEffect)
</button>
</div>
);
}
DevTools Performance 탭 확인 방법
1. 개발자 도구 → Performance 탭
2. 녹화 시작
3. 버튼 클릭
4. 녹화 중지
5. 프레임 드롭 확인
useEffect(() => {
// ✅ 다음 작업들은 useEffect에 적합
// 1. API 호출
fetch('/api/data').then(res => res.json()).then(setData);
// 2. 이벤트 리스너 등록
window.addEventListener('scroll', handleScroll);
// 3. 타이머 설정
const timer = setTimeout(() => {}, 1000);
// 4. localStorage/sessionStorage 접근
localStorage.setItem('key', value);
// 5. 분석/로깅
analytics.track('page_view');
return () => {
// 정리 작업
};
}, [dependency]);
useLayoutEffect(() => {
// ✅ 다음 중 하나라도 해당하면 useLayoutEffect 검토
// 1. getBoundingClientRect() 사용
const rect = element.getBoundingClientRect();
// 2. offsetWidth/offsetHeight 접근
const width = element.offsetWidth;
// 3. scrollLeft/scrollTop 수정
element.scrollTop = savedPosition;
// 4. 초기 렌더에서 깜빡임 방지 필요
setFinalState(initialValue);
// ⚠️ 그러나 항상 성능을 고려할 것!
}, [dependency]);
시작
↓
DOM을 읽거나 쓸 필요가 있는가?
├─ 아니오 → useEffect 사용
│
└─ 예
↓
시각적 깜빡임이 발생하는가?
├─ 아니오 → useEffect 사용 (성능 우선)
│
└─ 예
↓
SSR 환경인가?
├─ 예 → isomorphic hook 패턴 사용
│
└─ 아니오 → useLayoutEffect 사용
상황 1: 데이터 로드
// → useEffect
useEffect(() => {
loadUserData();
}, [userId]);
상황 2: 요소 크기 측정
// → useLayoutEffect (깜빡임 방지 필요)
useLayoutEffect(() => {
const size = element.getBoundingClientRect();
setSize(size);
}, []);
상황 3: 이벤트 리스너
// → useEffect
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
상황 4: 테마/스타일 적용 (SSR)
// → useIsomorphicLayoutEffect
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
useIsoLayoutEffect(() => {
applyTheme();
}, []);
// 1순위: CSS + useEffect
// 2순위: isomorphic hook + useLayoutEffect
// 3순위: useEffect만 사용 (대부분의 경우)