React에서 "분명히 로직은 맞는데 화면이 한 번 깜빡인다"거나 "effect 안에서 읽은 값이 한 박자 늦다"는 문제를 만날 때가 있습니다. 이런 버그는 대부분 로직 자체가 틀렸다기보다 effect가 언제 실행되는지를 정확히 모를 때 생깁니다.
이 글에서는 useEffect가 호출되는 규칙을 먼저 정리하고, React의 렌더 → 커밋 → paint 타임라인 위에서 useEffect와 useLayoutEffect가 각각 어느 시점에 실행되는지 설명합니다.
useEffect 호출 시점
useEffect는 "렌더가 화면에 반영된 뒤에 실행할 부수효과(side effect)"를 등록하는 훅입니다. 호출 시점은 세 가지로 나눌 수 있습니다.
import { useEffect } from 'react';
function Example({ userId }: { userId: string }) {
useEffect(() => {
// 1) 마운트 직후 한 번 실행된다.
// 2) userId가 바뀌어 리렌더되면 cleanup 후 다시 실행된다.
console.log('effect run', userId);
return () => {
// 3) 언마운트 직전, 그리고 다음 effect 실행 직전에 cleanup이 호출된다.
console.log('cleanup', userId);
};
}, [userId]);
return null;
}정리하면 이렇습니다.
- 마운트 시: 컴포넌트가 처음 화면에 그려진 직후 effect 본문이 실행됩니다.
- 의존성 변경 시: 의존성 배열의 값이 이전 렌더와 달라지면, 이전 effect의 cleanup을 먼저 실행하고 새 effect 본문을 실행합니다.
- 언마운트 시: 컴포넌트가 화면에서 사라질 때 cleanup만 실행됩니다.
effect는 렌더 결과가 화면에 반영된 뒤에 실행됩니다. 렌더 도중에는 실행되지 않습니다. 이 "뒤"가 정확히 언제인지는 아래에서 타임라인으로 다시 설명합니다.
의존성 배열의 세 가지 형태
useEffect의 두 번째 인자인 의존성 배열은 effect를 다시 실행할지 결정합니다. 형태에 따라 동작이 완전히 달라집니다.
// 1) 빈 배열 — 마운트 시 1회만 실행, 언마운트 시 cleanup 1회
useEffect(() => {
console.log('마운트 시 한 번만');
}, []);
// 2) 배열 생략 — 매 렌더마다 실행
useEffect(() => {
console.log('렌더될 때마다 매번');
});
// 3) 명시 — 배열 안의 값이 이전 렌더와 달라질 때만 실행
useEffect(() => {
console.log('count가 바뀔 때만');
}, [count]);React는 매 렌더마다 의존성 배열의 각 원소를 이전 렌더의 값과 Object.is로 비교합니다. 하나라도 다르면 effect를 다시 실행합니다.
- 빈 배열
[]: 비교할 원소가 없으니 "변한 게 없다"로 판정되어 다시 실행되지 않습니다. 그래서 마운트 시 1회만 실행됩니다. - 생략: 비교 자체를 건너뛰고 매 렌더마다 무조건 실행합니다.
- 명시
[count]:count가 이전과 달라진 렌더에서만 실행됩니다.
이 비교는 값을 기준으로 합니다. 객체나 함수를 의존성에 넣으면 매 렌더마다 새 참조가 만들어져 "항상 다름"으로 판정되므로, 매 렌더마다 실행하는 것과 같아집니다. 이 경우 useMemo나 useCallback으로 참조를 안정시키거나, 의존성 설계를 다시 보는 게 맞습니다.
cleanup으로 리스너와 타이머를 해제해야 하는 이유
cleanup 함수는 누수와 중복 등록을 막는 안전장치입니다. 이벤트 리스너, 타이머, 구독(subscription)처럼 "등록하면 계속 살아 있는" 것을 effect 안에서 만들었다면, 같은 effect의 cleanup에서 반드시 해제해야 합니다.
import { useEffect, useState } from 'react';
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
// resize 리스너를 등록한다.
window.addEventListener('resize', handleResize);
return () => {
// cleanup에서 같은 리스너를 해제한다.
window.removeEventListener('resize', handleResize);
};
}, []);
return <p>창 너비: {width}px</p>;
}cleanup을 빼먹으면 의존성이 바뀌어 effect가 다시 실행될 때마다 새 리스너가 추가로 등록됩니다. 이전 리스너는 해제되지 않은 채 남아 있으므로, 리렌더가 반복될수록 같은 이벤트에 리스너가 여러 개 쌓입니다. 결과적으로 resize 한 번에 핸들러가 여러 번 호출되고(중복 등록), 사라진 컴포넌트를 참조하는 리스너가 메모리에 남습니다(누수).
타이머도 똑같습니다.
useEffect(() => {
const timerId = setInterval(() => {
console.log('1초마다 실행');
}, 1000);
return () => {
// cleanup이 없으면 컴포넌트가 사라져도 타이머가 계속 돈다.
clearInterval(timerId);
};
}, []);규칙은 간단합니다. effect 안에서 등록한 것은 같은 effect의 cleanup에서 해제합니다. 등록과 해제를 한 쌍으로 묶어두면 마운트와 언마운트, 의존성 변경 어느 경우에도 리스너가 정확히 하나만 살아 있습니다.
렌더 → 커밋 → paint 타임라인
지금까지는 effect가 화면 반영 뒤에 실행된다고 설명했습니다. useEffect와 useLayoutEffect의 차이를 이해하려면 이 "화면 반영"을 더 잘게 나눠야 합니다. React가 상태 변경 한 번을 처리하는 과정은 크게 세 단계입니다.
- 렌더 페이즈(Render): 컴포넌트 함수를 호출해 새 가상 DOM을 계산합니다. 이 단계는 화면에 아무 영향을 주지 않습니다.
- 커밋 페이즈(Commit): 계산된 결과를 실제 DOM에 반영합니다. 이 시점에 DOM은 이미 바뀌었습니다.
- paint: 브라우저가 바뀐 DOM을 실제 화면 픽셀로 그립니다. 사용자에게 변화가 보이는 때입니다.
두 훅은 이 타임라인에서 끼어드는 위치가 다릅니다.
상태 변경
│
▼
[ 렌더 페이즈 ] 컴포넌트 함수 실행, 가상 DOM 계산
│
▼
[ 커밋 페이즈 ] 실제 DOM 변경 완료
│
├──▶ useLayoutEffect 실행 (동기) ── paint 전에 끼어든다
│
▼
[ paint ] 브라우저가 화면을 그린다
│
▼
└──▶ useEffect 실행 (비동기) ── paint 후에 실행된다useLayoutEffect: 커밋 직후, paint 전에 동기로 실행됩니다. 이 effect가 끝날 때까지 브라우저는 화면을 그리지 않고 기다립니다.useEffect: paint가 끝난 뒤에 비동기로 실행됩니다. 화면은 이미 사용자에게 보인 상태입니다.
useLayoutEffect에서 DOM을 수정하면 사용자는 수정 전 상태를 보지 못합니다. paint 전에 수정이 끝나기 때문입니다. useEffect에서 DOM을 수정하면 사용자가 원래 화면과 수정된 화면을 차례로 볼 수 있습니다. paint가 끝난 뒤에 실행되기 때문입니다.
시점이 만드는 버그 1: 화면 깜빡임(layout flash)
이 타임라인 차이가 실제 버그로 드러나는 대표 사례가 화면 깜빡임입니다. DOM을 측정한 뒤 그 값으로 위치나 크기를 보정하는 작업을 useEffect에서 하면, 보정 전 모습이 한 프레임 노출됩니다.
import { useEffect, useRef, useState } from 'react';
function Tooltip({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const [left, setLeft] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
// 렌더된 너비를 측정해 가운데로 보정한다.
setLeft(-el.offsetWidth / 2);
}, [text]);
return (
<div ref={ref} style={{ position: 'absolute', left }}>
{text}
</div>
);
}이 코드의 문제는 실행 순서에 있습니다.
- 첫 렌더에서
left는0입니다. 커밋 후 paint가 일어나 툴팁이 왼쪽으로 치우친 채 화면에 그려집니다. - paint가 끝난 뒤
useEffect가 실행되어setLeft로 보정값을 넣습니다. - 다시 렌더 → 커밋 → paint가 일어나 이번엔 가운데 정렬된 툴팁이 그려집니다.
사용자는 1번과 3번 사이에서 툴팁의 위치가 갑자기 바뀌는 깜빡임을 보게 됩니다. 보정 계산이 paint보다 뒤에 일어났기 때문입니다.
useLayoutEffect로 바꾸면 이 문제가 사라집니다.
import { useLayoutEffect, useRef, useState } from 'react';
function Tooltip({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const [left, setLeft] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// 커밋 직후, paint 전에 측정과 보정을 끝낸다.
setLeft(-el.offsetWidth / 2);
}, [text]);
return (
<div ref={ref} style={{ position: 'absolute', left }}>
{text}
</div>
);
}useLayoutEffect는 paint 전에 동기로 실행되고, 그 안에서 호출한 setLeft로 인한 리렌더와 커밋까지 모두 paint 전에 끝납니다. 브라우저는 이 과정이 끝난 뒤 가운데로 보정된 결과만 한 번에 그립니다. 사용자는 치우친 중간 상태를 보지 못합니다.
시점이 만드는 버그 2: 의존성 누락으로 stale 값 읽기
두 번째 버그는 의존성 배열을 잘못 채워서 effect가 과거 렌더의 값을 계속 읽는 경우입니다. 이것도 effect가 언제, 어떤 렌더의 클로저로 실행되는지에 관한 시점 문제입니다.
import { useEffect, useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timerId = setInterval(() => {
// count는 effect가 등록된 렌더 시점의 값으로 고정된다.
console.log('현재 count:', count);
}, 1000);
return () => clearInterval(timerId);
}, []); // ← count를 의존성에서 빠뜨렸다.
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}버튼을 눌러 count를 아무리 올려도 콘솔에는 계속 현재 count: 0만 찍힙니다. 이유는 시점에 있습니다.
- effect는 의존성 배열이
[]라서 마운트 시 1회만 실행됩니다. - 그때 만들어진
setInterval콜백은 첫 렌더의 클로저를 기억합니다. 그 클로저 안의count는0입니다. - 이후
count가 바뀌어 컴포넌트는 새 값으로 리렌더되지만, effect는 다시 실행되지 않으므로 타이머 콜백은 여전히 첫 렌더의count = 0을 참조합니다.
컴포넌트는 최신 값으로 다시 그려지지만, effect가 참조하는 클로저는 과거 렌더에서 만들어진 그대로입니다. 이 어긋남이 stale 값 버그입니다.
해결책은 두 가지입니다. count를 실제로 읽어야 한다면 의존성에 추가해 값이 바뀔 때마다 effect를 새 클로저로 다시 등록합니다.
useEffect(() => {
const timerId = setInterval(() => {
console.log('현재 count:', count);
}, 1000);
return () => clearInterval(timerId);
}, [count]); // count가 바뀔 때마다 cleanup 후 새 effect로 교체된다.만약 이전 값을 기준으로 누적만 하면 된다면, 클로저의 옛 값을 읽는 대신 업데이터 함수를 써서 항상 최신 상태를 받습니다.
useEffect(() => {
const timerId = setInterval(() => {
// 인자로 받은 prev는 항상 최신 상태다. 클로저의 옛 값을 읽지 않는다.
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(timerId);
}, []);의존성 배열은 effect가 어느 렌더의 값을 보고 실행될지 결정하는 장치입니다. 비워두면 첫 렌더의 값에 고정되고, 정확히 채우면 값이 바뀔 때마다 최신 클로저로 갱신됩니다.
useLayoutEffect 사용 기준
여기까지 보면 깜빡임이 사라지니 항상 useLayoutEffect를 쓰면 될 것 같지만, 그렇지 않습니다. 두 훅은 실행 시점이 다른 만큼 장단점도 다릅니다.
useLayoutEffect를 쓰는 경우는 DOM을 측정한 결과로 화면을 동기적으로 보정해야 할 때입니다.
- 요소의 실제 크기나 위치를
offsetWidth,getBoundingClientRect등으로 잰 뒤 그 값으로 레이아웃을 조정해야 할 때 (위의 툴팁 예제) - paint 전에 스크롤을 특정 위치로 맞춰 점프를 숨겨야 할 때
- paint 전에 반드시 끝나야 하는, 사용자에게 중간 상태를 보이면 안 되는 DOM 조작
useLayoutEffect를 남용하면 안 되는 이유도 시점에 있습니다. useLayoutEffect는 paint를 막고 동기로 실행되므로, 그 안의 작업이 무겁거나 추가 리렌더를 유발하면 브라우저가 화면을 그리는 시점이 늦어집니다. 결과적으로 첫 화면이 늦게 뜨고 상호작용 반응이 느려집니다.
기준은 이렇게 잡으면 됩니다.
- paint된 결과를 사용자가 봐도 괜찮다면
useEffect를 씁니다. 데이터 패칭, 구독, 로깅, 이벤트 리스너 등록 등 대부분의 부수효과가 여기에 해당합니다. - paint 전에 반드시 끝나야 깜빡임이 없다면
useLayoutEffect를 씁니다. DOM 측정 후 동기 보정처럼 시각적 일관성이 깨지는 경우에만 씁니다.
서버 컴포넌트 환경에서는 한 가지를 더 기억해야 합니다. useLayoutEffect는 DOM이 있어야 동작하므로 서버 렌더링 시 실행되지 않고, 클라이언트와 서버 출력이 어긋난다는 경고를 띄울 수 있습니다. Next.js처럼 서버 컴포넌트를 쓰는 환경이라면 useLayoutEffect는 'use client' 컴포넌트에서, 정말 필요한 곳에서만 쓰는 것이 안전합니다.
정리
useEffect는 마운트 시, 의존성 변경 시 실행되고 언마운트 시 cleanup이 호출됩니다.- 의존성 배열은
[](마운트 1회), 생략(매 렌더), 명시(값이 바뀔 때)로 동작이 달라지며,Object.is값 비교가 기준입니다. - effect에서 등록한 리스너·타이머·구독은 같은 effect의 cleanup에서 해제해야 누수와 중복 등록을 막습니다.
useEffect는 paint 후 비동기로,useLayoutEffect는 커밋 직후 paint 전에 동기로 실행됩니다.- 깜빡임 버그와 stale 값 버그는 모두 effect가 언제, 어느 렌더의 값으로 실행되는지에 관한 시점 문제입니다.
useLayoutEffect는 DOM 측정 후 동기 보정처럼 paint 전에 끝나야 하는 경우에만 쓰고, paint를 막는다는 비용 때문에 기본 선택은useEffect로 둡니다.
마치며
이번 글에서는 useEffect와 useLayoutEffect를 React의 렌더 → 커밋 → paint 타임라인을 기준으로 살펴봤습니다. 두 훅 중 무엇을 쓸지는 그 부수효과가 paint 전에 끝나야 하는지로 판단하면 됩니다. 긴 글 읽어주셔서 감사합니다.