Dev Thinking
32완료

Effect의 생명주기 관리 - 의존성 배열과 클린업 함수

2025-08-28
9분 읽기

리액트 공식문서 기반의 리액트 입문기

들어가며

이전 편들(5-3편, 5-4편)에서 우리는 useEffect 훅을 이용해 컴포넌트 내부에서 사이드 이펙트를 처리하는 방법과 의존성 배열의 기본적인 역할 및 useMemo를 활용한 성능 최적화 측면의 중요성을 간략히 살펴보았습니다. useEffect는 컴포넌트가 렌더링된 후에 특정 작업을 수행해야 할 때 유용하다는 것을 알 수 있었지만, 단순히 한 번 실행되고 끝나는 것이 아니라, 컴포넌트의 생명주기(Lifecycle)에 맞춰 실행되고 정리되는 과정을 가집니다.

이번 편에서는 useEffect의 생명주기를 더욱 깊이 있게 이해하고, 특정 상황에서만 Effect가 다시 실행되도록 제어하는 '의존성 배열(Dependency Array)'의 역할에 대해 이야기해보고자 합니다. 특히, Effect가 더 이상 필요하지 않을 때 불필요한 동작을 멈추거나 메모리 누수를 방지하기 위해 수행하는 '클린업(Cleanup) 함수'의 중요성도 함께 다루어볼 것입니다. 이 과정들을 통해 우리는 리액트 컴포넌트의 사이드 이펙트를 보다 안정적이고 효율적으로 관리하는 방법을 탐구할 수 있을 것 같습니다. 이를 바탕으로 리액트 애플리케이션의 성능과 안정성을 향상하는 데 도움이 될 수 있을 것입니다.

Effect의 생명주기 - 생명주기 이해와 의존성 배열

useEffect 훅은 컴포넌트가 렌더링된 후에 실행되는 사이드 이펙트를 다루며, 컴포넌트의 생명주기(Lifecycle)와 밀접하게 연관되어 있습니다. useEffect는 컴포넌트가 처음 화면에 나타날 때(마운트될 때) 실행되고, 특정 조건에 따라 컴포넌트가 업데이트될 때(다시 렌더링될 때) 재실행될 수 있으며, 컴포넌트가 화면에서 사라질 때(언마운트될 때) 정리됩니다. 이러한 일련의 과정들을 통해 Effect의 생명주기가 관리될 수 있습니다.

useEffect의 두 번째 인자로 전달하는 '의존성 배열(Dependency Array)'은 이러한 Effect의 생명주기 관리에 핵심적인 역할을 수행합니다. 이 배열에 어떤 값들을 넣느냐에 따라 Effect가 언제 다시 실행될지가 결정됩니다.

만약 의존성 배열을 비워두면([]), Effect는 컴포넌트가 처음 마운트될 때 단 한 번만 실행되고, 컴포넌트가 언마운트될 때 정리됩니다. 이는 한 번만 설정하고 계속 유지해야 하는 구독이나 이벤트 리스너 같은 경우에 유용하게 사용될 수 있습니다.

반대로 의존성 배열을 생략하면, Effect는 컴포넌트가 렌더링될 때마다 매번 다시 실행됩니다. 이는 성능 문제로 이어질 가능성이 있어 주의해야 합니다.

그리고 배열 안에 특정 값들을 넣어주면, 해당 값들이 변경될 때마다 Effect가 다시 실행됩니다. 예를 들어, 사용자 ID가 변경될 때마다 새로운 데이터를 불러와야 하는 상황이라면, 의존성 배열에 사용자 ID를 포함함으로써 필요한 시점에만 Effect가 동작하도록 만들 수 있습니다.

아래 예시 코드를 보면서 의존성 배열의 역할을 더 자세히 살펴보겠습니다.

import { useState, useEffect } from 'react';
 
function Timer() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    console.log('Effect가 실행되었습니다. (의존성 배열 없음)');
    // 이 Effect는 매 렌더링마다 실행됩니다.
  });
 
  useEffect(() => {
    console.log('Effect가 실행되었습니다. (빈 의존성 배열)');
    // 이 Effect는 컴포넌트가 마운트될 때 단 한 번만 실행됩니다.
  }, []);
 
  useEffect(() => {
    console.log('Effect가 실행되었습니다. (count 값 변경 감지)');
    // 이 Effect는 count 값이 변경될 때마다 실행됩니다.
  }, [count]);
 
  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}
 
export default Timer;

위 코드에서 첫 번째 useEffect는 의존성 배열이 없으므로, count 상태가 변경되어 컴포넌트가 리렌더링될 때마다 계속해서 실행됩니다. 두 번째 useEffect는 빈 배열([])을 가지고 있어, Timer 컴포넌트가 처음 화면에 나타날 때(마운트될 때)만 실행됩니다. 마지막 useEffect[count]를 의존성으로 가지고 있어, count 값이 바뀔 때마다 실행되는 것을 확인할 수 있습니다.

의존성 배열을 적절히 활용하는 것은 불필요한 Effect의 재실행을 방지하고, 리액트 애플리케이션의 성능을 최적화하며, 예상치 못한 버그를 줄이는 데 중요한 역할을 합니다. Effect가 언제 실행되어야 하는지에 대한 명확한 규칙을 제공하여 컴포넌트의 동작을 예측 가능하게 만드는 데 도움이 될 수 있습니다.

클린업 함수 - 메모리 누수 방지와 Effect 정리

useEffect는 단순히 사이드 이펙트를 실행하는 것뿐만 아니라, 필요할 때 이를 '정리(Cleanup)'할 수 있는 메커니즘을 제공합니다. Effect가 더 이상 필요하지 않게 되었을 때, 예를 들어 컴포넌트가 언마운트되거나 다음 Effect가 실행되기 전에 이전 Effect를 정리하는 작업이 중요한데, 이 역할을 하는 것이 바로 '클린업 함수(Cleanup Function)'입니다.

클린업 함수는 useEffect 콜백 함수가 반환하는 함수입니다. 이 클린업 함수는 다음과 같은 시점에 실행될 수 있습니다:

  • 컴포넌트가 언마운트될 때 (화면에서 사라질 때)
  • Effect의 의존성이 변경되어 Effect가 다시 실행되기 전 (이전 Effect를 정리하고 새로운 Effect를 실행하기 위함)

클린업 함수는 주로 다음과 같은 경우에 사용될 수 있습니다:

  • 구독 해제: 이벤트 리스너나 외부 데이터 소스 구독을 해제하여 메모리 누수를 방지합니다.
  • 타이머 해제: setTimeout이나 setInterval과 같은 타이머를 clearTimeout 또는 clearInterval로 해제합니다.
  • 생성된 리소스 정리: Effect 내부에서 생성한 DOM 요소나 네트워크 요청 등을 정리합니다.

클린업 함수를 사용하지 않으면, 컴포넌트가 사라진 후에도 이벤트 리스너가 계속 남아있거나 타이머가 계속 실행되어 메모리 누수나 예상치 못한 오류를 발생시킬 가능성이 있습니다. 아래 예시 코드를 통해 클린업 함수의 중요성을 살펴보겠습니다.

import { useState, useEffect } from 'react';
 
function Stopwatch() {
  const [seconds, setSeconds] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
 
  useEffect(() => {
    if (!isRunning) {
      return; // 실행 중이 아니면 아무것도 하지 않음
    }
 
    const intervalId = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);
 
    console.log('타이머가 시작되었습니다.');
 
    // 클린업 함수
    return () => {
      clearInterval(intervalId);
      console.log('타이머가 정리되었습니다.');
    };
  }, [isRunning]); // isRunning 값이 변경될 때마다 Effect를 재실행
 
  return (
    <div>
      <p>경과 시간: {seconds}초</p>
      <button onClick={() => setIsRunning(!isRunning)}>{isRunning ? '중지' : '시작'}</button>
      <button
        onClick={() => {
          setIsRunning(false);
          setSeconds(0);
        }}
      >
        초기화
      </button>
    </div>
  );
}
 
export default Stopwatch;

Stopwatch 컴포넌트는 isRunning 상태에 따라 타이머를 시작하거나 멈춥니다. useEffect 내부에서 setInterval을 이용해 seconds 상태를 1초마다 증가시키고 있습니다. 여기서 중요한 부분은 return () => { clearInterval(intervalId); ... }; 와 같이 클린업 함수를 반환한다는 점입니다.

isRunning 값이 false로 바뀌어 타이머가 중지되거나, Stopwatch 컴포넌트가 화면에서 사라질 때, 이 클린업 함수가 실행되어 clearInterval을 통해 이전에 설정된 setInterval을 해제합니다. 이를 통해 불필요한 타이머가 계속 작동하여 메모리나 CPU 자원을 낭비하는 것을 방지하고, 잠재적인 버그를 예방할 수 있습니다. 클린업 함수는 Effect의 생명주기를 안정적으로 관리하며, 특히 외부 시스템과의 동기화 작업에서 그 중요성이 더욱 부각될 수 있습니다.

Effect 분리하기 - 관심사별 Effect 관리

지금까지 useEffect의 생명주기와 의존성 배열, 그리고 클린업 함수의 중요성에 대해 살펴보았습니다. useEffect는 매우 강력한 도구이지만, 하나의 Effect 안에 너무 많은 로직을 포함하게 되면 코드가 복잡해지고 관리하기 어려워질 수 있습니다. 특히, 서로 다른 이유로 실행되어야 하는 로직들이 하나의 Effect에 섞여 있다면, 의존성 배열을 관리하는 것도 까다로워질 수 있습니다.

이러한 문제를 해결하기 위한 좋은 방법 중 하나는 '관심사별로 Effect를 분리'하는 것입니다. 즉, 각 useEffect 훅은 오직 하나의 독립적인 사이드 이펙트(그리고 그에 대한 정리 로직)만을 담당하도록 작성하는 것입니다. 이렇게 하면 각 Effect가 언제, 왜 실행되는지 명확하게 이해할 수 있고, 의존성 배열도 해당 Effect에 직접적으로 관련된 값들로만 구성할 수 있습니다.

예를 들어, 사용자 데이터를 불러오는 Effect와 채팅방에 연결하는 Effect, 그리고 문서의 제목을 업데이트하는 Effect가 있다고 가정해 봅시다. 이 세 가지를 하나의 useEffect에 넣기보다는 각각 별개의 useEffect로 분리하여 관리하는 것이 훨씬 효율적입니다.

아래 예시 코드를 통해 Effect를 분리하는 것이 왜 중요한지 살펴보겠습니다.

import { useState, useEffect } from 'react';
 
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  const [isOnline, setIsOnline] = useState(false);
 
  // 1. 채팅방 연결/해제 로직 (roomId에 의존)
  useEffect(() => {
    console.log(`채팅방 ${roomId}에 연결합니다.`);
 
    // 구독 로직
    const connection = {
      connect: () => console.log(`${roomId} 연결됨`),
      disconnect: () => console.log(`${roomId} 연결 해제됨`),
      on(event, callback) {
        if (event === 'status') {
          setTimeout(() => callback({ isOnline: true }), 1000); // 가상 온라인 상태
        }
      },
    };
    connection.connect();
 
    const handleStatusChange = status => setIsOnline(status.isOnline);
    connection.on('status', handleStatusChange);
 
    // 클린업 함수
    return () => {
      connection.disconnect();
      console.log(`채팅방 ${roomId}에서 연결을 해제합니다.`);
    };
  }, [roomId]); // roomId가 변경될 때마다 재실행
 
  // 2. 문서 제목 업데이트 로직 (roomId에 의존)
  useEffect(() => {
    document.title = `채팅방: ${roomId}`; // 문서 제목 업데이트
    console.log(`문서 제목이 '채팅방: ${roomId}'으로 업데이트되었습니다.`);
  }, [roomId]); // roomId가 변경될 때마다 재실행
 
  return (
    <div>
      <h2>{`채팅방: ${roomId}`}</h2>
      <p>온라인 상태: {isOnline ? '온라인' : '오프라인'}</p>
      <input
        value={message}
        onChange={e => setMessage(e.target.value)}
        placeholder="메시지를 입력하세요..."
      />
      <button onClick={() => console.log(`[${roomId}] 메시지 전송: ${message}`)}>전송</button>
    </div>
  );
}
 
export default function App() {
  const [showChat, setShowChat] = useState(false);
  const [roomId, setRoomId] = useState('general');
 
  return (
    <div>
      <button onClick={() => setShowChat(!showChat)}>
        {showChat ? '채팅방 닫기' : '채팅방 열기'}
      </button>
      {showChat && (
        <>
          <label>
            채팅방 선택:
            <select value={roomId} onChange={e => setRoomId(e.target.value)}>
              <option value="general">General</option>
              <option value="react">React</option>
              <option value="javascript">JavaScript</option>
            </select>
          </label>
          <ChatRoom roomId={roomId} />
        </>
      )}
    </div>
  );
}

위 코드에서 ChatRoom 컴포넌트는 두 개의 useEffect 훅을 사용합니다. 하나는 채팅방 연결 및 해제 로직을 담당하고, 다른 하나는 문서의 제목을 업데이트하는 로직을 담당합니다. 두 Effect 모두 roomId에 의존하지만, 각각 독립적인 관심사를 가지고 있으므로 별도의 useEffect로 분리하는 것이 좋습니다.

이렇게 Effect를 분리하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 가독성 향상: 각 Effect가 어떤 역할을 하는지 명확해집니다.
  2. 유지보수 용이: 특정 로직을 수정해야 할 때, 해당 Effect만 집중하여 작업할 수 있습니다.
  3. 의존성 관리 용이: 각 Effect의 의존성이 더욱 명확해져, 불필요한 재실행을 방지하고 성능 최적화에 도움이 될 수 있습니다.
  4. 재사용성: 나중에 커스텀 훅으로 추출하여 재사용할 가능성을 높여줄 수 있습니다.

Effect를 분리하는 것은 클린 코드 작성 원칙과도 일맥상통합니다. 각 Effect가 단일 책임 원칙(Single Responsibility Principle)을 따르도록 설계함으로써, 복잡한 컴포넌트에서도 사이드 이펙트를 체계적으로 관리하고 예측 가능한 동작을 보장하는 데 도움이 될 수 있습니다.

useEffect 훅의 특징

useEffect 훅은 리액트 컴포넌트의 생명주기와 외부 시스템과의 동기화를 관리하는 강력한 도구이며, 다음과 같은 특징을 가질 수 있습니다.

  1. 생명주기 및 의존성 배열을 통한 정교한 제어와 안정적인 리소스 관리: useEffect는 의존성 배열의 활용을 통해 Effect의 실행 시점을 마운트, 특정 상태/props 업데이트, 언마운트 시점으로 정교하게 제어할 수 있습니다. 특히, Effect 함수가 반환하는 클린업 함수는 Effect가 재실행되거나 컴포넌트가 언마운트되기 전에 외부 시스템에서 할당된 리소스(이벤트 리스너, 타이머, 구독 등)를 안전하게 해제하여 메모리 누수를 방지하고 애플리케이션의 안정성을 확보하는 데 기여합니다.
  2. 관심사 분리 용이: 하나의 컴포넌트 안에서 여러 개의 useEffect 훅을 사용하여 각각 독립적인 사이드 이펙트를 관리할 수 있습니다. 이를 통해 각 Effect가 단일 책임 원칙을 따르도록 설계할 수 있으며, 코드의 가독성, 유지보수성, 그리고 재사용성을 향상하는 데 도움이 될 수 있습니다. 예를 들어, 데이터 페칭, 이벤트 리스너 등록, 문서 제목 업데이트 등 서로 다른 로직을 각각의 useEffect로 분리하여 관리할 수 있습니다.
  3. 클린업 메커니즘 제공: useEffect는 Effect가 다시 실행되거나 컴포넌트가 언마운트되기 전에 실행될 클린업 함수를 반환할 수 있도록 설계되었습니다. 이 클린업 함수는 구독 해제, 타이머 해제 등 불필요하게 남아있는 리소스를 정리하여 메모리 누수를 방지하고 애플리케이션의 안정성을 높이는 데 필수적인 역할을 수행합니다. 이를 통해 개발자는 사이드 이펙트의 시작과 끝을 명확하게 제어할 수 있습니다.

요약

지금까지 우리는 useEffect 훅을 활용하여 리액트 컴포넌트의 생명주기 동안 사이드 이펙트를 관리하는 심층적인 방법에 대해 살펴보았습니다. Effect의 실행 시점을 제어하는 의존성 배열과, 불필요한 리소스 사용을 방지하는 클린업 함수의 중요성을 다양한 예시를 통해 이해할 수 있었을 것 같습니다.

  • Effect 생명주기: useEffect는 컴포넌트 마운트, 업데이트, 언마운트 시점에 맞춰 실행 및 정리될 수 있습니다.
  • 의존성 배열: Effect의 재실행 시점을 제어하며, 빈 배열([])은 마운트 시 한 번만 실행, 특정 값은 해당 값 변경 시 재실행을 의미합니다.
  • 클린업 함수: Effect 함수가 반환하는 함수로, Effect 재실행 전 또는 컴포넌트 언마운트 시점에 실행되어 메모리 누수 방지 및 리소스 정리를 담당합니다.
  • Effect 분리: 하나의 useEffect에 여러 로직을 넣기보다 관심사별로 분리하여 가독성, 유지보수성, 재사용성을 높이는 것이 좋습니다.

참고문서

Effect의 생명주기 (Lifecycle of Reactive Effects)