부수 효과 기본형 의존성 배열 정리(cleanup) 흔한 함정 📝 문제풀이
◀ 이전 📋 목차 CH 08 ▶
🔄 CHAPTER 07 · 중급

useEffect — 렌더링 바깥의 일을 다루기

컴포넌트는 기본적으로 화면을 그리는 일만 해요. 그런데 데이터를 가져오거나 타이머를 켜는 것처럼 화면 그리기 바깥의 일도 해야 할 때가 있죠. 그걸 안전하게 처리하는 도구가 useEffect예요. 천천히, 단계별로 익혀봐요.

🎯 이 장을 끝내면
🧩
이 장에서 뭐가 JS고 뭐가 리액트? 🟦 순수 JS: setTimeout·setInterval 같은 타이머, addEventListener로 이벤트 구독·해제, fetch로 데이터 가져오기, 객체·함수의 "참조 비교"처럼 브라우저·자바스크립트가 원래 하는 일. 🟪 리액트: useEffect·의존성 배열·정리(cleanup) 함수·마운트/언마운트. 값을 계산·판단하면 JS, "언제·어떻게 화면·컴포넌트에 반영되나"는 리액트라고 보면 대체로 맞아요.
📖
부수 효과
부수 효과(side effect)란 뭘까?
이름은 어렵지만, "화면 그리기 말고 하는 나머지 일"이에요.

리액트 컴포넌트의 본업은 딱 하나예요. props와 state를 받아서 화면(JSX)을 그려서 돌려주는 것. 그런데 실제 앱은 화면만 그려선 안 되죠. 서버에서 데이터를 가져오고, 타이머를 켜고, 이벤트를 구독하고 같은 일도 해야 해요. 이렇게 렌더링 결과와는 직접 상관없지만 컴포넌트가 해야 하는 "바깥 일"부수 효과(side effect)라고 불러요.

🧩 부수 효과의 대표 예시들🟦 순수 JS 데이터 가져오기 — 서버(API)에서 목록을 불러오기.
타이머setInterval·setTimeout으로 1초마다 뭔가 하기.
이벤트 구독 — 스크롤·리사이즈 같은 브라우저 이벤트를 듣기.
수동 DOM 조작 — 특정 요소에 포커스를 주거나 크기를 직접 재기. 공통점은 "화면을 그리는 것 자체는 아니지만, 화면과 함께 필요한 일"이라는 점이에요.
⚠️
이런 일을 렌더 도중에 그냥 하면 안 돼요. 렌더 함수(컴포넌트 본문)는 화면을 계산하는 순수한 과정이어야 해요. 그 안에서 데이터를 가져오거나 타이머를 켜면, 리액트가 화면을 여러 번 계산할 때마다 그 일이 제멋대로 여러 번 일어나 버그가 생겨요. 그래서 부수 효과는 렌더가 끝난 뒤에 따로 실행해야 하고, 그 도구가 바로 useEffect예요.
side effect 데이터 가져오기 타이머 이벤트 구독 DOM 조작 useEffect
🖼️ 그림으로 보기 — useEffect 실행 타이밍
① 마운트 effect 실행 화면이 그려진 뒤 ② 의존성 값 변경 cleanup 실행 → effect 다시 실행 ③ 언마운트 cleanup 실행 마지막 뒷정리
빈 배열 []이면 마운트 때 1번만. 의존성이 있으면 값이 바뀔 때마다 (정리 후) 다시 실행돼요. return한 cleanup은 다음 실행 전·언마운트 때 돌아요.
🔤
마운트 · 언마운트 — 마운트는 컴포넌트가 화면에 처음 나타나는 순간, 언마운트는 화면에서 사라지는 순간이에요. (아래에서 더 자세히 나와요.)
🧪
기본형
useEffect의 기본 모양
인자 두 개 — "무엇을 할지"와 "언제 다시 할지"예요.

useEffect는 두 개의 인자를 받아요. 첫 번째는 실행할 함수(이펙트 함수), 두 번째는 의존성 배열이에요.

useEffect(() => {
  // 여기에 실행할 부수 효과를 적어요
  console.log("화면이 그려진 뒤 실행돼요!");
}, [의존성]);
⏱️ 언제 실행될까 — "화면이 그려진 뒤"🟪 리액트 가장 중요한 포인트예요. 이펙트 함수는 렌더 도중이 아니라, 화면이 실제로 그려진(paint) 뒤에 실행돼요. 그래서 화면을 막지 않고, 부수 효과가 렌더 계산에 끼어들지 않아요. 순서: ① 컴포넌트 함수 실행(화면 계산) → ② 실제 화면에 그리기 → ③ 그 다음 useEffect 실행.

실제로 자주 보는 예시예요. 컴포넌트가 처음 나타났을 때 데이터를 한 번 가져오는 흔한 패턴이죠.

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가 의존성
🧭
규칙. 이펙트 함수 안에서 사용하는 state나 props모두 의존성 배열에 넣어야 해요. 그래야 값이 바뀔 때 이펙트가 최신 값으로 다시 돌아요. (넣지 않으면 오래된 값을 계속 참조하는 버그가 생겨요.)
🧹
정리(cleanup)
뒷정리 함수 — return으로 등록한다
타이머를 켰으면 꺼야 하고, 구독했으면 해제해야 해요.

타이머를 켜거나 이벤트를 구독하면, 컴포넌트가 사라질 때 그걸 정리(해제)해야 해요. 안 그러면 이미 없어진 컴포넌트를 위해 타이머가 계속 돌거나, 구독이 쌓여서 메모리 누수·버그가 생겨요. 리액트는 이걸 위해 이펙트 함수가 return하는 함수를 정리(cleanup) 함수로 써요.

useEffect(() => {
  // 부수 효과 시작: 1초마다 실행되는 타이머
  const id = setInterval(() => {
    console.log("똑딱");
  }, 1000);

  // ↓ return한 함수가 "정리 함수"예요
  return () => {
    clearInterval(id); // 타이머 해제
  };
}, []);
🕒 정리 함수는 언제 호출될까?🟪 리액트 두 순간에 호출돼요.
1. 컴포넌트가 사라질 때(언마운트) — 화면에서 없어지기 직전에 뒷정리를 해요.
2. 다음 이펙트가 실행되기 직전 — 의존성이 바뀌어 이펙트를 다시 돌리기 전에, 이전 이펙트의 뒷정리를 먼저 해요. 그래서 "켰으면 → 끈다"가 항상 짝을 이뤄요. setIntervalclearInterval, addEventListenerremoveEventListener처럼요.
🟢
이펙트 실행
타이머·구독 시작
🔁
의존성 변경
또는 언마운트
🧹
정리 함수
return한 함수 호출
깔끔
누수 없이 해제
🧹
정리 함수 = 이펙트에서 return하는 함수. 별도 API가 아니라, 그냥 이펙트 안에서 함수를 하나 돌려주면 리액트가 알아서 "정리할 때 이걸 부르면 되는구나" 하고 기억해요. 목적은 타이머 해제·구독 해제 같은 뒷정리예요.
🕳️
흔한 함정
무한 루프에 빠지는 이유
가장 많이 겪는 실수 하나만 확실히 짚어봐요.

이펙트 안에서 state를 바꾸는데, 그 이펙트의 의존성에 매 렌더마다 새로 만들어지는 값이 들어 있으면 무한 루프가 돌아요. 이펙트 실행 → state 변경 → 리렌더 → 의존성이 또 "달라졌다"고 판단 → 이펙트 재실행 → ... 이 고리가 끝나지 않는 거예요.

😵 왜 "새 값"이 문제일까🟦 순수 JS 리액트는 의존성을 이전 값과 비교해 달라졌는지 봐요. 그런데 객체·배열·함수를 렌더 안에서 {}·[]·() => {}로 새로 만들면, 내용이 같아도 "다른 값"으로 취급돼요(참조가 매번 새로 생기니까). 그래서 이펙트가 매번 "달라졌다"고 보고 다시 실행돼요. 이펙트 안에서 state를 바꾸기까지 하면, 그 리렌더가 또 새 값을 만들고 → 또 실행 → 무한 루프!
🔤
참조(reference) — 객체·배열·함수는 값 자체가 아니라 "메모리 속 위치를 가리키는 주소"로 비교돼요. 그래서 내용이 똑같아도 새로 만들면 주소(참조)가 달라 "다른 값"으로 취급돼요.
🟢
이펙트 실행
setState 호출
🔄
리렌더
화면 다시 계산
🆕
새 객체 생성
참조가 또 달라짐
♾️
무한 반복
다시 이펙트 실행…
// ❌ 무한 루프! options는 매 렌더마다 새 객체
function Bad() {
  const [n, setN] = useState(0);
  const options = { step: 1 }; // 매번 새로 생김

  useEffect(() => {
    setN((v) => v + options.step); // state 변경 → 리렌더
  }, [options]); // options가 매번 "달라져" 다시 실행 → 무한 반복
}
💡
해결의 방향. 의존성에는 정말 필요한 원시값(숫자·문자열 등)만 넣거나, 값 자체가 안 바뀌게 컴포넌트 밖으로 빼거나(상수), 나중에 배울 useMemo·useCallback으로 같은 참조를 유지하면 돼요. 지금은 "매 렌더마다 새로 생기는 값을 의존성에 넣으면 무한 루프가 날 수 있다"는 감만 잡으면 충분해요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
배운 걸 가볍게 점검해봐요
시험이 아니라 "내가 이해했나" 확인용이에요. 틀려도 바로 해설이 나와요.
🧪
문제를 풀면 즉시 정답과 해설이 나오고, 위쪽 바에 점수가 쌓여요. 네 가지 유형(객관식 · O/X · 빈칸 · 개념결과)을 섞어 두었어요.
CH 08 📝 폼과 제어 컴포넌트 — 입력값을 state로 다루는 법