웹/React

componentDidUpdate, componentWillUnmount와 useEffect의 차이

디정 2024. 3. 28. 20:38

componentDidUpdate와 componentWillUnmount는 클래스 컴포넌트에서 사용하는 생명주기 관리 메소드, useEffect는 함수 컴포넌트에서 사용하는 생명주기 관리 Effect hook이다. 공식 문서를 조금이라도 깔짝여본 적이 있다면 useEffect를  componentDidUpdate와 componentWillUnmount의 역할로 이해해도 괜찮다고 적시한 내용을 기억할지도 모른다.

 

하지만 위 두 함수와 useEffect가 완벽하게 동일한 역할을 한다면 모순되는 부분이 보이기 시작했다. 아무래도 자세한 이해가 부족한 것 같아 미뤄뒀던 공식 문서의 다음 장을 읽기 시작했다.

 

클래스 컴포넌트의 두 함수와 useEffect의 차이를 명확하게 이해하기 위해서는 먼저 두 컴포넌트의 생명주기의 차이점부터 이해해야 한다. 컴포넌트의 생명주기 단계는 공식 문서에서 이해하기 쉽게 그림으로 정리해놨다.

 

< 클래스 컴포넌트의 생명주기 >

 

클래스 컴포넌트의 생명주기 관리 함수들은 명확하게 '생명주기 관리' 역할에 집중한다. componentDidMount는 컴포넌트가 생성된 후 마운트 될 때, componentWillUnmount는 컴포넌트가 언마운트 될 때, 그리고 componentDidUpdate는 컴포넌트가 랜더링 될 때마다 실행되어 각 생명주기 단계마다 필수로 실행되여야 하는 역할을 정리할 수 있다. 

 

이 중에서도 componentDidUpdate가 중요한 이유는 컴포넌트 생명주기가 종료될 때 정리(Clean-up)해야하는 작업들을 깔끔하게 처리해야하기 때문이다. 예를 들어 setInterval 로 만들어진 인터벌 객체는 컴포넌트가 언마운트 될 때 제대로 정리해줘야만 서비스 부하를 막을 수 있다. 

 

useEffect도 주로 같은 역할을 한다. 하지만 클래스 컴포넌트의 생명주기 관리 메소드들과 달리 useEffect는 특별히 제약을 걸지 않으맨 매 랜더링 이후 마다 수행된다. 마치 클래스 컴포넌트의 세 메소드의 역할을 useEffect에 떼려넣은 듯한 모양새이고, 이 때문에 세 메소드의 역할을 useEffect가 대신한다고 흔히 설명하는 것이다.

 

하지만 단순히 그렇게만 이해하기에는 이상한 점이 있다. 한 컴포넌트에 각 한 개씩만 정의하는 클래스 컴포넌트 생명주기 메소드와 달리 useEffect는 목적에 따라 여러개를 사용하도록 권장되기 때문이다. 아래 코드는 setInterval로 count 값을 1씩 증가하여 매 랜더링마다의 컴포넌트에 번호를 부여해, 콘솔창과 화면에 컴포넌트 생성, 정리 결과를 출력하는 컴포넌트다. 

 

"use client"

import { useEffect, useState, useRef } from "react";
import styles from "./game.module.css"


export default function Page() {
  const [count, setCount] = useState<number>(0);
  // setInterval로 count를 1씩 증가켜 매 랜더링마다 컴포넌트 번호를 부여한다.
  const timer = useRef<NodeJS.Timeout>();

  useEffect(()=>{  //첫 번째 useEffect
    console.log(`${count} 번째 컴포넌트 생성`)  //-- (1)
    timer.current = setInterval(()=>{
      console.log(count); // -- (2)
      setCount((prev)=>prev+1);
    }, 2000); //2초에 한번씩 count 1 증가
    return ()=>{
      clearInterval(timer.current);
      console.log(`${count} 번째 컴포넌트 정리 - 뒤로가기`); // -- (1)
    }
  }, []);

  useEffect(()=>{ // 두 번째 useEffect
      console.log(`${count} 번째 컴포넌트 생성`); // -- (1)
    return ()=>{
      console.log(`${count} 번째 컴포넌트 정리`); // -- (1)
    }
  });

  return (
      <section className={styles.gameBoard}>
          <p>{count}</p>
      </section>
  );
}

 

첫번째 useEffect에서는 의존성 배열을 빈 배열로 넘겨 첫 랜더링 시에만 실행되게 했고(componentDidMount 역할과 유사해짐), 두번째 useEffect에서는 의존성 배열을 생략해 매 랜더링 시 마다 실행되도록 했다(componentDidUpdate 역할과 유사).

 

interval 객체는 첫번째 useEffect에서 생성 및 저장하고 있기 때문에 첫 랜더링 시에만 실행되고, (2)번 라인은 0번째 컴포넌트의 렉시컬 환경에 생성됐기 때문에 콘솔창에 계속 0만 출력하게 될 것이다. 

 

(1)번 라인들과 두 번째 useEffect는 오직 컴포넌트의 생성과 소멸 상태를 관찰하기 위해 입력하였다. 프로그램 실행 후 다섯 번째 컴포넌트가 생성되었을 때 자체적으로 만든 뒤로가기 버튼을 클릭해 화면을 빠져나왔다. (뒤로가기 하면 컴포넌트가 화면에서 삭제되므로, unmount 시점이다.)

 

실행 결과

 

첫번째 두번째 useEffect 모두 첫 랜더링 시에는 실행되므로 '0번째 컴포넌트 생성' 문구는 두 번 찍힌다. 이후 생성되는 1~5번 컴포넌트들은 두 번째 useEffect로 인해 미루지 않고 생성과 소멸이 차례대로 일어난다. 

 

그리고 뒤로가기 버튼을 클릭해 컴포넌트 화면에서 완전히 빠져나왔을 때, 의존성 배열로 빈 배열을 넘겼던 첫 번째 useEffect의 리턴 함수가 실행되며 이 시점에서야 '0 번째 컴포넌트 정리 - 뒤로가기' 문구가 출력된다.

 

하지만 useEffect가 마운트, 언마운트 단계의 componentDidMount, componentWillUnmount 메소드의 역할을 대신한다고 하기에 위 출력값은 이상하다. 마운트 시점에서야 그럴 수 있다고 치지만, 언마운트는 DOM에서 컴포넌트 객체가 정리되는 작업으로 이미 '0번 컴포넌트 정리' 부분이 실행되었는데 후에 또다시 '0 번째 컴포넌트 정리 - 뒤로가기' 가 실행될 수 없다. 이미 언마운트가 끝난 컴포넌트를 다시한번 언마운트 시켰다는 이상한 상황이 되어버린다. 

 

이 이슈에 대해 결론부터 말하자면, useEffect는 클래스 컴포넌트의 생명주기 메소드들 처럼 마운트와 언마운트 단계에서 실행되지 않는다. 이 내용은 공식 문서의 함수 컴포넌트 생명주기 도표에서 확인할 수 있다.

 

< 함수 컴포넌트의 생명주기 >

 

처음 function을 읽어 return으로 랜더값을 반환하는 부분까지가 마운트 영역이고, useEffect는 Commit phase와 cleanup phase에서 실행되고 있는데, 주의할 점이 이 때 매 랜더링 시 마다 정리(Clean-up)되는 것은 컴포넌트가 아니라 useEffect의 매개 변수로 전달되는 이펙트 함수이다. 

 

React will clean up before the effects' next run 문구로 이해할 수 있듯이 함수 컴포넌트는 매 랜더 시 마다 useEffect로 전달된 이전 컴포넌트의 effect 함수를 정리한다. 즉 useEffect의 effect 함수는 매 랜더 시 마다 실행될 명령이고, effect 함수의 반환 함수는 unmount 단계에서 실행될 뿐만 아니라, 다음 랜더의 effect 함수가 새로 전달되기 전 기존 effect 함수가 종료될 때도 실행된다.

 

이 내용을 실험하기 위해 위 코드에 toggle 스테이트를 추가했다. toggle 스테이트는 버튼을 클릭했을 때 false가 true로 변경되며, 첫 번째 useEffect를 실행시키는 트리거 역할이다. 

 

"use client"

import { useEffect, useState, useRef } from "react";
import styles from "./game.module.css"


export default function Page() {
  const [count, setCount] = useState<number>(0);
  const [toggle, setToggle] = useState<boolean>(false);
  // setInterval로 count를 1씩 증가켜 매 랜더링마다 컴포넌트 번호를 부여한다.
  const timer = useRef<NodeJS.Timeout>();

  useEffect(()=>{  //첫 번째 useEffect
    console.log(`${count} 번째 컴포넌트 생성`)
    timer.current = setInterval(()=>{
      console.log(count);
      setCount((prev)=>prev+1);
    }, 2000); //2초에 한번씩 count 1 증가
    return ()=>{
      clearInterval(timer.current);
      if (toggle) console.log(`${count} 번째 컴포넌트 정리 - 뒤로가기`);
      else console.log(`${count} 번째 컴포넌트 정리 - 정리 버튼`);
    }
  }, [toggle]);

  useEffect(()=>{ // 두 번째 useEffect
      console.log(`${count} 번째 컴포넌트 생성`);
    return ()=>{
      console.log(`${count} 번째 컴포넌트 정리`);
    }
  });

  return (
      <section className={styles.gameBoard}>
          <p>{count}</p>
          <button onClick={()=>setToggle(true)}>0번째 컴포넌트 정리</button>
      </section>
  );
}

 

 

이번에는 2번째 컴포넌트 생성 때 정리 버튼을 클릭하고, 4번째 컴포넌트가 생성됐을 때 뒤로가기 버튼을 클릭해 컴포넌트 페이지를 빠져나왔다. 

 

첫번째 useEffect의 의존성 배열에 toggle을 줬기 때문에 '정리 버튼'을 클릭했을 때 첫번째 useEffect가 실행되며, 0번째 effect 함수를 종료(반환)하고 새로운 effect 함수가 전달되었다. 그리고 이때 만들어진 첫번째 useEffect의 두 번째 effect 함수의 반환 함수(clean-up 함수)는 뒤로가기 버튼을 눌러 컴포넌트가 언마운트 되었을 때 실행되었다.

 

 

요약

1. useEffect는 클래스 컴포넌트의 생명주기 관리 메소드와 달리 매 랜더링 시 마다 (의존성 배열 조건을 만족하면) 작동하여 effect 함수를 매개변수로 전달한다.

2. effect 함수는 매 랜더 시 반복할 내용을 담고 있으며, clean-up 함수를 리턴한다.

3. clean-up 함수는 '다음 effect 함수가 전달되기 전 시점'과 '언마운트 시점'에 실행된다.

4. effect 함수는 호출된 useEffect 수 만큼 생성&관리 가능하다.

5. 함수 컴포넌트의 생명주기를 요약하면 아래와 같다.

  • 1) 컴포넌트 마운트
  • 2) state 변경 시 마다 랜더링
    •  2-1 ) 의존성 배열 조건 만족하는 useEffect 실행됨. 
    • 2-2 ) 이전 랜더의 effect 함수 종료됨 (반환 함수 [clean-up 함수] 실행)
    • 2-3 ) 새 랜더에서 새로운 effect 함수 넘김
  • 3) 다른 화면으로 넘어갈 시 페이지에서 컴포넌트 사라지며 언마운트
    • 3-1 ) 아직 실행하지 않는 clean-up 함수들 실행하여 컴포넌트 정리.

 

 

나는 사실 여태 함수 컴포넌트는 매 랜더 시 마다 매번 마운트-언마운트도 병행된다고 오해하고 있었다. 아직 불명확한 개념들이 있긴 하지만 그래도 한 고비 넘긴 기분이다. 

 

 

 

END.