본문 바로가기

useEffect 심화

의존성 배열과 cleanup의 실행 시점을 구분하고 로그·타이머·fetch 예제별 정리 범위와 예상 결과를 확인합니다.

앞 글에서는 useState의 심화 개념을 다루며 상태를 더 견고하고 효율적으로 관리하는 방법을 알아봤습니다. 이번에는 함수 컴포넌트의 부수 효과(side effect)를 다루는 useEffect가 작업을 시작하고 정리하는 시점을 자세히 살펴봅니다. 3장에서 useEffect의 기본 역할과 마운트, 업데이트, 언마운트 때의 동작을 간단히 살펴봤습니다.

입력이 바뀌면 이전에 예약한 타이머는 어떻게 될까요? 이 글에서는 의존성 배열(dependency array, deps 배열)에 따라 setup이 실행되는 시점과, 정리 함수(cleanup)가 실제로 정리하는 범위를 구분합니다. 로그만 남기는 예제, 대기 중인 타이머를 해제하는 예제, 진행 중인 fetch를 중단하는 발췌가 서로 다른 동작이라는 점도 확인합니다.


useEffect의 핵심: 의존성 배열 (deps 배열)

useEffect의 두 번째 인자인 의존성 배열은 useEffect의 동작을 정하는 가장 중요한 요소입니다. 이 배열에 어떤 값을 넣느냐에 따라 useEffect의 콜백이 언제 다시 실행될지가 정해집니다.

useEffect(() => {
  // 이펙트 콜백 함수: 부수 효과 로직
  console.log('이펙트 실행!');

  return () => {
    // 클린업 함수: 부수 효과 정리 로직
    console.log('클린업 실행!');
  };
}, [dep1, dep2, ...]); // 의존성 배열 (deps)

deps 배열에 있는 값 가운데 하나라도 이전 렌더 이후 바뀌었다면 useEffect의 콜백이 다시 실행됩니다. 즉 deps 배열은 React에게 이 Effect가 기대는 값이 여기 있으니 이 값이 바뀔 때만 다시 실행하라고 알려 주는 역할을 합니다.

deps 배열이 없는 경우

deps 배열을 아예 생략하면 useEffect는 렌더 결과가 커밋될 때마다 실행됩니다. 모든 렌더링마다 어떤 로직을 실행해야 하는 드문 경우에 씁니다. 이때 Effect 안에서 state를 바로 바꾸면 무한 루프에 빠질 수 있습니다. 예를 들어 setState(newValue)를 바로 호출하면, state가 바뀌고, 다시 렌더링되고, useEffect가 다시 실행되어 또 state를 바꾸는 일이 끝없이 되풀이됩니다.

빈 deps 배열

deps 배열로 빈 배열([])을 넘기면, useEffect는 일반적인 첫 마운트 때 setup을 실행하고, 이후의 렌더링에서는 의존하는 값의 변화로 다시 실행되지 않습니다. 클래스 컴포넌트의 componentDidMount와 비슷합니다. 다만 개발 모드의 Strict Mode는 setup → cleanup → setup의 추가 점검을 할 수 있으므로, 이 규칙과 개발 환경의 점검은 구별해야 합니다. 빈 배열은 초기 데이터를 불러오거나, 이벤트 리스너나 구독을 등록하거나(정리 함수에서 해제해야 합니다), 차트 라이브러리를 초기화하는 것처럼 DOM을 직접 다룰 때 씁니다.

사용자 정보를 처음에 불러오는 예제를 봅니다. src/components/UserFetcher.jsx를 만듭니다. 이 예제는 실제 HTTP 요청 없이 Promise 안의 타이머로 1.5초 기다린 뒤 고정된 사용자를 보여 줍니다. 정리 함수는 로그만 남길 뿐 타이머나 Promise를 취소하지 않고, 늦게 도착한 결과를 무시하는 장치도 없습니다.

src/components/UserFetcher.jsx
import React, { useState, useEffect } from 'react';

function UserFetcher() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log('UserFetcher setup: 사용자 정보 로딩을 시작합니다.');
    setLoading(true);

    const fetchUser = async () => {
      try {
        // 실제 HTTP 요청 없이 지연 Promise로 고정 사용자를 표시합니다.
        await new Promise(resolve => setTimeout(resolve, 1500)); // 1.5초 지연
        const fetchedUser = { id: 1, name: '김리액트', email: '[email protected]' };
        setUser(fetchedUser);
        setError(null);
      } catch (err) {
        setError('사용자 정보를 불러오는 데 실패했습니다.');
        setUser(null);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // cleanup: 정리 시점을 로그로 확인합니다.
    return () => {
      console.log('UserFetcher cleanup: 정리 시점을 확인합니다.');
      // 로그만 남깁니다. 지연 Promise 취소나 늦은 결과 무시는 하지 않습니다.
    };
  }, []); // 일반 마운트 때 setup; 개발 Strict Mode에서는 추가 점검 가능

  if (loading) return <p style={{ color: '#555' }}>사용자 정보 로딩 중...</p>;
  if (error) return <p style={{ color: 'red' }}>에러: {error}</p>;

  return (
    <div style={{ border: '1px solid #673AB7', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>사용자 프로필</h2>
      {user && (
        <div>
          <p>이름: {user.name}</p>
          <p>이메일: {user.email}</p>
        </div>
      )}
    </div>
  );
}

export default UserFetcher;

화면은 처음에 loading이 true이므로 사용자 정보 로딩 중...을 보여 주고, 1.5초 뒤 setUser와 setLoading(false)가 반영되면 이름과 이메일이 담긴 프로필로 바뀝니다. useEffect의 콜백은 정리 함수나 아무것도 돌려주지 않아야 하므로 async로 만들 수 없습니다. 그래서 안에서 fetchUser라는 비동기 함수를 따로 정의하고 바로 호출했습니다.

특정 값들을 deps 배열에 포함하는 경우

가장 흔한 사용 방식입니다. deps 배열에 넣은 값(prop, state, 함수 등) 가운데 하나라도 바뀔 때마다 useEffect의 콜백이 다시 실행되며, 클래스 컴포넌트의 componentDidUpdate와 비슷합니다. 검색어가 바뀔 때마다 검색 결과를 다시 불러오는 것처럼, 특정 prop이나 state가 바뀔 때마다 데이터를 다시 불러오거나 다른 부수 효과를 실행해야 할 때 씁니다.

검색어에 따라 결과를 보여 주는 예제를 봅니다. src/components/SearchComponent.jsx를 만듭니다. 검색어가 두 글자 이상이면 500ms 뒤에 그 검색어를 넣은 고정 문자열을 만들고, 검색어가 다시 바뀌면 이전 setup의 정리 함수가 먼저 실행되어 아직 대기 중인 타이머를 해제합니다. 실제 HTTP 검색이나 서버의 작업을 취소하는 예제는 아닙니다.

src/components/SearchComponent.jsx
import React, { useState, useEffect } from 'react';

function SearchComponent() {
  const [searchTerm, setSearchTerm] = useState('');
  const [searchResults, setSearchResults] = useState([]);
  const [loading, setLoading] = useState(false);

  // (1) searchTerm이 변경될 때마다 이펙트 실행
  useEffect(() => {
    if (searchTerm.length < 2) { // 2글자 미만일 땐 검색하지 않음
      setSearchResults([]);
      setLoading(false);
      return;
    }

    setLoading(true);
    console.log(`"${searchTerm}" 검색 타이머를 설정했습니다.`);

    // 입력이 멈춘 뒤 고정 검색 결과를 만듭니다.
    const timer = setTimeout(() => { // 마지막 입력 뒤 0.5초 기다리는 디바운싱
      const mockResults = [
        `검색 결과: ${searchTerm} - 아이템 A`,
        `검색 결과: ${searchTerm} - 아이템 B`,
        `검색 결과: ${searchTerm} - 아이템 C`,
      ].filter(item => item.includes(searchTerm)); // 고정 문자열에 검색어가 포함되어 있습니다.

      setSearchResults(mockResults);
      setLoading(false);
      console.log(`"${searchTerm}" 검색 결과를 만들었습니다.`);
    }, 500);

    // ⭐️ (2) 클린업 함수: 이전 타이머 해제
    return () => {
      console.log(`"${searchTerm}" 이전 검색 타이머 정리.`);
      clearTimeout(timer); // 아직 실행되지 않은 타이머 콜백만 막습니다.
    };
  }, [searchTerm]); // ⭐️ searchTerm이 변경될 때마다 이펙트 재실행

  return (
    <div style={{ border: '1px solid #FFC107', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>실시간 검색</h2>
      <input
        type="text"
        placeholder="검색어를 입력하세요..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        style={{ padding: '8px', fontSize: '16px', borderRadius: '5px', border: '1px solid #ddd', width: '80%', marginBottom: '15px' }}
      />
      {loading && <p>검색 중...</p>}
      {!loading && searchResults.length === 0 && searchTerm.length >= 2 && <p>일치하는 결과가 없습니다.</p>}
      {!loading && searchResults.length > 0 && (
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {searchResults.map((result, index) => (
            <li key={index} style={{ padding: '5px 0', borderBottom: '1px dotted #eee' }}>{result}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default SearchComponent;

deps 배열에 [searchTerm]을 넣었으므로 searchTerm이 바뀔 때마다 useEffect가 다시 실행됩니다. 이 예제처럼 입력이 이어지는 동안 기다렸다가 마지막 입력 뒤 0.5초가 지나야 작업하는 방식을 디바운싱(debouncing)이라고 하며, 여기서는 고정 결과를 만드는 타이머에 적용했습니다. 예를 들어 “리액트”를 입력하고 0.5초가 지나면 “검색 결과: 리액트 - 아이템 A”부터 C까지 세 줄이 나타납니다. clearTimeout은 아직 실행되지 않은 콜백만 막을 뿐, 이미 만들어진 결과나 서버의 작업을 되돌리지는 않습니다.


useEffect의 클린업(Clean-up) 함수 심화

정리 함수는 useEffect의 콜백이 돌려주는 함수로, 자원을 정리하는 일을 합니다. setup이 무언가를 시작했다면 정리 함수는 그 일을 끝내는 짝입니다. 타이머를 설정했다면 해제하고, 리스너를 붙였다면 떼고, 구독했다면 해제하는 식으로 setup과 정리 함수를 한 쌍으로 작성합니다. 아래에서는 이 함수가 언제 실행되는지와, 자주 쓰는 정리 작업의 예를 차례로 봅니다.

클린업이 실행되는 시점

정리 함수는 세 가지 경우에 실행됩니다. 첫째, 컴포넌트가 언마운트될 때입니다. 의존성 배열이 있든 없든 컴포넌트가 화면에서 없어지면 마지막 setup이 돌려준 정리 함수를 실행하며, 그 setup이 정리 함수를 돌려주지 않았다면 실행할 것도 없습니다. 둘째, 다음 setup이 실행되기 전입니다. 의존하는 값이 바뀌어 Effect를 다시 실행할 때는 이전 setup의 정리 함수를 먼저 실행하며, 의존성 배열을 생략해 커밋마다 실행하는 경우에도 이전 정리 뒤에 새 setup이 이어집니다. SearchComponent의 clearTimeout(timer)가 이 순서를 따릅니다.

셋째, 개발 모드의 Strict Mode 추가 점검입니다. 이 점검이 적용되면 setup → cleanup → setup의 순서를 한 번 더 거칩니다. 그러니 정리 로그가 보였다고 실제로 컴포넌트를 숨겼다고 단정할 수 없으며, 이 점검은 프로덕션에서 더 실행되는 규칙도 아닙니다.

Effect는 이전 연결을 닫은 뒤 새 연결을 연다 — 상태 추적

room-1과 room-2 연결에서 새 commit 뒤 이전 cleanup→새 setup, unmount 마지막 cleanup으로 자원 상태가 달라진다. 개발 Strict Mode 점검과 실제 제거를 구분한다.

새 setup 전에 이전 Effect가 만든 자원을 정리합니다

EFFECT: REPLACE A RESOURCE

새 setup 전에 이전 Effect가 만든 자원을 정리합니다

구독 room-1→room-2는 정리 순서를 설명하는 예입니다. 의존성이 달라진 커밋 뒤 cleanup(room-1)이 먼저 실행되고 setup(room-2)이 이어집니다. 본문의 SearchComponent는 실제 HTTP 요청이 아니라 아직 대기 중인 타이머만 해제합니다. UserFetcher의 로그 전용 cleanup과는 정리 범위가 다릅니다.

Effect는 이전 연결을 닫은 뒤 새 연결을 연다roomId 1 연결의 setup 이후 roomId가 2로 바뀌면 이전 연결 cleanup이 먼저 실행되고 새 연결 setup이 실행되며, unmount에서 마지막 연결을 정리하는 순서.
첫 setuproom-1 연결
연결 관계첫 setup · room-1 연결 → 새 값의 커밋 · roomId = 2
새 값의 커밋roomId = 2
연결 관계새 값의 커밋 · roomId = 2 → 이전 cleanup · room-1 종료
이전 cleanuproom-1 종료
연결 관계이전 cleanup · room-1 종료 → 새 setup · room-2 연결
새 setuproom-2 연결
연결 관계새 setup · room-2 연결 → 언마운트 cleanup · room-2 종료
언마운트 cleanuproom-2 종료
연결 관계언마운트 cleanup · room-2 종료 → 정리 결과 · 연결 없음
정리 결과연결 없음

SearchComponent · [searchTerm]

검색어 A: 500ms 타이머 설정
검색어 B: A 타이머 clearTimeout
          B 타이머 설정
unmount: 마지막 타이머 해제

2글자 미만이면 이전 대기 타이머를 정리하고 결과를 비우며 새 타이머는 만들지 않습니다. 이미 콜백이 실행한 뒤의 결과까지 되돌리는 취소는 아닙니다.

비동기 작업마다 다른 정리

UserFetcher의 cleanup은 로그만 남겨 1.5초 지연 Promise가 계속되며 늦은 결과 guard도 없습니다. fetch 발췌는 실제 endpoint와 의존 값이 필요합니다. AbortController는 진행 중 fetch·응답 읽기를 중단하지만 서버의 완료 작업이나 이미 반영한 화면을 되돌리지 않습니다.

Effect에서 읽는 반응형 값을 deps에 포함합니다. cleanup은 의존 값이 바뀌어 다음 setup을 실행하기 전과 마지막 unmount 때 실행합니다. 개발 Strict Mode의 추가 setup→cleanup→setup은 별도 점검입니다. cleanup 로그만으로 타이머·Promise가 취소되었다고 판단하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
room 1 setupresource: room 1 열림 / input: roomId 1roomId 1의 연결을 연다Effect 자원은 해당 setup의 소유다
room 2 commit과 교체room 1: 종료 / room 2: 열림새 값 commit 뒤 room 1 cleanup을 하고 room 2 setup을 한다이전 자원을 먼저 정리한다
unmountresource: 없음 / component: 트리 제거마지막 room 2 cleanup으로 연결이 없다Strict Mode의 개발 점검 cleanup은 실제 제거와 별개다

Effect는 이전 연결을 닫은 뒤 새 연결을 연다 — 상태 추적

room-1과 room-2 연결에서 새 commit 뒤 이전 cleanup→새 setup, unmount 마지막 cleanup으로 자원 상태가 달라진다. 개발 Strict Mode 점검과 실제 제거를 구분한다.

새 setup 전에 이전 Effect가 만든 자원을 정리합니다

EFFECT: REPLACE A RESOURCE

새 setup 전에 이전 Effect가 만든 자원을 정리합니다

구독 room-1→room-2는 정리 순서를 설명하는 예입니다. 의존성이 달라진 커밋 뒤 cleanup(room-1)이 먼저 실행되고 setup(room-2)이 이어집니다. 본문의 SearchComponent는 실제 HTTP 요청이 아니라 아직 대기 중인 타이머만 해제합니다. UserFetcher의 로그 전용 cleanup과는 정리 범위가 다릅니다.

Effect는 이전 연결을 닫은 뒤 새 연결을 연다roomId 1 연결의 setup 이후 roomId가 2로 바뀌면 이전 연결 cleanup이 먼저 실행되고 새 연결 setup이 실행되며, unmount에서 마지막 연결을 정리하는 순서.
첫 setuproom-1 연결
연결 관계첫 setup · room-1 연결 → 새 값의 커밋 · roomId = 2
새 값의 커밋roomId = 2
연결 관계새 값의 커밋 · roomId = 2 → 이전 cleanup · room-1 종료
이전 cleanuproom-1 종료
연결 관계이전 cleanup · room-1 종료 → 새 setup · room-2 연결
새 setuproom-2 연결
연결 관계새 setup · room-2 연결 → 언마운트 cleanup · room-2 종료
언마운트 cleanuproom-2 종료
연결 관계언마운트 cleanup · room-2 종료 → 정리 결과 · 연결 없음
정리 결과연결 없음

SearchComponent · [searchTerm]

검색어 A: 500ms 타이머 설정
검색어 B: A 타이머 clearTimeout
          B 타이머 설정
unmount: 마지막 타이머 해제

2글자 미만이면 이전 대기 타이머를 정리하고 결과를 비우며 새 타이머는 만들지 않습니다. 이미 콜백이 실행한 뒤의 결과까지 되돌리는 취소는 아닙니다.

비동기 작업마다 다른 정리

UserFetcher의 cleanup은 로그만 남겨 1.5초 지연 Promise가 계속되며 늦은 결과 guard도 없습니다. fetch 발췌는 실제 endpoint와 의존 값이 필요합니다. AbortController는 진행 중 fetch·응답 읽기를 중단하지만 서버의 완료 작업이나 이미 반영한 화면을 되돌리지 않습니다.

Effect에서 읽는 반응형 값을 deps에 포함합니다. cleanup은 의존 값이 바뀌어 다음 setup을 실행하기 전과 마지막 unmount 때 실행합니다. 개발 Strict Mode의 추가 setup→cleanup→setup은 별도 점검입니다. cleanup 로그만으로 타이머·Promise가 취소되었다고 판단하지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
room 1 setupresource: room 1 열림 / input: roomId 1roomId 1의 연결을 연다Effect 자원은 해당 setup의 소유다
room 2 commit과 교체room 1: 종료 / room 2: 열림새 값 commit 뒤 room 1 cleanup을 하고 room 2 setup을 한다이전 자원을 먼저 정리한다
unmountresource: 없음 / component: 트리 제거마지막 room 2 cleanup으로 연결이 없다Strict Mode의 개발 점검 cleanup은 실제 제거와 별개다

위 그림은 Effect가 이전 연결을 닫은 뒤 새 연결을 여는 순서를 채팅방 구독의 예로 보여 줍니다. 이 구독 예는 본문의 코드에는 없고, 정리 순서를 설명하려고 그림에서 든 예입니다. 첫 setup이 room-1에 연결한 뒤 roomId가 2로 바뀐 커밋이 일어나면, 먼저 이전 정리 함수가 room-1의 연결을 끊고 이어서 새 setup이 room-2에 연결합니다. 컴포넌트가 언마운트되면 마지막 정리 함수가 room-2의 연결을 끊어 남은 연결이 없게 됩니다.

그림의 아래 칸은 본문의 예제에 같은 순서를 적용합니다. SearchComponent에서는 검색어 A가 500ms 타이머를 설정하고, 검색어 B로 바뀌면 A의 타이머를 clearTimeout으로 해제한 뒤 B의 타이머를 설정하며, 언마운트될 때 마지막 타이머를 해제합니다. 두 글자 미만이면 이전 대기 타이머를 정리하고 결과를 비우며 새 타이머는 만들지 않지만, 이미 콜백이 실행된 뒤의 결과까지 되돌리지는 않습니다. 반면 UserFetcher의 정리 함수는 로그만 남기므로 1.5초 지연 Promise는 계속 진행되고, 아래에서 볼 fetch 발췌의 AbortController는 진행 중인 fetch와 응답 읽기를 중단하지만 서버에서 끝난 작업이나 이미 반영한 화면을 되돌리지는 않습니다.

그림의 마지막 칸이 정리하듯, Effect에서 읽는 반응형 값은 deps에 넣고, 정리 함수는 의존하는 값이 바뀌어 다음 setup을 실행하기 전과 마지막 언마운트 때 실행됩니다. 정리 로그만 보고 타이머나 Promise가 취소되었다고 판단해서는 안 됩니다.

클린업의 중요성 및 사용 사례

setup에서 연 구독이나 리스너가 필요 없어졌는데도 남아 있으면, 같은 동작이 겹치거나 메모리가 새는 문제로 이어질 수 있습니다. 정리 함수에는 그 자원을 정리하는 동작을 직접 써야 합니다. 함수를 돌려줬다는 사실만으로 타이머나 Promise, 요청이 저절로 취소되지는 않습니다. 자주 쓰는 정리 작업은 다음과 같습니다.

먼저 타이머 해제입니다. setTimeout이나 setInterval로 타이머를 설정했다면, 컴포넌트가 사라질 때 타이머도 함께 멈춰야 합니다.

useEffect(() => {
  const timer = setInterval(() => console.log('tick'), 1000);
  return () => clearInterval(timer); // 타이머 해제
}, []);

다음은 이벤트 리스너 제거입니다. window나 document, 특정 DOM 요소에 이벤트 리스너를 직접 붙였다면, 컴포넌트가 언마운트될 때 그 리스너를 떼어야 합니다.

useEffect(() => {
  const handleResize = () => console.log('창 크기 변경');
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize); // 리스너 제거
}, []);

removeEventListener에는 등록할 때와 같은 함수 참조를 넘겨야 리스너가 떨어지므로, 처리 함수를 handleResize 변수에 담아 두 곳에서 함께 씁니다. WebSocket이나 RxJS 같은 외부 라이브러리로 데이터 스트림을 구독했다면, 더 이상 필요 없을 때 구독을 해제해야 합니다. 아래 someService는 구독 객체를 돌려주는 가상의 서비스이며, 실제 라이브러리에서는 해제 메서드의 이름이 다를 수 있습니다.

useEffect(() => {
  const subscription = someService.subscribe(data => console.log(data));
  return () => subscription.unsubscribe(); // 구독 해제
}, []);

마지막으로 진행 중인 fetch의 중단입니다. AbortController는 진행 중인 fetch와 응답 본문의 읽기를 중단할 때 쓰며, signal을 fetch에 넘기는 기본 사용법은 Fetch API와 AJAX에서 다룹니다. 이미 서버에 전달된 작업이나 화면에 반영한 결과를 되돌리지는 않으며, SearchComponent의 대기 타이머 해제와도 다른 동작입니다. 아래 코드는 참고용 발췌로, 실제 /api/data 엔드포인트와 someDependency의 정의, 응답 상태별 처리와 결과를 쓸 문맥이 있어야 동작합니다. 이 글은 그 API 서버를 제공하지 않으므로, 이 코드를 독립된 App 예제로 붙여 넣어 요청이 성공하는지 확인할 수는 없습니다.

// 예시: 실제 endpoint와 의존 값이 필요한 fetch 발췌
useEffect(() => {
  const controller = new AbortController();
  const signal = controller.signal;

  fetch('/api/data', { signal })
    .then(response => response.json())
    .then(data => console.log(data))
    .catch(error => {
      if (error.name === 'AbortError') {
        console.log('Fetch aborted');
      } else {
        console.error('Fetch error:', error);
      }
    });

  return () => {
    controller.abort(); // 다음 setup 전 또는 언마운트 때 진행 중 fetch를 중단
  };
}, [someDependency]);

정리 함수에서 controller.abort()를 부르면 진행 중인 fetch의 Promise가 AbortError로 거부되므로, catch에서 이 오류를 일반 오류와 구분합니다. 이 구분이 없으면 의존 값이 바뀔 때마다 의도한 중단이 오류 로그로 남습니다.


두 컴포넌트의 동작 확인 (App.jsx)

프로젝트의 src/App.jsx에서 위의 두 컴포넌트를 불러옵니다. fetch 발췌나 구독 발췌를 App에 더하는 단계는 아닙니다. 화면과 Console을 함께 보며 아래 조건마다 무엇이 정리되는지 확인해 보세요.

src/App.jsx
import React, { useState } from 'react';
import './App.css';
import UserFetcher from './components/UserFetcher';
import SearchComponent from './components/SearchComponent';

function App() {
  const [showUserFetcher, setShowUserFetcher] = useState(true);

  return (
    <div className="App">
      <h1>useEffect 심화 학습</h1>

      <button onClick={() => setShowUserFetcher(!showUserFetcher)} style={{ margin: '10px', padding: '10px 20px' }}>
        UserFetcher {showUserFetcher ? '숨기기' : '보이기'}
      </button>
      {showUserFetcher && <UserFetcher />} {/* 조건부 렌더링으로 마운트/언마운트 테스트 */}

      <hr />

      <SearchComponent />

    </div>
  );
}

export default App;

일반적인 마운트와 개발 환경의 추가 점검을 구분하며 다음 순서로 확인합니다. 타이머의 시간은 예약한 지연이며, 실제 콜백은 브라우저의 작업 상황에 따라 더 늦게 실행될 수 있습니다.

조건과 행동코드로 예상할 결과다를 때 먼저 확인할 곳
UserFetcher를 계속 표시setup 로그 뒤 1.5초를 기다려 고정 사용자 “김리액트”를 표시로딩 상태와 fetchedUser의 고정 값
UserFetcher를 1.5초 전에 숨기기정리 로그와 함께 화면에서 사라지며, 예약한 Promise 흐름은 계속되고 취소되지 않음정리 함수에 로그 말고 타이머 해제나 늦은 결과 무시 장치가 있는지
두 글자 이상의 검색어를 넣고 500ms 전에 다른 두 글자 이상의 검색어로 바꾸기아직 대기 중인 이전 타이머를 정리한 뒤 새 타이머를 설정하고, 마지막 검색어의 고정 결과를 표시clearTimeout(timer)와 Console의 설정·정리·결과 로그
검색어를 두 글자 미만으로 줄이기이전 대기 타이머를 정리하고 결과와 로딩 상태를 비우며, 새 타이머는 만들지 않음길이 검사 뒤의 이른 return
개발 모드의 Strict Mode 추가 점검이 적용됨setup → cleanup → setup 로그가 더 보일 수 있으며, UserFetcher의 지연 작업은 정리 함수에서 취소되지 않음프로젝트에서 Strict Mode를 적용한 위치와 각 정리 함수의 실제 코드

이전 타이머의 콜백이 이미 실행된 뒤라면 clearTimeout으로 그 결과를 되돌릴 수 없습니다. 또 UserFetcher는 고정 값이 같아 화면만으로는 중복 예약을 구별하기 어렵습니다. 정리 로그가 보였다는 사실과 실제로 자원을 정리했다는 사실을 구분해 주세요.


의존성은 무엇과 동기화하는지 드러냅니다

React는 의존성의 각 항목을 이전 렌더의 항목과 Object.is로 비교합니다. 같은 문자열이면 그 값 때문에 다시 동기화하지 않지만, 렌더마다 새로 만든 객체는 내용이 같아도 참조가 달라 매번 다시 실행됩니다. 실행 횟수를 줄이려고 Effect가 실제로 읽는 반응형 값을 배열에서 빼면 오래된 값을 쓰게 될 수 있으므로, 먼저 무엇과 동기화하는지를 분명히 합니다(React 문서의 반응형 의존성 지정). 의존성 배열은 실행 횟수를 줄이는 장치가 아니라, Effect가 어떤 값과 동기화되는지를 드러내는 선언입니다.

다른 코드를 읽을 때도 setup이 시작한 작업과 정리 함수가 실제로 끝내는 작업을 짝지어 보세요. 로그만 남기는 함수와 자원을 해제하는 함수는 결과가 다릅니다.

더 깊이: 읽지만 다시 동기화할 이유는 아닌 값

Effect 안에서 읽는 값이 모두 다시 연결할 이유인 것은 아닙니다. 채팅방에 연결된 순간 알림을 띄우는 Effect가 알림의 색을 정하는 theme까지 읽으면, 의존성 규칙에 따라 theme이 바뀔 때마다 연결을 끊고 다시 맺게 됩니다. roomId는 연결 자체에 쓰이는 값이지만 theme은 연결된 뒤에 일어난 일을 처리할 때 읽을 뿐입니다. 그렇다고 린트 규칙을 끄고 theme을 배열에서 빼면, 나중에 Effect를 고칠 때 린터가 빠진 의존성을 알려 주지 못해 버그가 생기기 쉽습니다.

React 19.2에 정식으로 들어온 useEffectEvent는 이런 부분을 Effect 이벤트(Effect Event)라는 함수로 떼어 냅니다. const onConnected = useEffectEvent(() => showNotification('Connected!', theme))처럼 만들고 Effect 안에서 onConnected()를 부르면, 이 함수는 DOM 이벤트 처리 함수처럼 언제나 최신 props와 state를 읽지만 의존성 배열에는 넣지 않습니다. 대신 Effect 이벤트는 그것을 쓰는 Effect와 같은 컴포넌트나 훅 안에서만 선언해야 하고, ESLint로 설정한 프로젝트라면 린터가 이를 확인하도록 eslint-plugin-react-hooks를 최신 버전으로 올려야 합니다(React 19.2 발표 글). 개발 환경 설정의 명령으로 만든 Oxlint 템플릿에서는 .oxlintrc.json에 켜 둔 react/rules-of-hooks 규칙이 Effect 이벤트를 다른 컴포넌트로 넘기거나 Effect 밖에서 부르는 코드를 알려 줍니다. 같은 글은 이 훅이 린트 오류를 숨기려고 아무 함수나 감싸는 도구가 아니라, Effect에서 일어나는 이벤트에만 쓰는 도구라고 강조합니다.


정리

  • 의존성 배열을 생략하면 커밋마다, 빈 배열이면 첫 마운트 때만, 값을 넣으면 그 값이 바뀔 때마다 setup이 실행됩니다.
  • 정리 함수는 다음 setup이 실행되기 전과 언마운트될 때 실행되며, 개발 모드의 Strict Mode는 setup → cleanup → setup 점검을 더할 수 있습니다.
  • 정리 함수를 돌려준다고 작업이 저절로 취소되지는 않으므로, clearTimeout, removeEventListener, unsubscribe, abort()처럼 정리 동작을 직접 씁니다.
  • clearTimeout은 아직 실행되지 않은 콜백만 막고, AbortController는 진행 중인 fetch만 중단하며, 둘 다 이미 끝난 결과는 되돌리지 않습니다.
  • 의존성은 Object.is로 비교하므로 렌더마다 새로 만든 객체는 매번 다시 실행을 일으키며, Effect가 읽는 반응형 값은 배열에서 빼지 않습니다.

연습 문제

1. 대기 타이머 시간표

SearchComponent의 길이 검사와 타이머를 함께 읽어 봅니다. 아래 시각은 각 Effect의 setup이 실행된 때로 가정하며, 500ms가 되기 전에 정리한 타이머의 콜백은 아직 실행되지 않았다고 가정합니다. 처음 setup 시점을 0ms로 두고 검색어를 “자바”로 입력한 뒤, 200ms에 “타입”으로, 600ms에 “타”로 바꿔 각각 다음 setup이 실행된다고 할 때, 타이머의 예약 시각과 해제 대상, 마지막 결과와 로딩 상태를 적어 보세요.

해설 보기

첫 타이머는 500ms에 실행되도록 예약되지만, 200ms의 정리 함수에서 해제됩니다. 두 번째 타이머는 700ms에 실행되도록 예약되지만, 600ms의 정리 함수에서 해제됩니다. 마지막 setup은 검색어가 한 글자이므로 결과를 빈 배열로, 로딩을 false로 바꾸고 새 타이머를 만들지 않습니다.

이 시간표에서는 두 콜백 모두 실행되지 않습니다. 실제 브라우저에서 입력과 렌더, Effect가 정확히 이 시각에 실행된다는 뜻은 아니며, 이미 실행된 결과는 타이머를 해제해도 되돌릴 수 없습니다.

2. 로그만 남기는 정리 함수

UserFetcher를 화면에 띄운 뒤 1초 만에 숨겼다가, 바로 다시 보이게 합니다. 처음 마운트의 지연 Promise는 어떻게 될까요? 다시 나타난 UserFetcher는 무엇을 보여 줄까요?

해설 보기

정리 함수가 로그만 남기므로 처음 마운트의 지연 Promise는 취소되지 않고 계속 진행되어, 0.5초 뒤에 이미 사라진 컴포넌트의 setUser를 호출합니다. 이 호출은 화면에 아무 영향을 주지 않습니다.

다시 나타난 UserFetcher는 새로 마운트된 컴포넌트라서 loading이 true인 초기 상태에서 시작해 “사용자 정보 로딩 중...”을 보여 주고, 자기 setup이 예약한 1.5초가 지난 뒤 사용자 정보를 보여 줍니다. 이 예제는 고정 값을 쓰므로 화면만으로는 늦은 결과를 구별할 수 없지만, 실제 요청이라면 늦게 도착한 결과를 무시하거나 요청을 중단하는 장치가 필요합니다.

3. 렌더마다 새로 만든 객체

SearchComponent 안에서 const options = { term: searchTerm };을 만들고, Effect가 options.term을 읽으며 의존성 배열을 [options]로 바꿨다고 가정합니다. 로딩 상태만 바뀌어 다시 렌더링될 때 Effect는 어떻게 될까요?

해설 보기

options는 렌더마다 새로 만들어지는 객체라서, 내용이 같아도 Object.is 비교에서 이전 렌더의 객체와 다르다고 판단됩니다. 그래서 검색어가 그대로인데도 다시 렌더링될 때마다 정리 함수와 setup이 다시 실행되어, 대기 중이던 타이머를 해제하고 새로 예약합니다. 로딩이 false일 때 setup이 setLoading(true)를 부르면 렌더가 한 번 더 일어나, 방금 예약한 타이머도 해제하고 다시 예약합니다.

이미 true인 값을 다시 넣는 호출은 Object.is로 같으므로 React가 렌더링을 건너뛰고, 마지막 타이머는 500ms 뒤에 실행됩니다. 하지만 그 콜백의 setSearchResults와 setLoading(false)가 다시 렌더링을 일으켜 Effect가 또 실행되므로, 검색어가 그대로인데도 결과가 잠깐 보였다가 “검색 중...”으로 돌아가는 일이 0.5초마다 끝없이 되풀이됩니다. 검색어가 두 글자 미만일 때도 setup이 렌더마다 새 빈 배열을 setSearchResults에 넘기므로, 타이머 없이 렌더와 Effect가 끝없이 이어집니다.

Effect가 실제로 읽는 값은 searchTerm 문자열이므로, 의존성 배열은 원래대로 [searchTerm]으로 두는 것이 맞습니다.

다음 글 useContext에서는 useContext로 컴포넌트 트리 전체에서 데이터를 공유하는 방법을 다룹니다.