2025-09-22 01:26

  • 리액트 컴포넌트 생명주기는 컴포넌트가 생성되고, 업데이트되고, 소멸되는 과정 전체를 의미한다.

  • 클래스 컴포넌트는 componentDidMount, componentDidUpdate와 같은 명시적인 생명주기 메서드를 사용한다.

  • 함수형 컴포넌트는 useEffect 훅을 사용하여 클래스 컴포넌트의 생명주기 기능들을 통합적으로 다룬다.

리액트 생명주기 완벽 정복 핸드북 개발자 필독 가이드

리액트(React)는 오늘날 가장 인기 있는 자바스크립트 라이브러리 중 하나로, 사용자 인터페이스(UI)를 구축하는 데 강력한 기능을 제공한다. 리액트의 핵심 철학은 ‘컴포넌트 기반 아키텍처(Component-Based Architecture)‘이다. 이는 UI를 독립적이고 재사용 가능한 조각들로 나누어 개발하는 방식인데, 레고 블록을 조립해 원하는 모양을 만드는 것과 유사하다.

하지만 이 레고 블록, 즉 리액트 컴포넌트는 단순히 화면에 그려지는 정적인 존재가 아니다. 컴포넌트는 스스로의 상태(state)를 가지고, 사용자와 상호작용하며, 부모로부터 새로운 데이터(props)를 받아 동적으로 변화한다. 이처럼 컴포넌트는 생성되고, 변화하며, 결국에는 사라지는 일련의 과정을 거치는데, 이를 **생명주기(Lifecycle)**라고 부른다.

리액트 개발자에게 생명주기에 대한 이해는 선택이 아닌 필수다. 컴포넌트가 언제 화면에 나타나고, 언제 업데이트되며, 언제 사라지는지 정확히 알아야만 필요한 시점에 원하는 동작(예: 데이터 요청, 이벤트 리스너 등록/해제)을 수행할 수 있기 때문이다. 이 핸드북은 리액트 컴포넌트 생명주기의 탄생 배경부터 구조, 그리고 현대적인 활용법까지 모든 것을 깊이 있게 다룰 것이다.


리액트 생명주기는 왜 만들어졌을까

초기 웹 개발은 간단했다. 정적인 HTML 페이지를 만들고 약간의 CSS와 자바스크립트로 동적인 효과를 추가하는 정도였다. 하지만 웹 애플리케이션이 점점 복잡해지면서, 수많은 UI 요소들의 상태를 관리하고 동기화하는 것은 엄청난 골칫거리가 되었다. 예를 들어, 사용자가 버튼을 클릭하면 특정 목록이 업데이트되고, 그와 동시에 다른 곳의 카운터 숫자도 바뀌어야 하는 상황을 상상해 보자. 이런 여러 개의 DOM 요소를 직접 찾아다니며 일일이 수정하는 방식(바닐라 자바스크립트나 jQuery 방식)은 코드를 복잡하게 만들고 버그를 양산하기 쉬웠다.

페이스북(현 메타) 개발팀 역시 이러한 문제에 직면했다. 특히 페이스북의 복잡한 UI(뉴스피드, 채팅창 등)를 관리하기 위해 더 효율적인 방법이 필요했다. 그래서 그들은 ‘데이터가 변하면, UI는 그 데이터의 상태에 맞게 알아서 다시 그려져야 한다’는 선언적인(declarative) 접근 방식을 고안해냈다. 이것이 바로 리액트의 시작이다.

리액트는 가상돔(Virtual DOM)이라는 개념을 도입하여, 데이터가 변경될 때마다 실제 DOM을 직접 건드리는 대신 메모리상의 가상 DOM을 먼저 업데이트한다. 그리고 이전 가상 DOM과 비교하여 변경된 부분만 실제 DOM에 효율적으로 반영한다.

이 과정에서 한 가지 중요한 질문이 생긴다. “컴포넌트가 처음 화면에 그려지는 시점은 언제인가?”, “데이터가 바뀌어서 컴포넌트가 다시 그려지기 직전이나 직후에는 무엇을 해야 할까?”, “컴포넌트가 화면에서 사라질 때 정리해야 할 작업은 없을까?”

생명주기 메서드는 바로 이 질문들에 대한 답이다. 리액트는 컴포넌트가 겪는 주요 시점들, 즉 ‘태어나고(생성)’, ‘살아가며(업데이트)’, ‘죽는(소멸)’ 각 단계에 개입하여 특정 코드를 실행할 수 있는 ‘갈고리(hook)‘들을 만들어 제공했다. 개발자들은 이 갈고리, 즉 생명주기 메서드를 사용하여 다음과 같은 작업들을 정교하게 제어할 수 있게 되었다.

  • 컴포넌트 생성 시점: 외부 API로부터 초기 데이터를 가져오거나, 특정 라이브러리를 연동한다.

  • 컴포넌트 업데이트 시점: 부모로부터 받은 props가 변경되었을 때만 특정 로직을 수행하여 불필요한 렌더링을 막거나, 스크롤 위치를 조정하는 등의 부수 효과(Side Effect)를 처리한다.

  • 컴포넌트 소멸 시점: 컴포넌트가 사용하던 메모리(예: setInterval 타이머, 외부 이벤트 리스너)를 깨끗하게 정리하여 메모리 누수(memory leak)를 방지한다.

이처럼 생명주기는 리액트가 선언적인 UI 렌더링 모델을 유지하면서도, 개발자가 애플리케이션의 동작을 세밀하게 제어할 수 있도록 만들어주는 핵심적인 장치다.


리액트 생명주기의 구조

리액트 컴포넌트는 크게 두 가지 종류가 있다. 초기에 주로 사용되던 **클래스 컴포넌트(Class Component)**와, 이후 등장하여 현재는 대세가 된 **함수형 컴포넌트(Functional Component)**다. 두 컴포넌트 유형은 생명주기를 다루는 방식에 차이가 있다. 먼저 전통적인 클래스 컴포넌트의 생명주기를 이해하고, 그 개념이 함수형 컴포넌트에서 어떻게 useEffect 훅으로 통합되었는지 살펴보자.

1. 클래스 컴포넌트의 생명주기

클래스 컴포넌트의 생명주기는 크게 세 단계로 나눌 수 있다.

  • 마운팅(Mounting): 컴포넌트 인스턴스가 생성되어 DOM에 삽입될 때

  • 업데이트(Updating): props 또는 state가 변경되어 컴포넌트가 다시 렌더링될 때

  • 언마운팅(Unmounting): 컴포넌트가 DOM에서 제거될 때

마운팅 단계

컴포넌트가 세상에 처음 태어나는 과정이다. 아래 순서대로 메서드가 호출된다.

메서드명설명주요 사용 사례
constructor()컴포넌트 생성자. state를 초기화하거나 메서드를 바인딩할 때 사용.this.state 설정, this 컨텍스트 바인딩
static getDerivedStateFromProps()props로부터 파생된 state를 동기화할 때 사용. 렌더링 직전에 호출되며, state를 변경하고 싶을 경우 객체를, 아니면 null을 반환.props 변화에 따라 state가 변해야 할 때 (예: 애니메이션)
render()UI를 렌더링하는 핵심 메서드. 순수 함수여야 하며, this.propsthis.state를 기반으로 JSX를 반환.컴포넌트의 JSX 구조 정의
componentDidMount()컴포넌트가 DOM에 성공적으로 렌더링된 후 호출. 이 시점부터 DOM 노드에 직접 접근 가능.외부 API 호출, 서드파티 라이브러리 연동, 이벤트 리스너 등록

비유: constructor는 아기가 태어나기 전 유전자를 결정하는 과정, render는 아기의 모습을 그리는 과정, componentDidMount는 아기가 세상에 태어나 첫 숨을 쉬는 순간과 같다. 이 순간부터 세상(DOM)과 상호작용할 수 있다.

업데이트 단계

컴포넌트가 성장하고 변화하는 과정이다. propsstate가 변경되거나, 부모 컴포넌트가 다시 렌더링될 때 발생한다.

메서드명설명주요 사용 사례
static getDerivedStateFromProps()마운팅 단계와 동일. 업데이트 시작 전에도 호출된다.props 변화에 따른 state 동기화
shouldComponentUpdate()리렌더링 여부를 결정하는 중요한 성능 최적화 메서드. true를 반환하면 업데이트를 계속하고, false를 반환하면 중단.불필요한 리렌더링 방지 (성능 최적화)
render()마운팅 단계와 동일. 변경된 propsstate를 기반으로 다시 UI를 그린다.UI 업데이트
getSnapshotBeforeUpdate()render 메서드 호출 후, DOM에 실제 변경이 반영되기 직전에 호출. 주로 스크롤 위치 등 DOM의 특정 값을 업데이트 전에 캡처할 때 사용.스크롤 위치 고정, DOM 요소 크기 변화 감지
componentDidUpdate()컴포넌트 업데이트가 완료된 후 호출. 이 시점에는 이미 DOM이 변경된 상태.업데이트된 props를 기반으로 한 네트워크 요청, DOM 조작

비유: shouldComponentUpdate는 “지금 꼭 옷을 갈아입어야 할까?”라고 스스로에게 묻는 과정이다. getSnapshotBeforeUpdate는 옷을 갈아입기 전(DOM 업데이트 전) 자신의 키를 재어두는 것과 같고, componentDidUpdate는 새 옷을 입고 난 후(DOM 업데이트 후) 거울을 보는 것과 같다.

언마운팅 단계

컴포넌트가 생을 마감하고 사라지는 과정이다.

메서드명설명주요 사용 사례
componentWillUnmount()컴포넌트가 DOM에서 제거되기 직전에 호출.setInterval 타이머 제거, 등록했던 이벤트 리스너 해제 등 메모리 정리 작업

비유: componentWillUnmount는 떠나기 전 뒷정리를 하는 것과 같다. 빌렸던 물건(이벤트 리스너)을 반납하고, 켜뒀던 가스 불(타이머)을 끄는 과정이다. 이 과정을 제대로 거치지 않으면 애플리케이션에 메모리 누수와 같은 문제가 발생할 수 있다.

2. 함수형 컴포넌트와 useEffect

리액트 훅(Hook)이 도입되면서, 함수형 컴포넌트에서도 클래스 컴포넌트의 생명주기 기능들을 사용할 수 있게 되었다. 그 중심에는 useEffect 훅이 있다. useEffect는 여러 생명주기 메서드의 기능을 하나로 통합한 강력한 도구다.

useEffect는 “이 컴포넌트는 렌더링 이후에 어떤 부수 효과(side effect)를 수행해야 한다”고 리액트에게 알려준다. 여기서 부수 효과란 데이터 가져오기, 구독(subscription) 설정하기, 수동으로 DOM 조작하기 등을 의미한다.

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

JavaScript

useEffect(() => {
  // 부수 효과를 수행하는 코드 (콜백 함수)
  // componentDidMount + componentDidUpdate 와 유사하게 작동

  return () => {
    // 뒷정리(cleanup) 함수
    // componentWillUnmount 와 유사하게 작동
  };
}, [/* 종속성 배열 (dependency array) */]);

useEffect의 동작 방식은 두 번째 인자인 종속성 배열에 의해 결정된다.

  1. 종속성 배열을 생략할 경우:

    JavaScript

    useEffect(() => {
      console.log('컴포넌트가 렌더링될 때마다 실행됩니다.');
    });
    

    컴포넌트가 매번 렌더링될 때마다 콜백 함수가 실행된다. 이는 componentDidMountcomponentDidUpdate를 합쳐놓되, 렌더링마다 실행되는 방식이다.

  2. 빈 배열([])을 전달할 경우:

    JavaScript

    useEffect(() => {
      console.log('컴포넌트가 처음 마운트될 때 한 번만 실행됩니다.');
      // API 호출, 이벤트 리스너 등록 등
    
      return () => {
        console.log('컴포넌트가 언마운트될 때 실행됩니다.');
        // 이벤트 리스너 해제, 타이머 제거 등
      };
    }, []);
    

    콜백 함수는 **최초 렌더링 직후(마운트 시점)**에 한 번만 실행된다. 클래스 컴포넌트의 componentDidMount와 정확히 같은 역할을 한다. 또한, return되는 뒷정리 함수는 컴포넌트가 사라질 때(언마운트 시점) 실행되므로 componentWillUnmount의 역할을 한다.

  3. 배열에 특정 값([prop, state])을 전달할 경우:

    JavaScript

    const [count, setCount] = useState(0);
    
    useEffect(() => {
      console.log(`count가 변경되었습니다: ${count}`);
    }, [count]); // count 값이 변경될 때만 이 효과가 다시 실행됩니다.
    

    콜백 함수는 최초 마운트 시점과, 종속성 배열 안의 값이 변경될 때마다 실행된다. 이는 componentDidUpdate에서 특정 propsstate의 변경을 감지하는 로직과 유사하다. prevPropsprevState와 비교하는 조건문을 useEffect의 종속성 배열이 대신해주는 셈이다.

결론적으로, useEffect는 마운트, 업데이트, 언마운트라는 ‘시점’ 중심의 사고에서 벗어나, ‘어떤 데이터의 변화에 따라 어떤 부수 효과를 동기화할 것인가’라는 ‘논리’ 중심의 사고를 가능하게 해준다. 예를 들어, 특정 유저 ID(userId)가 바뀔 때마다 해당 유저의 프로필 정보를 가져오는 로직은 클래스 컴포넌트에서는 componentDidMountcomponentDidUpdate 두 곳에 중복해서 작성해야 할 수 있지만, useEffect를 사용하면 하나의 useEffect 블록 안에서 [userId]를 종속성으로 지정하여 깔끔하게 처리할 수 있다.


리액트 생명주기 현명하게 사용하기

이제 생명주기의 구조를 알았으니, 실제 개발에서 어떻게 활용하는지 구체적인 예시와 함께 알아보자.

사용 사례 1: 데이터 로딩 (API 호출)

컴포넌트가 화면에 나타날 때 외부 서버로부터 데이터를 가져오는 것은 가장 흔한 작업 중 하나다.

클래스 컴포넌트 (componentDidMount)

JavaScript

class UserProfile extends React.Component {
  state = {
    user: null,
    loading: true,
  };

  async componentDidMount() {
    const response = await fetch(`https://api.example.com/users/${this.props.userId}`);
    const userData = await response.json();
    this.setState({ user: userData, loading: false });
  }

  render() {
    const { user, loading } = this.state;
    if (loading) {
      return <div>로딩 중...</div>;
    }
    return <div>{user.name}</div>;
  }
}

componentDidMount는 렌더링이 완료된 직후이므로, 여기서 setState를 호출하면 다시 렌더링이 발생하지만 사용자에게는 로딩 상태가 보이지 않고 바로 최종 결과물이 보인다.

함수형 컴포넌트 (useEffect)

JavaScript

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const fetchUser = async () => {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      const userData = await response.json();
      setUser(userData);
      setLoading(false);
    };

    fetchUser();
  }, [userId]); // userId가 변경될 때마다 데이터를 다시 가져온다.

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

useEffect의 종속성 배열에 userId를 넣어줌으로써, userId 프롭이 바뀔 때마다 새로운 사용자 정보를 똑똑하게 다시 불러온다.

사용 사례 2: 불필요한 리렌더링 방지 (성능 최적화)

리액트는 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 기본적으로 리렌더링된다. 하지만 자식 컴포넌트에 전달되는 props가 변경되지 않았다면 이는 불필요한 연산이다.

클래스 컴포넌트 (shouldComponentUpdate 또는 React.PureComponent)

shouldComponentUpdate를 직접 구현하여 props와 state를 비교할 수 있다.

JavaScript

class MyComponent extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    // name prop이 변경되었을 때만 리렌더링한다.
    return this.props.name !== nextProps.name;
  }

  render() {
    console.log('MyComponent 렌더링!');
    return <div>안녕하세요, {this.props.name}</div>;
  }
}

또는 React.PureComponent를 상속받으면 propsstate에 대한 얕은 비교(shallow comparison)를 자동으로 수행해준다.

함수형 컴포넌트 (React.memo)

React.memo는 고차 컴포넌트(Higher-Order Component)로, 함수형 컴포넌트를 감싸서 props의 변화를 감지하고, props가 변경되지 않았으면 이전 렌더링 결과를 재사용한다.

JavaScript

import React from 'react';

const MyComponent = React.memo(({ name }) => {
  console.log('MyComponent 렌더링!');
  return <div>안녕하세요, {name}</div>;
});

React.memo는 클래스 컴포넌트의 PureComponent와 유사한 역할을 한다.

사용 사례 3: 뒷정리 작업 (Cleanup)

컴포넌트가 화면에서 사라질 때, 등록했던 이벤트 리스너나 타이머 등을 정리하지 않으면 메모리 누수의 원인이 된다.

클래스 컴포넌트 (componentWillUnmount)

JavaScript

class Timer extends React.Component {
  componentDidMount() {
    this.timerId = setInterval(() => {
      console.log('타이머 작동 중...');
    }, 1000);
  }

  componentWillUnmount() {
    console.log('타이머를 정리합니다.');
    clearInterval(this.timerId);
  }

  render() {
    return <div>타이머 예제</div>;
  }
}

함수형 컴포넌트 (useEffect의 cleanup 함수)

JavaScript

import React, { useEffect } from 'react';

function Timer() {
  useEffect(() => {
    const timerId = setInterval(() => {
      console.log('타이머 작동 중...');
    }, 1000);

    // cleanup 함수
    return () => {
      console.log('타이머를 정리합니다.');
      clearInterval(timerId);
    };
  }, []); // 빈 배열을 사용하여 마운트/언마운트 시에만 실행

  return <div>타이머 예제</div>;
}

useEffectreturn 값으로 전달된 함수는 컴포넌트가 언마운트되기 직전에 호출되어 뒷정리 역할을 완벽하게 수행한다.


심화 내용: 더 깊이 알아보기

에러 처리 생명주기

애플리케이션이 아무리 견고해도 에러는 발생할 수 있다. 렌더링 과정에서 발생하는 자바스크립트 에러는 리액트 컴포넌트 트리를 깨뜨려 전체 애플리케이션을 중단시킬 수 있다. 이를 방지하기 위해 리액트는 에러 경계(Error Boundaries) 라는 개념을 도입했다.

에러 경계는 하위 컴포넌트 트리에서 발생하는 에러를 포착하여, 에러가 발생했음을 알리는 UI(Fallback UI)를 보여주고, 에러 리포팅 같은 부수적인 작업을 수행할 수 있는 클래스 컴포넌트다.

다음 두 가지 생명주기 메서드를 사용하여 에러 경계를 구현할 수 있다.

  • static getDerivedStateFromError(error): 하위 컴포넌트에서 에러가 발생했을 때 호출된다. 에러 상황을 렌더링하기 위해 state를 업데이트하는 데 사용된다.

  • componentDidCatch(error, info): 에러와 에러 정보를 로깅하는 데 사용된다.

JavaScript

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 합니다.
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수 있습니다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 직접 폴백 UI를 렌더링할 수 있습니다.
      return <h1>무언가 잘못되었습니다.</h1>;
    }

    return this.props.children;
  }
}

아쉽게도 현재(React 18 기준)까지는 함수형 컴포넌트에서 useEffect와 같은 훅으로 에러 경계를 구현할 수 있는 방법은 없다. 따라서 에러 경계는 여전히 클래스 컴포넌트로 작성해야 하는 몇 안 되는 사례 중 하나다.

사라진 생명주기 메서드들

과거 리액트 버전에는 componentWillMount, componentWillReceiveProps, componentWillUpdate와 같은 생명주기 메서드들이 있었다. 하지만 이들은 몇 가지 문제를 안고 있었다.

  • 비동기 렌더링과의 충돌: 리액트의 새로운 비동기 렌더링(Concurrent Mode) 환경에서는 render 메서드가 호출되기 전 작업들이 여러 번 중단되고 재시작될 수 있다. 만약 componentWillMount에서 API를 호출한다면, 렌더링이 완료되기도 전에 API가 여러 번 호출되는 문제가 발생할 수 있다.

  • 오용 가능성: 개발자들이 componentWillReceiveProps에서 props가 변경될 때마다 부수 효과와 함께 setState를 호출하는 코드를 작성하는 경우가 많았다. 이는 의도치 않은 무한 루프나 복잡한 버그를 유발했다.

이러한 문제들 때문에 이 메서드들은 안전하지 않은(unsafe) 것으로 간주되어 현재는 사용이 지양되며, 이름 앞에 UNSAFE_ 접두사가 붙었다. 대신 getDerivedStateFromPropsuseEffect 같은 더 안전하고 예측 가능한 대안을 사용해야 한다.

결론: 생명주기를 지배하는 자가 리액트를 지배한다

리액트 컴포넌트의 생명주기는 단순히 기술적인 개념을 넘어, 컴포넌트라는 하나의 작은 생명체가 어떻게 태어나고, 변화하며, 소멸하는지를 이해하는 철학적인 과정과도 같다. 클래스 컴포넌트의 명시적인 메서드들은 우리에게 생명주기의 각 단계를 명확하게 보여주었고, 함수형 컴포넌트의 useEffect 훅은 이러한 단계들을 ‘관심사의 분리’라는 더 현대적인 패러다임으로 통합했다.

이 핸드북을 통해 당신은 더 이상 생명주기 앞에서 망설이지 않게 될 것이다. 언제 데이터를 가져와야 할지, 언제 불필요한 계산을 건너뛰어야 할지, 그리고 언제 깨끗하게 뒷정리를 해야 할지 아는 것은 당신의 리액트 코드를 더 효율적이고, 안정적이며, 예측 가능하게 만들어 줄 것이다. 생명주기를 깊이 이해하고 자유자재로 활용하여, 살아 숨 쉬는 동적인 웹 애플리케이션을 만들어 나가길 바란다.