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