useEffect 완전 분석 — 의존성 배열의 함정과 올바른 사용법
useEffect는 React에서 가장 많이 쓰이지만 가장 많이 잘못 쓰이는 훅입니다. 의존성 배열에 뭘 넣어야 할지, cleanup은 언제 실행되는지 정확히 알고 계신가요?
개념 정의
useEffect는 컴포넌트를 외부 시스템과 동기화 하기 위한 훅입니다. DOM 조작, 데이터 페칭, 구독 설정 등 렌더링 자체가 아닌 부수효과(Side Effect) 를 처리합니다.
왜 필요한가
React 컴포넌트는 순수 함수처럼 동작해야 합니다. 하지만 실제 애플리케이션에서는 API 호출, 이벤트 리스너 등록, 타이머 설정 같은 부수효과가 필수적입니다. useEffect는 이런 부수효과를 렌더링과 분리 하여 관리합니다.
내부 동작
setup과 cleanup
useEffect(() => {
// setup: 렌더링 후 실행되는 코드
const connection = createConnection(roomId);
connection.connect();
// cleanup: 다음 setup 전 또는 언마운트 시 실행
return () => {
connection.disconnect();
};
}, [roomId]); // 의존성 배열
실행 타이밍을 정리하면 다음과 같습니다.
- **첫 렌더링 후 **: setup 실행
- ** 의존성 변경 시 **: 이전 cleanup 실행 → 새 setup 실행
- ** 언마운트 시 **: 마지막 cleanup 실행
의존성 배열의 세 가지 형태
// 1. 의존성 배열 없음 — 매 렌더링마다 실행
useEffect(() => {
console.log('모든 렌더링 후');
});
// 2. 빈 배열 — 마운트 시 한 번만 실행
useEffect(() => {
console.log('마운트 시 한 번');
return () => console.log('언마운트 시 한 번');
}, []);
// 3. 의존성 지정 — 해당 값이 변경될 때만 실행
useEffect(() => {
console.log(`roomId가 ${roomId}로 변경됨`);
return () => console.log(`roomId ${roomId} cleanup`);
}, [roomId]);
얕은 비교 (Shallow Comparison)
의존성 배열의 각 항목은 Object.is로 이전 값과 비교됩니다.
function Component({ user }) {
// ⚠️ user가 매번 새 객체면 매 렌더링마다 실행
useEffect(() => {
fetchData(user.id);
}, [user]); // Object.is(prevUser, nextUser) === false
// ✅ 필요한 원시값만 의존성으로 지정
useEffect(() => {
fetchData(user.id);
}, [user.id]); // id가 같으면 스킵
}
stale closure 문제와 해결
문제 상황
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// ❌ query가 빠르게 변경되면 이전 요청의 결과가 나중에 도착할 수 있음
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
}
AbortController로 해결
function SearchComponent() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
// cleanup: 이전 요청 취소
return () => controller.abort();
}, [query]);
}
boolean 플래그로 해결
useEffect(() => {
let ignore = false;
async function fetchData() {
const response = await fetch(`/api/data/${id}`);
const data = await response.json();
if (!ignore) {
setData(data);
}
}
fetchData();
return () => { ignore = true; };
}, [id]);
useEffect 안에서 async 사용하기
// ❌ useEffect에 async 함수를 직접 전달 불가
useEffect(async () => {
const data = await fetchData();
setData(data);
}, []);
// 경고: useEffect의 반환값은 함수여야 하는데 Promise가 반환됨
// ✅ 내부에 async 함수를 정의하고 호출
useEffect(() => {
async function loadData() {
try {
const data = await fetchData();
setData(data);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
}
loadData();
}, []);
useEffect가 필요 없는 경우
React 공식 문서에서 강조하는 "You Might Not Need an Effect" 패턴들입니다.
파생 데이터 계산
// ❌ 불필요한 useEffect — state 동기화
const [items, setItems] = useState([]);
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(item => item.active));
}, [items]);
// ✅ 렌더링 중에 직접 계산
const [items, setItems] = useState([]);
const filteredItems = items.filter(item => item.active);
// 비용이 크면 useMemo 사용
const filteredItems = useMemo(
() => items.filter(item => item.active),
[items]
);
props 변경에 따른 state 리셋
// ❌ useEffect로 state 리셋
function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
useEffect(() => {
setComment('');
}, [userId]);
}
// ✅ key 변경으로 컴포넌트 재마운트
function ProfilePage({ userId }) {
return <CommentForm key={userId} />;
}
이벤트 핸들러로 충분한 경우
// ❌ useEffect에서 이벤트에 반응
const [query, setQuery] = useState('');
useEffect(() => {
if (query !== '') {
analytics.track('search', { query });
}
}, [query]);
// ✅ 이벤트 핸들러에서 직접 처리
function handleSearch(e) {
const newQuery = e.target.value;
setQuery(newQuery);
if (newQuery !== '') {
analytics.track('search', { query: newQuery });
}
}
실전 패턴
인터벌/타이머
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
// callback이 바뀌면 ref 업데이트
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
브라우저 이벤트
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
주의할 점
의존성 배열에 객체/배열을 넣으면 매번 실행됨
부모에서 전달받은 객체 prop이나 렌더링 중 생성된 배열을 의존성에 넣으면, 매 렌더링마다 새 참조가 생성되어 effect가 ** 매번 재실행 **됩니다. 필요한 원시값만 의존성으로 지정해야 합니다.
useEffect에 async 함수를 직접 전달하면 안 됨
useEffect의 반환값은 cleanup 함수여야 하는데, async 함수는 Promise를 반환합니다. 내부에 별도의 async 함수를 정의하고 호출하는 패턴을 사용해야 합니다.
"useEffect가 필요 없는 경우"를 구분하지 못하는 실수
props로부터 파생된 값은 렌더링 중에 직접 계산하면 됩니다. useEffect로 state를 업데이트하면 ** 불필요한 추가 렌더링 **이 발생합니다. 이벤트에 대한 반응도 이벤트 핸들러에서 직접 처리하는 것이 올바릅니다.
정리
| 항목 | 설명 |
|---|---|
| useEffect의 본질 | 생명주기 대체가 아닌 ** 외부 시스템과의 동기화** |
| cleanup 실행 시점 | 다음 setup 전 + 언마운트 시 |
| 의존성 비교 | Object.is로 얕은 비교 — 객체/배열은 원시값으로 분리 |
| race condition | AbortController 또는 boolean 플래그로 방지 |
| 불필요한 useEffect | 파생 데이터 계산, props 리셋(key 사용), 이벤트 반응 |
| async 패턴 | 내부에 별도 async 함수를 정의 후 호출 |
useEffect의 올바른 사용법을 배우는 것만큼, "useEffect가 필요 없는 경우"를 아는 것이 중요합니다.