컴포넌트는 기본적으로 화면을 그리는 일만 해요. 그런데 데이터를 가져오거나 타이머를 켜는 것처럼 화면 그리기 바깥의 일도 해야 할 때가 있죠. 그걸 안전하게 처리하는 도구가 useEffect예요. 천천히, 단계별로 익혀봐요.
useEffect(() => {}, [의존성])의 기본형을 읽고 쓸 수 있어요.[]·[count]·생략일 때 각각 언제 실행되는지 구분해요.setTimeout·setInterval 같은 타이머, addEventListener로 이벤트 구독·해제, fetch로 데이터 가져오기, 객체·함수의 "참조 비교"처럼 브라우저·자바스크립트가 원래 하는 일. 🟪 리액트: useEffect·의존성 배열·정리(cleanup) 함수·마운트/언마운트. 값을 계산·판단하면 JS, "언제·어떻게 화면·컴포넌트에 반영되나"는 리액트라고 보면 대체로 맞아요.리액트 컴포넌트의 본업은 딱 하나예요. props와 state를 받아서 화면(JSX)을 그려서 돌려주는 것. 그런데 실제 앱은 화면만 그려선 안 되죠. 서버에서 데이터를 가져오고, 타이머를 켜고, 이벤트를 구독하고 같은 일도 해야 해요. 이렇게 렌더링 결과와는 직접 상관없지만 컴포넌트가 해야 하는 "바깥 일"을 부수 효과(side effect)라고 불러요.
setInterval·setTimeout으로 1초마다 뭔가 하기.useEffect예요.useEffect는 두 개의 인자를 받아요. 첫 번째는 실행할 함수(이펙트 함수), 두 번째는 의존성 배열이에요.
useEffect(() => {
// 여기에 실행할 부수 효과를 적어요
console.log("화면이 그려진 뒤 실행돼요!");
}, [의존성]);
실제로 자주 보는 예시예요. 컴포넌트가 처음 나타났을 때 데이터를 한 번 가져오는 흔한 패턴이죠.
function UserList() {
const [users, setUsers] = useState([]);
useEffect(() => {
// 화면이 그려진 뒤, 서버에서 목록을 가져와요
fetch("/api/users")
.then((res) => res.json())
.then((data) => setUsers(data));
}, []); // ← 처음 한 번만
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
useEffect(할일, [의존성]) — "화면이 그려진 뒤, 의존성이 바뀌면 할일을 다시 해줘." 그러니까 무엇을 할지는 첫 번째 인자, 언제 다시 할지는 두 번째 인자가 정해요.이펙트가 얼마나 자주 실행되는지는 전적으로 의존성 배열이 결정해요. 세 가지 경우를 확실히 구분해 두면 됩니다.
[] — 마운트 시 딱 1번🟪 리액트
배열이 비어 있으면, "지켜볼 값이 없다"는 뜻이에요. 그래서 컴포넌트가 처음 화면에 나타날 때(마운트) 딱 한 번만 실행되고, 이후엔 다시 실행되지 않아요. 데이터를 처음 한 번 가져올 때 자주 써요.
[count] — 그 값이 바뀔 때마다🟪 리액트
[count]처럼 값을 넣으면, 마운트 시 한 번 실행되고, 그 뒤로는 count가 바뀔 때마다 다시 실행돼요. 리액트가 이전 렌더의 count와 지금의 count를 비교해서, 달라졌을 때만 이펙트를 다시 돌려요.
| 두 번째 인자 | 실행 시점 | 주 용도 |
|---|---|---|
[] (빈 배열) | 마운트 시 딱 1번 | 초기 데이터 로딩, 최초 1회 설정 |
[count] | 마운트 + count가 바뀔 때마다 | 특정 값에 반응하는 처리 |
| 생략 | 매 렌더마다 | 거의 안 씀(대개 실수) |
// count가 바뀔 때마다 문서 제목을 갱신
useEffect(() => {
document.title = `클릭 ${count}번`;
}, [count]); // ← count가 의존성
타이머를 켜거나 이벤트를 구독하면, 컴포넌트가 사라질 때 그걸 정리(해제)해야 해요. 안 그러면 이미 없어진 컴포넌트를 위해 타이머가 계속 돌거나, 구독이 쌓여서 메모리 누수·버그가 생겨요. 리액트는 이걸 위해 이펙트 함수가 return하는 함수를 정리(cleanup) 함수로 써요.
useEffect(() => {
// 부수 효과 시작: 1초마다 실행되는 타이머
const id = setInterval(() => {
console.log("똑딱");
}, 1000);
// ↓ return한 함수가 "정리 함수"예요
return () => {
clearInterval(id); // 타이머 해제
};
}, []);
setInterval↔clearInterval, addEventListener↔removeEventListener처럼요.
return하는 함수. 별도 API가 아니라, 그냥 이펙트 안에서 함수를 하나 돌려주면 리액트가 알아서 "정리할 때 이걸 부르면 되는구나" 하고 기억해요. 목적은 타이머 해제·구독 해제 같은 뒷정리예요.이펙트 안에서 state를 바꾸는데, 그 이펙트의 의존성에 매 렌더마다 새로 만들어지는 값이 들어 있으면 무한 루프가 돌아요. 이펙트 실행 → state 변경 → 리렌더 → 의존성이 또 "달라졌다"고 판단 → 이펙트 재실행 → ... 이 고리가 끝나지 않는 거예요.
{}·[]·() => {}로 새로 만들면, 내용이 같아도 "다른 값"으로 취급돼요(참조가 매번 새로 생기니까). 그래서 이펙트가 매번 "달라졌다"고 보고 다시 실행돼요.
이펙트 안에서 state를 바꾸기까지 하면, 그 리렌더가 또 새 값을 만들고 → 또 실행 → 무한 루프!
// ❌ 무한 루프! options는 매 렌더마다 새 객체
function Bad() {
const [n, setN] = useState(0);
const options = { step: 1 }; // 매번 새로 생김
useEffect(() => {
setN((v) => v + options.step); // state 변경 → 리렌더
}, [options]); // options가 매번 "달라져" 다시 실행 → 무한 반복
}
useMemo·useCallback으로 같은 참조를 유지하면 돼요. 지금은 "매 렌더마다 새로 생기는 값을 의존성에 넣으면 무한 루프가 날 수 있다"는 감만 잡으면 충분해요.useEffect(할일, [의존성]) — 이펙트는 화면이 그려진 뒤 실행돼요.[] = 마운트 1번, [count] = count 바뀔 때마다, 생략 = 매 렌더마다.