icon
4장 : React 훅 기초

useEffect 심화

지난 장에서는 useState 훅의 심화 개념들을 다루며 상태를 더욱 견고하고 효율적으로 관리하는 방법을 알아보았습니다. 이제 리액트 함수형 컴포넌트에서 부수 효과(Side Effect)를 다루는 핵심 훅인 useEffect에 대해 더 깊이 파고들 시간입니다.

우리는 이미 3장에서 useEffect의 기본적인 역할과 마운트/업데이트/언마운트 시점에서의 동작 방식을 간략하게 살펴보았습니다. 이번 장에서는 useEffect의존성 배열(deps 배열)의 역할과 중요성, 그리고 useEffect의 꽃이라고 할 수 있는 클린업(Clean-up) 함수의 다양한 활용 사례들을 실제 예제와 함께 자세히 다룰 것입니다.


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

useEffect 훅의 두 번째 인자인 의존성 배열(Dependency Array)useEffect의 동작 방식을 결정하는 가장 중요한 요소입니다. 이 배열에 어떤 값을 넣느냐에 따라 useEffect 콜백 함수가 언제 다시 실행될지가 결정됩니다.

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

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

deps 배열의 역할:

  • deps 배열에 있는 값들 중 어떤 하나라도 이전 렌더링 이후 변경되었다면, useEffect 콜백 함수가 다시 실행됩니다.
  • deps 배열은 리액트에게 "이 이펙트가 의존하는 값들은 여기에 있으니, 이 값들이 변할 때만 다시 실행해줘"라고 알려주는 역할을 합니다.

deps 배열이 없는 경우

deps 배열을 아예 생략하면, useEffect컴포넌트가 렌더링될 때마다 (모든 propsstate의 변경 포함) 실행됩니다.

사용 시점

  • 컴포넌트의 모든 렌더링 주기마다 특정 로직을 실행해야 할 때 (매우 드뭄).
  • 주의: 이 안에서 state를 직접 변경하면 무한 루프에 빠질 수 있습니다. 예를 들어, setState(newValue)를 직접 호출하면 state 변경 $\rightarrow$ 리렌더링 $\rightarrow$ useEffect 재실행 $\rightarrow$ state 변경 ... 무한 반복.

deps 배열

deps 배열을 빈 배열([]) 로 전달하면, useEffect는 컴포넌트가 처음 마운트(Mount)될 때 단 한 번만 실행됩니다. 이후 컴포넌트가 리렌더링되어도 다시 실행되지 않습니다. 이는 클래스 컴포넌트의 componentDidMount와 유사합니다.

사용 시점

  • 초기 데이터 로딩 (API 호출).
  • 이벤트 리스너 등록 (클린업에서 해제 필요).
  • 구독 설정 (클린업에서 해제 필요).
  • DOM 직접 조작 (차트 라이브러리 초기화 등).

예제: 사용자 정보 초기 로딩 (빈 deps 배열)

src/components/UserFetcher.js
// src/components/UserFetcher.js
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 컴포넌트가 마운트되었습니다. 사용자 정보 로딩 시작!');
    setLoading(true);

    const fetchUser = async () => {
      try {
        // (가상) API 호출 시뮬레이션
        await new Promise(resolve => setTimeout(resolve, 1500)); // 1.5초 지연
        const fetchedUser = { id: 1, name: '김리액트', email: 'react@example.com' };
        setUser(fetchedUser);
        setError(null);
      } catch (err) {
        setError('사용자 정보를 불러오는 데 실패했습니다.');
        setUser(null);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();

    // 클린업 함수: 컴포넌트 언마운트 시 실행될 로직
    return () => {
      console.log('UserFetcher 컴포넌트가 언마운트될 예정입니다. 정리 작업 수행.');
      // 이 경우 특별히 정리할 것은 없지만, 예를 들어 외부 구독을 해제하는 등의 작업을 할 수 있습니다.
    };
  }, []); // ⭐️ 빈 배열: 마운트 시 한 번만 실행

  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;

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

가장 일반적인 사용 패턴입니다. deps 배열에 포함된 값(prop, state, 함수 등) 중 하나라도 변경될 때마다 useEffect 콜백이 재실행됩니다. 이는 클래스 컴포넌트의 componentDidUpdate와 유사합니다.

사용 시점

  • 특정 prop이나 state가 변경될 때마다 데이터를 다시 로드해야 할 때 (예: 검색어 변경 시 검색 결과 업데이트).
  • 특정 prop이나 state에 따라 다른 부수 효과를 실행해야 할 때.

예제: 검색어에 따른 검색 결과 표시

src/components/SearchComponent.js
// src/components/SearchComponent.js
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([]);
      return;
    }

    setLoading(true);
    console.log(`"${searchTerm}"으로 검색 시작...`);

    // (가상) 검색 API 호출 시뮬레이션
    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;
  • [searchTerm]deps 배열에 넣었기 때문에 searchTerm state가 변경될 때마다 useEffect가 다시 실행됩니다.
  • setTimeoutclearTimeout을 활용하여 디바운싱(Debouncing) 을 구현했습니다. 사용자가 타이핑을 멈춘 후 0.5초가 지나야 실제 검색이 시작되도록 하여 불필요한 API 호출을 줄입니다. (이전 타이머를 clearTimeout으로 해제하는 것이 클린업의 중요한 역할 중 하나입니다.)

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

클린업 함수는 useEffect 콜백 함수가 반환하는 함수로, 리소스 정리 작업을 수행합니다.

클린업이 실행되는 시점

  1. 컴포넌트가 언마운트될 때: deps 배열이 빈 배열([])이거나, 컴포넌트가 화면에서 사라질 때 마지막으로 한 번 실행됩니다.
  2. 이전 이펙트가 다시 실행되기 직전: deps 배열에 값이 있고, 그 값이 변경되어 useEffect가 재실행될 때, 새로운 이펙트가 실행되기 전에 이전 이펙트의 클린업 함수가 먼저 실행됩니다. (위 SearchComponent 예시에서 clearTimeout(timer)가 이런 경우입니다.)

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

클린업 함수를 사용하는 주된 이유는 메모리 누수(Memory Leak) 를 방지하고, 불필요한 작업을 중단하여 애플리케이션의 성능과 안정성을 확보하기 위함입니다.

주요 클린업 사용 사례

  1. 타이머 해제: setTimeout, setInterval과 같은 타이머를 설정했을 때, 컴포넌트가 사라지면 타이머도 함께 멈춰야 합니다.
    useEffect(() => {
      const timer = setInterval(() => console.log('tick'), 1000);
      return () => clearInterval(timer); // 타이머 해제
    }, []);
  2. 이벤트 리스너 제거: window, document, 또는 특정 DOM 요소에 이벤트 리스너를 직접 추가했을 때, 컴포넌트가 언마운트되면 해당 리스너를 제거해야 합니다.
    useEffect(() => {
      const handleResize = () => console.log('창 크기 변경');
      window.addEventListener('resize', handleResize);
      return () => window.removeEventListener('resize', handleResize); // 리스너 제거
    }, []);
  3. 구독 해제: WebSocket, RxJS 등 외부 라이브러리를 통해 데이터 스트림을 구독했을 때, 더 이상 필요 없을 때 구독을 해제해야 합니다.
    useEffect(() => {
      const subscription = someService.subscribe(data => console.log(data));
      return () => subscription.unsubscribe(); // 구독 해제
    }, []);
  4. 진행 중인 비동기 요청 취소: SearchComponent 예제처럼 API 호출과 같은 비동기 작업이 이전 요청이 완료되기 전에 새로운 요청으로 대체되어야 할 때 이전 요청을 취소하여 불필요한 state 업데이트나 에러를 방지할 수 있습니다. (AbortController 사용 등)
    // 예시: API 요청 취소 (더 복잡한 구현 필요)
    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(); // 이펙트 재실행 또는 언마운트 시 요청 취소
      };
    }, [someDependency]);

모든 컴포넌트 한 곳에서 테스트 (App.js)

App.js 파일을 수정하여 위에서 만든 UserFetcherSearchComponent를 모두 불러와 렌더링하고, 콘솔을 보면서 useEffect의 동작 방식과 클린업 함수가 실행되는 시점을 직접 확인해 보세요. 특히 SearchComponent에서 빠르게 타이핑하면서 콘솔 메시지를 보면 클린업이 얼마나 중요한지 체감할 수 있을 것입니다.

src/App.js
// src/App.js
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의 '숨기기/보이기' 버튼을 클릭하면서 UserFetcher 컴포넌트의 마운트/언마운트 및 그에 따른 useEffect와 클린업의 실행을 콘솔에서 확인해 보세요. SearchComponent에서는 입력 필드에 빠르게 타이핑하면서 검색 요청이 어떻게 취소되고 새로 시작되는지 콘솔에서 확인해 보세요.


"useEffect 심화: 라이프사이클과 클린업"은 여기까지입니다. 이 장에서는 useEffect 훅의 의존성 배열(deps 배열)의 다양한 활용 패턴과 그 의미를 명확히 하고, 클린업 함수의 중요성 및 메모리 누수 방지를 위한 활용 사례들을 상세하게 다루었습니다.

이제 여러분은 컴포넌트의 생명 주기 동안 발생하는 복잡한 부수 효과들을 useEffect와 클린업 함수를 사용하여 안전하고 효율적으로 관리할 수 있게 되었습니다. 다음 장에서는 useRef 훅을 이용하여 DOM 요소에 직접 접근하는 방법과 그 활용 사례에 대해 알아보겠습니다.