중급

React 강좌 06강 — useEffect 완전 이해: 사이드 이펙트와 클린업

🎯 학습 목표

  • useEffect의 실행 순서와 cleanup 함수의 역할을 정확히 이해한다
  • 의존성 배열 세 가지 형태를 각각 설명할 수 있다
  • cleanup이 필요한 대표적인 사례를 코드로 구현한다
  • React 18 StrictMode에서 effect가 두 번 실행되는 이유를 이해한다
  • useLayoutEffect와 useEffect의 차이를 안다

📖 핵심 개념 1 — useEffect 실행 순서

useEffect는 렌더링이 완료된 후 실행됩니다. 브라우저가 화면을 그린 뒤에 실행되므로, DOM 조작이나 외부 요청에 적합합니다.

function LifecycleDemo() {
  const [count, setCount] = useState(0);

  console.log('1. 렌더링 시작'); // 렌더링마다 실행

  useEffect(() => {
    console.log('3. Effect 실행 (렌더 후)');

    return () => {
      console.log('2. Cleanup 실행 (다음 effect 전, 또는 언마운트 시)');
      // cleanup은 다음 순서로 실행됨:
      // 리렌더링 시: cleanup → 렌더 → effect
      // 언마운트 시: cleanup
    };
  }, [count]);

  console.log('1-2. 렌더링 계속');

  return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

// count가 0→1로 바뀔 때 실행 순서:
// 1. 렌더링 시작
// 1-2. 렌더링 계속
// 2. Cleanup 실행 (이전 effect의 정리)
// 3. Effect 실행 (새 count=1 기준)

📖 핵심 개념 2 — 의존성 배열 세 가지 형태

// 형태 1: 빈 배열 [] — 마운트 시 한 번만 실행
useEffect(() => {
  console.log('컴포넌트가 화면에 나타났습니다');
  fetchInitialData(); // API 최초 호출

  return () => {
    console.log('컴포넌트가 화면에서 사라집니다');
    // 리소스 정리
  };
}, []); // 빈 배열 — 의존성 없음

// 형태 2: [dep1, dep2] — 명시된 값이 변경될 때마다 실행
useEffect(() => {
  document.title = `검색: ${query} (${results.length}건)`;
  fetchSearchResults(query);
}, [query]); // query가 변경될 때마다 실행

// 형태 3: 의존성 배열 생략 — 렌더링마다 실행 (거의 사용 안 함)
useEffect(() => {
  console.log('매 렌더링 후 실행'); // 대부분의 경우 불필요
}); // 배열 없음

📖 핵심 개념 3 — Cleanup이 필요한 사례들

// 사례 1: 타이머
function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setSeconds(prev => prev + 1);
    }, 1000);

    // cleanup: 언마운트 시 타이머 정리
    return () => clearInterval(id);
  }, []);

  return <p>경과 시간: {seconds}초</p>;
}

// 사례 2: 이벤트 리스너
function WindowSize() {
  const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });

  useEffect(() => {
    function handleResize() {
      setSize({ w: window.innerWidth, h: window.innerHeight });
    }

    window.addEventListener('resize', handleResize);

    // cleanup: 이벤트 리스너 제거
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return <p>창 크기: {size.w} × {size.h}</p>;
}

// 사례 3: fetch 취소 (AbortController)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchUser() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal, // fetch에 signal 전달
        });
        const data = await res.json();
        setUser(data);
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('Fetch 취소됨 (컴포넌트 언마운트)');
          return; // 취소 에러는 무시
        }
        console.error('Fetch 에러:', err);
      }
    }

    fetchUser();

    // cleanup: 컴포넌트 언마운트 또는 userId 변경 시 이전 fetch 취소
    return () => controller.abort();
  }, [userId]);

  if (!user) return <p>로딩 중...</p>;
  return <p>{user.name}</p>;
}

// 사례 4: WebSocket
function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);

    ws.onmessage = (e) => {
      setMessages(prev => [...prev, JSON.parse(e.data)]);
    };

    // cleanup: WebSocket 연결 종료
    return () => ws.close();
  }, [roomId]); // roomId 변경 시 이전 연결 닫고 새 연결 생성

  return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}

📖 핵심 개념 4 — StrictMode의 Effect 이중 실행

React 18 StrictMode에서는 개발 모드에서 effect를 마운트 → 언마운트 → 마운트 순으로 실행해, cleanup이 올바르게 구현됐는지 검증합니다.

// StrictMode에서 실행 순서:
// 1. 마운트 → effect 실행
// 2. cleanup 실행 (StrictMode가 강제로 언마운트)
// 3. 마운트 → effect 다시 실행

// ❌ cleanup 없이 이벤트 리스너 등록 → 이중 등록 버그
useEffect(() => {
  window.addEventListener('click', handleClick);
  // cleanup 없음 → StrictMode에서 리스너 2개 등록됨
}, []);

// ✅ cleanup 있음 → StrictMode에서도 리스너 1개
useEffect(() => {
  window.addEventListener('click', handleClick);
  return () => window.removeEventListener('click', handleClick);
}, []);

// StrictMode 이중 실행은 "cleanup이 없는 effect"를 감지하는 도구입니다.
// effect가 두 번 실행돼도 문제없으려면 cleanup을 반드시 구현해야 합니다.

📖 핵심 개념 5 — useLayoutEffect vs useEffect

// useEffect: 화면 그림 이후 비동기 실행 (일반적으로 사용)
useEffect(() => {
  // DOM이 화면에 표시된 후 실행
  // 깜빡임이 발생할 수 있지만, 페인팅을 차단하지 않음
}, []);

// useLayoutEffect: 화면 그림 전 동기 실행
// DOM을 측정하거나, 화면 표시 전에 DOM을 조작할 때 사용
useLayoutEffect(() => {
  // DOM이 변경됐지만 브라우저가 아직 화면에 표시하기 전
  // 이 시점에 DOM을 읽고/쓰면 깜빡임 없음
  const rect = elementRef.current.getBoundingClientRect();
  // 위치 기반 스타일 조정 등
}, []);

// 규칙: useEffect를 먼저 써보고,
// 화면 깜빡임이 보이면 그때 useLayoutEffect로 교체

⚠️ 흔한 실수 (よくあるミス)

  • 의존성 배열 누락: eslint-plugin-react-hooksexhaustive-deps 규칙을 활성화하면 누락된 의존성을 자동으로 경고해줍니다. 이 경고를 무시하면 stale closure 버그가 발생합니다.
  • useEffect 안에서 직접 async 함수 사용: useEffect(async () => {...})는 금지입니다. async 함수는 Promise를 반환하는데, useEffect의 반환값은 cleanup 함수여야 합니다. 내부에 async 함수를 선언하고 호출하세요.
  • cleanup 없이 구독/타이머/이벤트 등록: 메모리 누수와 StrictMode 이중 등록 버그의 가장 흔한 원인입니다.
  • 객체/배열을 의존성으로 사용: 매 렌더링마다 새 참조가 생성되어 무한 루프가 발생합니다. 원시값(string, number, boolean)을 의존성으로 사용하거나 useMemo로 메모이제이션하세요.

💡 실무 팁

  • eslint-plugin-react-hooks 필수 설정: Vite 프로젝트 생성 시 포함되어 있습니다. react-hooks/exhaustive-deps 규칙을 error 레벨로 설정하세요.
  • 커스텀 훅으로 추출: useEffect 로직이 복잡해지면 useWindowSize, useLocalStorage 등 커스텀 훅으로 분리하면 재사용성과 가독성이 높아집니다 (07강에서 다룹니다).
  • API 호출은 React Query 사용 권장: useEffect로 fetch하는 패턴은 캐싱, 로딩 상태, 에러 처리, 재시도 등을 직접 구현해야 합니다. 실무에서는 TanStack Query(React Query)를 사용하는 것이 표준입니다.