
useEffect 무한 루프 탈출기
useEffect 의존성 배열 때문에 무한 루프에 빠졌던 경험과 해결 방법

useEffect 의존성 배열 때문에 무한 루프에 빠졌던 경험과 해결 방법
분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

서버에서 잘 오던 데이터가 갑자기 앱을 죽입니다. 'type Null is not a subtype of type String' 에러의 원인과, 안전한 JSON 파싱을 위한 Null Safety 전략을 정리해봤습니다.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

저는 제 서비스에서 사용자 데이터를 불러오는 기능을 만들고 있었습니다. API를 호출해서 데이터를 가져온 다음, state에 저장하는 간단한 로직이었죠. useEffect를 써서 컴포넌트가 마운트될 때 데이터를 불러오려고 했습니다.
그런데 코드를 실행하자마자 브라우저가 먹통이 되더니 수천 개의 API 요청이 날아가기 시작했습니다. 개발자 도구의 Network 탭을 보니까 같은 요청이 끊임없이 반복되고 있었어요. 제 Supabase 무료 플랜 할당량이 순식간에 바닥났습니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [user]); // 🔥 이게 문제였습니다
return <div>{user?.name}</div>;
}
처음엔 "왜 이러지?"라고 당황했습니다. 분명히 useEffect를 썼는데 왜 계속 실행되는 거죠? 급하게 브라우저 탭을 닫고 코드를 다시 봤지만, 뭐가 잘못됐는지 바로 보이지 않았습니다.
더 황당했던 건, 이 버그가 프로덕션에 배포되기 직전에 발견됐다는 겁니다. 만약 실제 사용자들이 이걸 겪었다면... 생각만 해도 아찔합니다.
제가 가진 오개념은 이거였습니다: "의존성 배열에 관련된 변수를 다 넣으면 되는 거 아닌가?"
공식 문서에서 "의존성 배열에 effect 안에서 사용하는 모든 값을 넣으세요"라고 했거든요. 그래서 저는 user를 사용하니까 당연히 의존성 배열에 넣어야 한다고 생각했습니다.
그런데 이게 무한 루프를 만든 원인이었습니다:
useEffect가 실행됨 → API 호출setUser(data) 실행user state가 변경됨user가 의존성 배열에 있으니까 useEffect가 다시 실행됨"아니, 그럼 의존성 배열을 어떻게 써야 하는데?"라는 생각이 들었습니다. 넣으라고 해서 넣었더니 무한 루프고, 안 넣으면 ESLint가 경고를 띄우고... 정말 답답했습니다.
더 혼란스러웠던 건, 가끔은 의존성 배열을 비워도 ([]) 잘 동작하는데, 가끔은 또 문제가 생기는 거였습니다. "도대체 규칙이 뭐야?"라고 소리치고 싶었죠.
무한 루프의 원리를 이해한 건 이런 비유를 들었을 때였습니다:
"useEffect는 '변화를 감지하는 감시자'다. 의존성 배열에 있는 값이 바뀌면 '아, 뭔가 바뀌었네? 다시 실행해야지'라고 생각한다. 그런데 effect 안에서 그 값을 바꾸면? 감시자가 '또 바뀌었네? 또 실행!'을 무한 반복한다."아! 그래서 user를 의존성 배열에 넣으면 안 되는 거구나. useEffect 안에서 user를 바꾸는데, 그걸 의존성으로 넣으면 자기가 자기를 계속 트리거하는 셈이었습니다.
그럼 뭘 의존성 배열에 넣어야 할까요? 제가 깨달은 핵심은 이거였습니다:
"effect 안에서 '읽기만' 하는 값은 의존성에 넣고, effect 안에서 '쓰는(변경하는)' 값은 넣지 않는다."제 경우에는 userId를 읽기만 하니까 의존성에 넣어야 하고, user는 쓰기(변경)를 하니까 넣으면 안 되는 거였습니다.
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, [userId]); // ✅ userId만 의존성에 넣기
이제 userId가 바뀔 때만 API를 다시 호출합니다. 완벽하죠!
useEffect의 의존성 배열은 "이 값들이 바뀌면 effect를 다시 실행해줘"라는 뜻입니다. React는 이전 렌더링의 값과 현재 렌더링의 값을 비교해서 하나라도 바뀌면 effect를 실행합니다.
useEffect(() => {
console.log('Effect running');
}, [a, b, c]);
// a, b, c 중 하나라도 바뀌면 실행
여기서 중요한 건 "바뀌었다"의 기준입니다. React는 Object.is() 비교를 사용합니다. 원시 타입(숫자, 문자열, 불린)은 값으로 비교하지만, 객체나 배열은 참조로 비교합니다.
이게 또 다른 무한 루프의 원인이 됩니다:
function SearchResults() {
const [results, setResults] = useState([]);
const filters = { category: 'tech', minPrice: 100 }; // 🔥 매 렌더링마다 새 객체!
useEffect(() => {
searchAPI(filters).then(data => setResults(data));
}, [filters]); // 무한 루프!
}
왜 무한 루프일까요? filters는 매 렌더링마다 새로운 객체로 생성됩니다. 내용은 같아도 참조가 다르니까 React는 "바뀌었다"고 판단하고 effect를 다시 실행합니다.
해결책은 세 가지입니다:
해결책 1: 객체를 컴포넌트 밖으로 빼기const FILTERS = { category: 'tech', minPrice: 100 }; // 컴포넌트 밖
function SearchResults() {
const [results, setResults] = useState([]);
useEffect(() => {
searchAPI(FILTERS).then(data => setResults(data));
}, []); // FILTERS는 절대 안 바뀌니까 의존성 불필요
}
해결책 2: useMemo로 메모이제이션
function SearchResults() {
const [results, setResults] = useState([]);
const filters = useMemo(
() => ({ category: 'tech', minPrice: 100 }),
[] // 의존성이 없으니 한 번만 생성
);
useEffect(() => {
searchAPI(filters).then(data => setResults(data));
}, [filters]); // ✅ filters가 안 바뀌니까 한 번만 실행
}
해결책 3: 의존성을 개별 값으로 분리
function SearchResults({ category, minPrice }) {
const [results, setResults] = useState([]);
useEffect(() => {
const filters = { category, minPrice };
searchAPI(filters).then(data => setResults(data));
}, [category, minPrice]); // ✅ 원시 타입만 의존성에
}
저는 보통 해결책 3을 선호합니다. 가장 명확하고 실수할 여지가 적거든요.
함수도 객체니까 같은 문제가 생깁니다:
function DataFetcher() {
const [data, setData] = useState(null);
const fetchData = () => { // 🔥 매 렌더링마다 새 함수!
return api.getData();
};
useEffect(() => {
fetchData().then(result => setData(result));
}, [fetchData]); // 무한 루프!
}
해결책은 useCallback입니다:
function DataFetcher() {
const [data, setData] = useState(null);
const fetchData = useCallback(() => {
return api.getData();
}, []); // 의존성이 없으니 한 번만 생성
useEffect(() => {
fetchData().then(result => setData(result));
}, [fetchData]); // ✅ fetchData가 안 바뀌니까 한 번만 실행
}
하지만 더 간단한 방법은 함수를 effect 안에 넣는 겁니다:
function DataFetcher() {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = () => api.getData(); // effect 안에 정의
fetchData().then(result => setData(result));
}, []); // ✅ 의존성 없음
}
이게 제일 깔끔합니다. 함수가 effect 안에서만 쓰이면 밖으로 뺄 이유가 없습니다.
의존성 배열을 아예 안 쓰면 어떻게 될까요?
// 패턴 1: 빈 배열
useEffect(() => {
console.log('마운트될 때 한 번만');
}, []);
// 패턴 2: 배열 없음
useEffect(() => {
console.log('매 렌더링마다');
});
[]: 마운트될 때 한 번만 실행 (의존성이 없으니 절대 안 바뀜)저는 처음에 이 차이를 몰라서 배열을 안 쓰다가 성능 문제를 겪었습니다. 컴포넌트가 리렌더링될 때마다 API를 호출하니까 엄청 느려지더라고요.
ESLint의 exhaustive-deps 규칙은 정말 유용합니다. 이 경고를 무시하면 나중에 버그가 생깁니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data));
}, []); // ⚠️ ESLint 경고: userId를 의존성에 넣으세요
}
이 코드는 처음엔 잘 동작하는 것처럼 보입니다. 하지만 userId가 바뀌어도 새 데이터를 안 불러옵니다. 사용자가 프로필을 전환해도 이전 사용자 정보가 계속 보이는 버그가 생기는 거죠.
ESLint가 경고하면 99%는 진짜 문제입니다. 무시하지 말고 제대로 고치세요.
제 서비스에 검색 기능을 만들 때 이런 코드를 짰습니다:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (query.length > 0) {
searchAPI(query).then(data => setResults(data));
}
}, [query]); // query가 바뀔 때마다 검색
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
이건 잘 동작했습니다. 사용자가 타이핑할 때마다 query가 바뀌고, 그때마다 검색이 실행됩니다.
하지만 문제가 있었습니다. 사용자가 "React"를 검색하려고 "R", "Re", "Rea", "Reac", "React"를 입력하면 API가 5번 호출됩니다. 너무 많죠.
디바운싱을 추가했습니다:
function SearchBox() {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState([]);
// query가 바뀌면 500ms 후에 debouncedQuery 업데이트
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer); // cleanup
}, [query]);
// debouncedQuery가 바뀔 때만 검색
useEffect(() => {
if (debouncedQuery.length > 0) {
searchAPI(debouncedQuery).then(data => setResults(data));
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
}
이제 사용자가 타이핑을 멈춘 후 500ms 뒤에만 검색이 실행됩니다. API 호출이 확 줄었습니다.
여러 state를 동기화해야 할 때도 useEffect를 씁니다:
function PriceCalculator() {
const [quantity, setQuantity] = useState(1);
const [price, setPrice] = useState(100);
const [total, setTotal] = useState(100);
useEffect(() => {
setTotal(quantity * price);
}, [quantity, price]); // quantity나 price가 바뀌면 total 재계산
return (
<div>
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<input value={price} onChange={e => setPrice(+e.target.value)} />
<p>Total: {total}</p>
</div>
);
}
이건 잘 동작하지만, 사실 total을 state로 만들 필요가 없습니다. 계산된 값이니까요:
function PriceCalculator() {
const [quantity, setQuantity] = useState(1);
const [price, setPrice] = useState(100);
const total = quantity * price; // 그냥 계산하면 됨!
return (
<div>
<input value={quantity} onChange={e => setQuantity(+e.target.value)} />
<input value={price} onChange={e => setPrice(+e.target.value)} />
<p>Total: {total}</p>
</div>
);
}
이게 훨씬 간단합니다. useEffect를 쓰기 전에 "이거 정말 effect가 필요한가?"를 먼저 생각하세요.
API 호출을 취소하지 않으면 메모리 누수가 생길 수 있습니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) { // 취소되지 않았을 때만 업데이트
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: 취소 플래그 설정
};
}, [userId]);
return <div>{user?.name}</div>;
}
사용자가 빠르게 프로필을 전환하면 이전 요청이 아직 진행 중일 수 있습니다. Cleanup 함수로 이전 요청을 무시하면 "언마운트된 컴포넌트에서 setState 호출" 경고를 피할 수 있습니다.