React useEffect, useState 완벽 정리: 의존성 배열부터 클린업까지
React로 개발하다 보면 가장 먼저, 그리고 가장 자주 쓰는 훅이 바로 useState와 useEffect입니다. 하지만 동시에 가장 많은 버그가 발생하는 곳이기도 합니다. "왜 화면이 안 바뀌지?", "왜 API가 무한히 호출되지?" 같은 문제는 대부분 이 두 훅의 동작 원리를 정확히 모를 때 생깁니다. 이 글에서 핵심 원리와 흔한 실수를 정리합니다.
useState: 상태는 직접 바꾸지 않는다
가장 흔한 실수는 상태를 직접 변경하는 것입니다. React는 **불변성(immutability)**을 기준으로 리렌더링을 결정하므로, 객체나 배열은 새로 만들어 교체해야 합니다.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: '철수', age: 20 });
// 잘못된 예: user.age = 21; (리렌더링 안 됨)
// 올바른 예: 새 객체로 교체
const birthday = () => setUser(prev => ({ ...prev, age: prev.age + 1 }));
// 연속 업데이트는 함수형 갱신 사용
const addTwice = () => {
setCount(prev => prev + 1);
setCount(prev => prev + 1); // 정상적으로 +2
};
return <button onClick={addTwice}>{count}</button>;
}
팁: 이전 상태를 기반으로 갱신할 때는 항상 setCount(prev => ...) 형태의 함수형 업데이트를 쓰세요. 클로저로 인한 오래된(stale) 값 문제를 피할 수 있습니다.
useEffect: 의존성 배열이 전부다
useEffect는 렌더링 이후 실행되는 사이드 이펙트입니다. 두 번째 인자인 의존성 배열이 실행 시점을 결정합니다.
| 의존성 배열 | 실행 시점 |
|---|---|
| 없음 | 매 렌더링마다 실행 |
[] (빈 배열) | 마운트 시 1회만 실행 |
[a, b] | a 또는 b가 바뀔 때 실행 |
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false; // 경쟁 상태(race condition) 방지
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(json => {
if (!ignore) setData(json);
});
return () => { ignore = true; }; // 클린업
}, [userId]); // userId가 바뀔 때마다 다시 호출
return <div>{data?.name ?? '로딩 중...'}</div>;
}
무한 루프와 클린업 함수
의존성 배열에 매 렌더링마다 새로 만들어지는 객체/함수를 넣으면 무한 루프가 발생합니다. 객체는 참조값이 매번 달라지기 때문입니다.
- 무한 루프 원인: effect 안에서 state를 바꾸는데, 그 state가 의존성에 들어있는 경우
- 해결: 함수는
useCallback, 객체는useMemo로 메모이제이션하거나 원시값만 의존성에 넣기 - 클린업:
setInterval, 이벤트 리스너, 구독은 반드시return () => {...}로 해제
useEffect(() => {
const timer = setInterval(() => console.log('tick'), 1000);
return () => clearInterval(timer); // 언마운트 시 정리
}, []);
마무리 체크리스트
- 상태 변경은 항상 새 값/새 객체로 교체 (불변성 유지)
- 이전 값 기반 갱신은 함수형 업데이트
setX(prev => ...) useEffect의존성 배열에 effect에서 쓰는 모든 값을 정확히 명시- 타이머·리스너·구독은 반드시 클린업 함수로 해제
- 비동기 fetch에는
ignore플래그로 경쟁 상태 방어
이 원칙만 지켜도 React 훅 관련 버그의 90%는 사라집니다.
함께 보면 좋은 글
React key는 왜 index를 쓰면 안 될까: 리스트 렌더링의 함정
key의 역할(재조정), index를 key로 쓸 때 생기는 상태 꼬임·성능 저하, 안정적인 고유 id를 써야 하는 이유를 예제로 설명합니다.
CSS Flexbox vs Grid 차이와 선택 기준: 1차원 vs 2차원 레이아웃
Flexbox와 Grid는 언제 무엇을 써야 할까요? 1차원과 2차원이라는 핵심 차이부터 실전 코드, 선택 기준까지 한 번에 정리합니다. 더 이상 헷갈리지 마세요.
자바스크립트 비동기 완벽 가이드: Promise와 async/await 제대로 쓰기
콜백 지옥부터 async/await까지 자바스크립트 비동기 처리의 핵심을 정리합니다. Promise 체이닝, 에러 처리, 병렬 실행(Promise.all)까지 실무 예제로 익혀보세요.