icon
3장 : React 컴포넌트 심화

컴포넌트 라이프사이클 이해하기


이제 리액트 컴포넌트가 화면에 나타나고, 업데이트되며, 최종적으로 사라지는 전체 과정, 즉 컴포넌트 라이프사이클(Lifecycle, 생명 주기) 에 대해 이해해 볼 차례입니다.

컴포넌트 라이프사이클은 마치 사람의 '태어나고(mount), 살아가며(update), 죽는(unmount)' 과정과 같습니다. 리액트 컴포넌트도 이와 유사한 생명 주기를 가지며, 각 단계에서 특정 작업을 수행할 수 있도록 훅(Hook)을 제공합니다. 이 라이프사이클을 이해하는 것은 컴포넌트의 동작 방식을 더 깊이 이해하고, 필요한 시점에 적절한 로직을 실행하는 데 필수적입니다.


컴포넌트의 주요 라이프사이클 단계

리액트 컴포넌트는 크게 세 가지 주요 생명 주기 단계를 거칩니다.

마운트(Mounting): 컴포넌트 생성 및 DOM 삽입

  • 컴포넌트가 처음으로 생성되어 실제 DOM에 삽입되는 단계입니다.
  • 예시: 페이지 로딩 시 컴포넌트가 화면에 나타나는 순간

업데이트(Updating): 컴포넌트 재렌더링

  • 컴포넌트의 propsstate가 변경되어 UI가 다시 그려지는 단계입니다.
  • 예시: useState를 통해 상태가 변경되거나, 부모로부터 새로운 props를 받을 때

언마운트(Unmounting): 컴포넌트 제거 및 DOM에서 삭제

  • 컴포넌트가 더 이상 필요 없어져 실제 DOM에서 제거되는 단계입니다.
  • 예시: 페이지 이동 시 컴포넌트가 화면에서 사라지는 순간

함수형 컴포넌트의 useEffect

클래스형 컴포넌트에는 componentDidMount, componentDidUpdate, componentWillUnmount와 같은 생명 주기 메서드들이 존재했습니다. 하지만 함수형 컴포넌트에서는 이 모든 라이프사이클 작업을 useEffect 하나로 처리할 수 있습니다.

useEffect 훅은 컴포넌트가 렌더링된 이후에 특정 작업을 수행하도록 하는 훅입니다. 주로 부수 효과(Side Effect) 를 처리할 때 사용됩니다.

부수 효과(Side Effect)란?

함수 내에서 함수의 입력(인자) 외에 다른 외부에 영향을 주거나, 외부로부터 영향을 받는 작업을 말합니다. 리액트 컴포넌트에서의 부수 효과는 다음과 같은 것들이 있습니다.

  • 데이터 가져오기 (API 호출)
  • 구독(Subscription) 설정 및 해제
  • DOM 직접 조작 (드물지만 필요할 때)
  • 타이머 설정 및 해제 (setTimeout, setInterval)

useEffect의 기본 구조는 다음과 같습니다.

import React, { useEffect } from 'react';

useEffect(() => {
  // 컴포넌트가 렌더링된 후에 실행될 코드 (부수 효과)

  return () => {
    // 컴포넌트가 언마운트되거나, 다음 이펙트가 실행되기 전에 실행될 '클린업(Clean-up)' 함수
    // 구독 해제, 타이머 제거 등 정리 작업
  };
}, [의존성 배열]); // (선택 사항) 의존성 배열: 이 배열의 값이 변경될 때만 이펙트가 다시 실행됩니다.

useEffect의 두 번째 인자인 의존성 배열(Dependency Array)useEffect가 언제 다시 실행될지를 제어하는 데 매우 중요합니다.


useEffect의 다양한 활용 패턴

의존성 배열의 값에 따라 useEffect는 마운트, 업데이트, 언마운트 시점에 다르게 동작합니다.

마운트 시에만 실행 (빈 의존성 배열 [])

컴포넌트가 처음 마운트될 때만 한 번 실행되고, 이후에는 다시 실행되지 않도록 하려면 의존성 배열을 빈 배열([]) 로 전달합니다. 이는 클래스형 컴포넌트의 componentDidMount와 유사합니다.

예제: 페이지 제목 변경 및 초기 데이터 로딩

src/components/MountedComponent.js
// src/components/MountedComponent.js
import React, { useState, useEffect } from 'react';

function MountedComponent() {
  const [data, setData] = useState(null);

  useEffect(() => {
    // 이 코드는 컴포넌트가 처음 화면에 나타날 때 (마운트될 때) 한 번만 실행됩니다.
    console.log('MountedComponent가 마운트되었습니다!');

    // 페이지 제목 변경 (브라우저 탭 제목)
    document.title = 'React 앱 - 마운트됨';

    // (가상) API에서 데이터 가져오기 (마운트 시 초기 데이터 로드)
    const fetchData = async () => {
      console.log('데이터를 불러오는 중...');
      await new Promise(resolve => setTimeout(resolve, 2000)); // 2초 지연 시뮬레이션
      setData('성공적으로 로드된 데이터입니다!');
      console.log('데이터 로드 완료!');
    };

    fetchData();

    // (선택적) 클린업 함수: 컴포넌트가 언마운트될 때 실행됩니다.
    return () => {
      console.log('MountedComponent가 언마운트될 예정입니다.');
      document.title = 'React 앱'; // 제목을 원래대로 복원
    };
  }, []); // (1) 빈 의존성 배열: 마운트 시 한 번만 실행

  return (
    <div style={{ border: '1px solid #ff7f50', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>마운트 시 동작하는 컴포넌트</h2>
      <p>{data ? data : '데이터 로드 중...'}</p>
    </div>
  );
}

export default MountedComponent;
  • useEffect 내부의 코드는 컴포넌트가 처음 마운트될 때 딱 한 번 실행됩니다.
  • return () => { ... } 내의 코드는 컴포넌트가 언마운트될 때(예: MountedComponent가 화면에서 사라질 때) 실행되는 클린업 함수입니다. 타이머 해제, 구독 해제 등 뒷정리 작업을 여기에 작성합니다.

의존성이 변경될 때마다 실행

컴포넌트의 특정 stateprops 값이 변경될 때마다 useEffect를 다시 실행하고 싶다면, 해당 stateprops를 의존성 배열에 포함합니다.

예제: 카운터 값 변경 시 메시지 업데이트

src/components/CounterWithEffect.js
// src/components/CounterWithEffect.js
import React, { useState, useEffect } from 'react';

function CounterWithEffect() {
  const [count, setCount] = useState(0);
  const [message, setMessage] = useState('');

  // (1) count state가 변경될 때마다 이펙트 실행
  useEffect(() => {
    console.log(`카운트가 ${count}로 변경되었습니다.`);
    setMessage(`현재 카운트 값은 ${count} 입니다.`);

    // (선택적) 클린업: 이전 메시지 초기화
    return () => {
      console.log('이전 카운트 이펙트 정리');
      // setMessage(''); // 필요하다면 메시지 초기화 로직
    };
  }, [count]); // (2) 의존성 배열에 count 추가

  return (
    <div style={{ border: '1px solid #4CAF50', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>카운터 (useEffect 활용)</h2>
      <p>{message}</p>
      <p>현재 값: {count}</p>
      <button onClick={() => setCount(prevCount => prevCount + 1)}>
        증가
      </button>
      <button onClick={() => setCount(0)} style={{ marginLeft: '10px' }}>
        초기화
      </button>
    </div>
  );
}

export default CounterWithEffect;
  • useEffect의 의존성 배열에 [count]를 넣었습니다. 이는 count state의 값이 변경될 때마다 이 useEffect 내부의 코드를 다시 실행하라는 의미입니다.
  • console.log를 통해 count가 변경될 때마다 로그가 찍히는 것을 확인할 수 있습니다.

매 렌더링마다 실행 (의존성 배열 생략)

의존성 배열을 아예 생략하면 useEffect컴포넌트가 렌더링될 때마다 (props나 state가 변경될 때마다 포함) 실행됩니다.

src/components/AlwaysRunEffect.js
// src/components/AlwaysRunEffect.js
import React, { useState, useEffect } from 'react';

function AlwaysRunEffect() {
  const [value, setValue] = useState(0);

  useEffect(() => {
    // 이 코드는 컴포넌트가 렌더링(리렌더링)될 때마다 실행됩니다.
    // 무한 루프에 빠지지 않도록 주의해야 합니다. (state 업데이트를 여기서 직접하면 안 됨)
    console.log('컴포넌트가 렌더링될 때마다 실행!', value);
  }); // (1) 의존성 배열 생략

  return (
    <div style={{ border: '1px solid #8e44ad', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>항상 실행되는 useEffect</h2>
      <p>값: {value}</p>
      <button onClick={() => setValue(value + 1)}>값 증가</button>
    </div>
  );
}

export default AlwaysRunEffect;
  • 이 패턴은 흔히 사용되지는 않습니다. 주로 DOM 직접 조작이나 외부 라이브러리 연동 등, 컴포넌트의 모든 렌더링 주기마다 특정 작업을 수행해야 할 때 사용됩니다.
  • 주의: 의존성 배열을 생략하고 useEffect 내부에서 state를 업데이트하면 무한 루프에 빠질 수 있습니다. (예: setValue(value + 1)useEffect 내부에서 직접 호출하면 value가 변경 $\rightarrow$ useEffect 재실행 $\rightarrow$ value 변경 $\rightarrow$ ... 무한 반복)

클린업(Clean-up) 함수 활용

useEffect는 선택적으로 return 문 안에 클린업 함수를 반환할 수 있습니다. 이 클린업 함수는 다음 두 가지 시점에 실행됩니다.

  1. 컴포넌트가 언마운트될 때 (화면에서 사라질 때)
  2. 이전 이펙트가 다시 실행되기 직전 (의존성 배열의 값이 변경되어 이펙트가 재실행될 때)

클린업 함수는 메모리 누수를 방지하거나 불필요한 네트워크 요청 등을 막는 데 사용됩니다. 주로 구독 해제, 타이머 해제, 이벤트 리스너 제거 등의 뒷정리 작업에 활용됩니다.

예제: 타이머 설정 및 해제

src/components/TimerComponent.js
// src/components/TimerComponent.js
import React, { useState, useEffect } from 'react';

function TimerComponent() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    console.log('타이머 시작');
    // 1초마다 seconds state를 증가시키는 타이머 설정
    const interval = setInterval(() => {
      setSeconds(prevSeconds => prevSeconds + 1);
    }, 1000);

    // (1) 클린업 함수 반환: 컴포넌트가 언마운트되거나 이펙트 재실행 전에 타이머 해제
    return () => {
      console.log('타이머 클린업');
      clearInterval(interval); // (2) 타이머 해제
    };
  }, []); // 빈 의존성 배열: 컴포넌트 마운트 시 한 번만 타이머 설정

  return (
    <div style={{ border: '1px solid #17a2b8', padding: '20px', margin: '20px', borderRadius: '8px' }}>
      <h2>타이머 컴포넌트</h2>
      <p>현재 시간: {seconds}</p>
      <p>이 컴포넌트가 화면에서 사라지면 타이머도 멈춥니다.</p>
    </div>
  );
}

export default TimerComponent;
  • setInterval로 타이머를 시작하고, 반환된 interval ID를 변수에 저장합니다.
  • 클린업 함수에서 clearInterval(interval)을 호출하여 타이머를 해제합니다. 이 컴포넌트가 화면에서 사라지면 타이머가 자동으로 멈춥니다.
  • TimerComponentApp.js에 추가하고, 조건부 렌더링으로 이 컴포넌트를 나타났다 사라지게 하면 클린업 함수가 실행되는 것을 볼 수 있습니다. (예: 토글 버튼으로 TimerComponent의 렌더링 여부 제어)

모든 컴포넌트 한 곳에서 테스트

App.js 파일을 수정하여 위에서 만든 컴포넌트들을 모두 불러와 렌더링하고, 콘솔을 보면서 useEffect의 동작 방식을 직접 확인해 보세요. 특히 MountedComponentTimerComponent처럼 조건부 렌더링으로 렌더링 여부를 제어하면 마운트/언마운트 시의 useEffect 동작을 명확히 볼 수 있습니다.

src/App.js
// src/App.js
import React, { useState } from 'react';
import './App.css';
import MountedComponent from './components/MountedComponent';
import CounterWithEffect from './components/CounterWithEffect';
import AlwaysRunEffect from './components/AlwaysRunEffect';
import TimerComponent from './components/TimerComponent'; // 추가

function App() {
  const [showMounted, setShowMounted] = useState(true);
  const [showTimer, setShowTimer] = useState(true); // TimerComponent 제어용

  return (
    <div className="App">
      <h1>React 컴포넌트 라이프사이클과 useEffect</h1>

      <button onClick={() => setShowMounted(!showMounted)} style={{ margin: '10px', padding: '10px 20px' }}>
        MountedComponent {showMounted ? '숨기기' : '보이기'}
      </button>
      {showMounted && <MountedComponent />} {/* 조건부 렌더링 */}

      <hr />

      <CounterWithEffect />

      <hr />

      <AlwaysRunEffect />

      <hr />

      <button onClick={() => setShowTimer(!showTimer)} style={{ margin: '10px', padding: '10px 20px' }}>
        TimerComponent {showTimer ? '숨기기' : '보이기'}
      </button>
      {showTimer && <TimerComponent />} {/* 조건부 렌더링 */}
    </div>
  );
}

export default App;

버튼을 클릭하여 컴포넌트의 렌더링 여부를 변경하면서 개발자 도구의 콘솔을 유심히 살펴보면 useEffect와 클린업 함수의 실행 시점을 명확히 이해할 수 있을 것입니다.


3장 4절 "컴포넌트 라이프사이클 이해하기"는 여기까지입니다. 컴포넌트의 세 가지 주요 생명 주기 단계와 함수형 컴포넌트에서 이 모든 것을 다룰 수 있는 useEffect 훅의 기본 사용법과 다양한 의존성 배열 패턴, 그리고 클린업 함수의 중요성에 대해 상세하게 설명했습니다.

이제 여러분은 컴포넌트가 화면에 나타나고 사라지는 시점에 필요한 작업을 수행할 수 있는 능력을 갖추게 되었습니다. 다음 장에서는 useRef 훅을 이용하여 DOM 요소에 직접 접근하는 방법과 그 활용 사례에 대해 알아보겠습니다.