리액트 useEffect 사용법: 의존성 배열과 생명주기 이해
useEffect는 컴포넌트가 렌더링된 뒤 실행되어 이벤트 리스너 등록/해제, 타이머, API 호출 등 외부 동기화 작업을 담당합니다. 의존성 배열로 실행 시점을 제어하며 전통적인 componentDidMount·Update·Unmount에 대응합니다. 아래에 의존성 배열의 정확한 동작을 표와 예제로 자세히 알아봅니다.
1. useEffect 기본 형태
import { useEffect } from 'react';
useEffect(() => {
// 실행 로직 (렌더링 결과가 DOM에 반영된 뒤 실행)
return () => {
// 정리(cleanup) 로직 (언마운트 또는 다음 실행 직전에 실행)
};
}, [/* 의존성 배열 */]);2. 의존성 배열 깊게 이해하기
의존성 배열은 “이 값들이 바뀌면 이펙트를 다시 실행하라”는 의미입니다. 배열에 무엇을 넣느냐가 실행 타이밍을 결정합니다.
- 의존성 배열 없음 (
useEffect(fn)): 모든 렌더링 후 실행됩니다. (state/props 무엇이 바뀌든 매번) - [] (
useEffect(fn, [])): 마운트 시 1회만 실행되고, 언마운트 때 cleanup이 1회 실행됩니다. - [count] (
useEffect(fn, [count])): count 값이 바뀔 때만 실행됩니다. (다른 state가 바뀌어도 count가 그대로면 실행되지 않음)
왜 “[count]에서만 실행”인지 예제로 보기
import React, { useEffect, useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const [name, setName] = useState('Kim');
useEffect(() => {
console.log('효과 실행! count:', count);
}, [count]); // count가 바뀔 때만 실행
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
<p>name: {name}</p>
<button onClick={() => setName(n => n === 'Kim' ? 'Lee' : 'Kim')}>이름 토글</button>
</div>
);
}위 컴포넌트에서 name만 바꾸면 리렌더링은 일어나지만, count는 변하지 않았으므로 useEffect(..., [count])는 실행되지 않습니다.
반대로 count가 바뀌면 실행됩니다. 이것이 “count 값이 바뀔 때만 실행”의 정확한 의미입니다.
의존성 배열 요약표
| 의존성 표기 | 다시 실행되는 시점 | cleanup 실행 시점 | 대표 용도 |
|---|---|---|---|
| (없음) | 모든 렌더링 후 | 다음 실행 직전마다 | 간단한 로그, DOM 동기화 실험 |
| [] | 마운트 시 1회 | 언마운트 시 1회 | 이벤트 리스너 1회 등록/해제, 타이머 시작/정지 |
| [count] | count가 바뀔 때 | 각 재실행 직전 | 특정 값 변화에 맞춘 동기화(제목, API 재요청 등) |
| [id, query] | id 또는 query가 바뀔 때 | 각 재실행 직전 | 의존 값에 따른 데이터 페칭 |
| [someFn] | someFn의 참조가 바뀔 때 | 각 재실행 직전 | 함수 의존 시엔 useCallback으로 참조 안정화 권장 |
참고: 의존성 비교는 얕은 비교(참조 동일성)입니다. 객체/배열/함수는 매 렌더마다 새로 만들면 참조가 달라져 재실행이 빈번해질 수 있으니, 필요 시 useMemo/useCallback을 사용해 참조를 안정화하세요.
3. 예제 1 — 창 크기 추적 (마운트/언마운트 + cleanup)
import React, { useEffect, useState } from 'react';
function WindowSizeWatcher() {
const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
useEffect(() => {
const onResize = () => setSize({ w: window.innerWidth, h: window.innerHeight });
window.addEventListener('resize', onResize);
onResize(); // 최초 동기화
return () => window.removeEventListener('resize', onResize); // 언마운트 시 정리
}, []);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
<h4>WindowSizeWatcher 예제</h4>
<p>현재 창 크기</p>
<div>
<span>가로: {size.w}px</span>
</div>
<div>
<span>세로: {size.h}px</span>
</div>
<hr/>
<pre style={{background:'#f6f8fa', padding:8}}>
{JSON.stringify(size, null, 2)}
</pre>
</section>
);
}
export default WindowSizeWatcher;
▼ 실행 결과 화면

4. 예제 2 — 생명주기 대응: Mount/Update/Unmount 로그
import React, { useEffect, useState } from 'react';
function LifecycleLogDemo() {
const [count, setCount] = useState(0);
// componentDidMount / componentWillUnmount
useEffect(() => {
console.log('mounted');
return () => console.log('unmounted');
}, []);
// componentDidUpdate(count)
useEffect(() => {
console.log('updated: count =', count);
}, [count]);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
<h4>LifecycleLogDemo 예제</h4>
<p>카운트 값: {count}</p>
<div>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
<hr/>
<pre style={{background:'#f6f8fa', padding:8}}>
{JSON.stringify({ count }, null, 2)}
</pre>
</section>
);
}
export default LifecycleLogDemo;▼ 실행 결과 화면

5. 예제 3 — API 데이터 가져오기 (요청 취소 포함)
import React, { useEffect, useState } from 'react';
function UsersFetcher() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const ctrl = new AbortController();
async function fetchUsers() {
try {
setLoading(true);
setError('');
const res = await fetch('https://jsonplaceholder.typicode.com/users', {
signal: ctrl.signal,
});
if (!res.ok) throw new Error('네트워크 오류');
const data = await res.json();
setUsers(data);
} catch (err) {
if (err.name !== 'AbortError') setError(err.message ?? '알 수 없는 오류');
} finally {
setLoading(false);
}
}
fetchUsers();
return () => ctrl.abort(); // 언마운트/재요청 시 이전 요청 중단
}, []);
return (
<section style={{border:'1px solid #ddd', padding:12, borderRadius:8}}>
<h4>UsersFetcher 예제</h4>
{loading ? (
<p>로딩 중...</p>
) : error ? (
<p style={{color:'crimson'}}>오류: {error}</p>
) : (
<ul>
{users.map(u => (<li key={u.id}>{u.name} ({u.email})</li>))}
</ul>
)}
<hr/>
<pre style={{background:'#f6f8fa', padding:8}}>
{JSON.stringify(
{ loading, error: Boolean(error), usersCount: users.length, sample: users[0] ?? null },
null,
2
)}
</pre>
</section>
);
}
export default UsersFetcher;▼ 실행 결과 화면

6. 자주 하는 실수 & 베스트 프랙티스
- 의존성 누락: 오래된 값(스테일 클로저) 사용 위험. ESLint
react-hooks/exhaustive-deps규칙을 켜고 경고를 해결하세요. - 비동기 함수를 직접 전달:
useEffect(async () => ...)는 권장되지 않습니다. 내부에 async 함수를 선언해 호출하세요. - 객체/함수 의존성 튐: 매 렌더마다 새로 만들어지면 재실행이 잦아집니다.
useMemo/useCallback으로 참조를 고정하세요. - cleanup 누락: 이벤트/타이머 해제 잊으면 메모리 누수·중복 동작 발생.
참고 링크
함께 보면 좋은 게시글
- 리액트 state 관리: useState 기본 사용법과 동작 원리
- React 이벤트 처리 방법: onClick·onChange·onSubmit 예제
- 리액트 props 사용법: 부모에서 자식으로 데이터 전달하기
- React JSX 기초: 표현식·조건부 렌더링·리스트 출력
- 리액트 JSX 문법 규칙: 인라인 스타일링·className·닫는 태그·주석
이 글이 도움이 되셨다면 공유 부탁 드립니다.



