리액트 처음 배울 때 누구나 한 번쯤 겪는 순간이 있습니다.
"분명 공식문서대로 똑같이 썼는데... 왜 무한루프가 돌지?"
"콘솔에 같은 로그가 100번씩 찍히는데 대체 뭐가 문제야?"
저도 포트폴리오 작업하면서 useEffect 의존성 배열 때문에 몇 시간을 날린 적이 있어요. 분명 작동은 하는데 왜 이렇게 동작하는지 이해를 못 하니까 코드를 고칠 때마다 불안했죠.
이 글에서는 useState와 useEffect의 본질적인 차이부터 시작해서, 의존성 배열이 헷갈리는 진짜 이유와 실전에서 자주 만나는 트러블슈팅 케이스까지 정리해보려고 합니다.
useState는 한 마디로 정의하면 "리렌더링을 트리거하는 값을 저장하는 훅"입니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
여기서 중요한 포인트는, setCount를 호출하면 단순히 값만 바뀌는 게 아니라 컴포넌트 전체가 다시 렌더링된다는 점이에요. 일반 변수와 결정적으로 다른 부분이죠.
// ❌ 잘못된 예시
const [todos, setTodos] = useState([]);
function addTodo(newTodo) {
todos.push(newTodo); // 배열을 직접 수정
setTodos(todos); // 리액트는 이 변화를 감지하지 못함!
}
리액트는 state가 바뀌었는지를 참조값(reference) 비교로 판단해요. 위 코드는 todos 배열의 참조 자체는 그대로이기 때문에 화면이 갱신되지 않습니다.
// ✅ 올바른 예시
function addTodo(newTodo) {
setTodos([...todos, newTodo]); // 새로운 배열을 만들어서 전달
}
이 "새로운 참조를 만든다"는 개념이 사실 뒤에서 다룰 의존성 배열 문제와도 직결됩니다.
useState가 "값을 저장하고 변화를 알리는 훅"이라면, useEffect는 "렌더링 이후에 실행되는 부수효과(side effect)를 처리하는 훅"입니다.
부수효과란 컴포넌트 렌더링 자체와는 별개로 발생하는 작업들을 말해요. 예를 들면:
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => setUser(data));
}, [userId]); // userId가 바뀔 때마다 다시 실행
if (!user) return <p>로딩중...</p>;
return <p>{user.name}님 환영합니다</p>;
}
핵심은 useEffect가 "렌더링 자체"가 아니라 "렌더링이 끝난 후"에 실행된다는 점이에요. 그래서 화면을 그리는 로직과 부수효과 로직을 분리할 수 있게 해줍니다.
useEffect의 두 번째 인자로 들어가는 배열, 다들 한 번쯤은 "그냥 이렇게 쓰면 된다더라" 식으로 넘어간 경험 있으실 거예요. 정확히 짚고 가볼게요.
useEffect(() => {
// 실행할 코드
}, [의존성_배열]);
이 배열은 리액트에게 "이 값들이 바뀌었을 때만 effect를 다시 실행해줘"라고 알려주는 역할을 합니다. 케이스별로 비교해볼게요.
| 의존성 배열 | 실행 시점 |
|---|---|
배열 없음 (useEffect(() => {...})) | 렌더링될 때마다 매번 실행 |
빈 배열 ([]) | 최초 마운트 시 딱 한 번만 실행 |
값이 있는 배열 ([count]) | 최초 마운트 + count가 바뀔 때마다 실행 |
직접 콘솔로 찍어보면 감이 빨리 옵니다.
function Example({ count }) {
// 1. 매번 실행
useEffect(() => {
console.log('렌더링마다 실행됨');
});
// 2. 최초 1번만 실행
useEffect(() => {
console.log('마운트 시 1번만 실행됨');
}, []);
// 3. count가 바뀔 때만 실행
useEffect(() => {
console.log('count가 바뀌어서 실행됨:', count);
}, [count]);
}
여기까지는 다들 어느 정도 익숙하실 텐데, 진짜 헷갈리는 건 다음 단계부터예요.
// ❌ 문제가 있는 코드
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // 항상 0만 찍힘!
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []); // count를 빈 배열에 넣지 않음
}
이게 바로 "클로저가 오래된 값을 기억한다(stale closure)"는 현상이에요. useEffect는 최초 렌더링 시점에 만들어진 함수를 그대로 기억하고 있어서, count가 0이던 그 순간의 값을 계속 참조합니다.
// ✅ 해결 방법 1: 의존성 배열에 추가
useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => prev + 1); // 함수형 업데이트로 해결하는 것도 방법
}, 1000);
return () => clearInterval(timer);
}, []);
함수형 업데이트(setCount(prev => prev + 1))를 쓰면 의존성 배열에 count를 넣지 않아도 항상 최신 값을 기준으로 업데이트할 수 있어요. 이게 실무에서 가장 많이 쓰는 패턴입니다.
// ❌ 무한루프 발생
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const options = { query, limit: 10 }; // 렌더링마다 새로 생성됨
useEffect(() => {
fetchResults(options).then(setResults);
}, [options]); // options는 매번 다른 참조값!
}
options는 렌더링될 때마다 새로운 객체로 만들어집니다. 내용물이 같아도 참조가 다르기 때문에 리액트는 "의존성이 바뀌었다"고 판단해서 effect를 다시 실행하고, 그 안에서 setResults가 호출되어 또 리렌더링이 일어나고... 무한루프에 빠집니다.
// ✅ 해결 방법: 원시값만 의존성에 넣기
useEffect(() => {
const options = { query, limit: 10 };
fetchResults(options).then(setResults);
}, [query]); // query라는 원시값만 비교
객체나 배열은 effect 안에서 직접 만들어서 쓰거나, 정말 필요하다면 useMemo로 참조를 고정하는 방법도 있어요.
eslint-plugin-react-hooks를 설치하면 의존성 배열이 빠졌을 때 경고를 띄워줍니다.
React Hook useEffect has a missing dependency: 'userId'.
이 경고가 귀찮다고 그냥 // eslint-disable-next-line으로 덮어버리는 경우를 종종 보는데, 이건 대부분 stale closure 버그를 그대로 묻어두는 거예요. 경고가 뜬다면 "정말 이 값이 effect 안에서 필요 없는가?"를 먼저 의심해보는 게 맞습니다.
의존성 배열 앞에서 막막해질 때, 저는 이렇게 질문을 던지고 정리합니다.
prev => ...)를 고려하자결국 의존성 배열은 "리액트를 속이는 트릭"이 아니라, "이 effect가 실제로 어떤 값에 의존하고 있는지"를 정직하게 선언하는 자리라는 걸 이해하면 훨씬 덜 헷갈립니다.