useEffect, useLayoutEffect 실행 시점 딥다이브 하면서 발견한 React의 DFS

김건휘·2025년 4월 22일
0

React

목록 보기
19/19
post-thumbnail

리액트의 대표적인 hooke 중 하나인 useEffect. 하지만, 최근 면접에서 실행시점을 모호하게 알고 있어 질문에 명쾌하게 대답하지 못하였다. 이번 시간에는 useEffect 실행 시점을 딥다이브한 내용을 다루어 보겠습니다.

본론으로 들어가기에 앞서,알아두어야하는 개념이 있다.

컴포넌트 생명주기(Lifecycle)

컴포넌트 생명주기 - 리액트 공식문서

생명주기(Lifecycle)란 컴포넌트가 생성되고 변경되고 소멸될 때 까지 일련의 과정을 의미한다. 생명주기(Lifecycle)에서 사용되는 용어를 짚고 넘어가야 useEffect의 실행시점을 완벽하게 이해할 수 있고, useEffect를 남용하게 될 경우 발생하는 문제점을 정확하게 이해할 수 있다.

컴포넌트가 마운트될 때(Mount)

→ 컴포넌트가 웹브라우저에 추가되어 처음 그려질 때

컴포넌트가 업데이트될 때(Update)

→ 컴포넌트가 화면에서 다시 그려질 때(리렌더링될 때)

컴포넌트가 언마운트될 때(Unmount)

→ 컴포넌트가 웹브라우저에서 제거될 때

리액트 훅을 적용한 컴포넌트(함수형 컴포넌트)의 생명주기는 다음과 같다.

useEffect

useEffect는 함수형 컴포넌트에서 사이드 이펙트(side effect)를 처리하기 위해 사용되는 가장 대표적인 React Hook이다.

useEffect는 컴포넌트가 마운트되거나 업데이트될 때 특정 작업을 실행하고, 언마운트될 때 정리(clean-up) 작업도 함께 수행할 수 있게 해주기 때문에 사이드 이펙트(side effect)를 다룰 때 사용된다.

useEffect(() => {
  // 1️⃣ 실행할 작업 (mount 또는 update 시)
  
  return () => {
    // 2️⃣ 정리 함수 (unmount 시)
  };
}, [deps]);
  • 첫 번째 함수: 사이드 이펙트 실행
  • return 함수: cleanup 함수, 컴포넌트가 사라질 때 호출
  • 두 번째 인자 deps: 어떤 값이 변경될 때 effect가 다시 실행될지를 결정

useEffect는 언제 실행 될까?

import { useState, useEffect } from "react";

export default function App() {
  console.log("App 렌더링1");

  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("useEffect 실행");

    const intervalId = setInterval(() => {
      console.log("인터벌 동작중");
      setCount((prev) => prev + 1);
    }, 1000);

    return () => {
      console.log("클린업 실행");
      clearInterval(intervalId);
    };
  }, [count]);

  console.log("App 렌더링2");

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

App 렌더링 1 -> App 렌더링2 -> useEffect순으로 실행되는 것을 확인 할 수 있다.
즉, useEffect는 렌더링이 끝난 후 실행되는 것을 알 수 있다. 다시 말해, useEffect는 화면에 렌더링이 반영될 때까지 코드 실행을 “지연”시킨다.

useEffect의 cleanup함수는 언제 실행될까?

위의 코드에서는 의존성 배열에 count를 넣어주었으니 count가 변경 될때마다 cleanup함수가 실행될 것을 예상할 수 있다.

하지만, 여기서 주의해야될 부분이 있다. 위의 콘솔을 확인하면 알 수 있듯이 cleanup함수리렌더링 이후(App 렌더링 1 → App 렌더링2 → 클린업 실행)에 실행된다는 것이다.

즉, 컴포넌트가 리렌더링 될 때, 재평가 => 언마운트 => 업데이트 순으로 진행된다는 것이다. cleanup함수가 unmount 시 실행된다는 말이 헷갈린다면, useEffect가 재실행되기 직전cleanup함수가 실행된다고 생각하면 될 것 같다.

컴포넌트가 중첩되어있을 때 useEffect 실행 순서

import { useEffect } from "react";

const Inner = () => {
  useEffect(() => {
    console.log("Inner Mount");
  }, []);

  return <div> </div>;
};

const Outer = () => {
  useEffect(() => {
    console.log("Outer Mount");
  }, []);

  return (
    <div>
      <Inner />
    </div>
  );
};

function App() {
  useEffect(() => {
    console.log("App mount");
  }, []);

  return (
    <div className="App">
      <Outer />
    </div>
  );
}

export default App;

해당 코드는 App → Outer → Inner 구조로 컴포넌트가 중첩되어있다.

그런데 콘솔을 확인해보면, 렌더링은 topdown 방식으로 순차적으로 실행되는데 useEffect는 가장 아랫쪽에 있는 Inner부터 역순으로 실행되는 것을 확인할 수 있다.

🤔왜 거꾸로 실행될까?

useEffect는 브라우저가 paint를 완료한 후 실행되는데, React는 내부적으로 깊이 우선 순회(DFS) 방식으로 렌더링을 진행하고, effect도 비슷하게 자식부터 부모 순서로 등록된 effect들을 실행한다.
즉, 가장 안쪽의 Inner의 effect가 가장 먼저 실행되고, 그 다음 Outer, 마지막으로 App순으로 실행한다.

useLayoutEffect

useLayoutEffect는 렌더링 되고 DOM이 업데이트된 직후, 브라우저가 실제로 화면에 그리기(paint) 전에실행되는 Hook이다. 이를 통해 레이아웃을 측정하거나, DOM을 즉시 조작할 필요가 있을 때 활용되곤 한다.

useEffect vs useLayoutEffect

실행 시점

useEffect → paint 이후(비동기)
useLayoutEffect → paint 이전(동기)

useLayoutEffect는 동기적이므로 무거운 작업을 하는 경우에는 화면 렌더링을 지연시킬 수 있으므로 주의가 필요하다.

useEffect, useLayoutEffect 실행 시점

import { useEffect, useLayoutEffect, useState } from "react";
import "./style.css";

export default function App() {
  console.log("App Render Start");

  const [isMount, setIsMount] = useState(false);

  const handleClick = () => {
    console.log("======= TOGGLE =======");
    setIsMount((value) => !value);
  };

  return (
    <div>
      <button onClick={handleClick}>{isMount ? "UnMount" : "Mount"}</button>
      {isMount && <Parent />}
    </div>
  );
}

function Parent() {
  console.log("Parent Render Start");

  useLayoutEffect(() => {
    console.log("Parent useLayoutEffect");

    return () => {
      console.log("Parent useLayoutEffect cleanup");
    };
  }, []);

  useEffect(() => {
    console.log("Parent useEffect");

    return () => {
      console.log("Parent useEffect cleanup");
    };
  }, []);

  return (
    <div ref={(v) => console.log("Parent Ref Assign", v)} className="App">
      <Child />
      <div></div>
    </div>
  );
}

function Child() {
  console.log("Child Render Start");

  useLayoutEffect(() => {
    console.log("Child useLayoutEffect");

    return () => {
      console.log("Child useLayoutEffect cleanup");
    };
  }, []);

  useEffect(() => {
    console.log("Child useEffect");

    return () => {
      console.log("Child useEffect cleanup");
    };
  }, []);

  return <div ref={(v) => console.log("Child Ref Assign", v)}>Child</div>;
}

- 버튼을 클릭하여 Mount시 -

컴포넌트 렌더링

  1. App 렌더링 → "App Render Start"
  2. Parent 렌더링 → "Parent Render Start"
  3. Child 렌더링 → "Child Render Start"

Child의 DOM을 생성하고 ref 콜백을 실행

  1. Child의 DOM 생성됨 → "Child Ref Assign"

Child의 렌더 + DOM 생성 + ref 후 → 브라우저가 paint하기 전 useLayoutEffect 실행(동기적)

  1. Child useLayoutEffect 실행 → "Child useLayoutEffect"

Parent의 DOM을 생성하고 ref 콜백을 실행

  1. Parent의 DOM 생성됨 → "Parent Ref Assign"

Parent의 렌더 + DOM 생성 + ref 후 → 브라우저가 paint하기 전 useLayoutEffect 실행

  1. Parent useLayoutEffect 실행 → "Parent useLayoutEffect"

브라우저 Paint

useEffect 실행 (비동기적)

  1. Child useEffect 실행 → "Child useEffect"

  2. Parent useEffect 실행 → "Parent useEffect"

- 버튼을 클릭하여 unmount시 -

여기서 주의해야되는 점은 React는 부모 → 자식 순서로 트리를 순회하면서 DOM을 unmount하므로, 부모의 layoutEffect cleanup이 먼저 실행된다는 점이다.

App 컴포넌트 리렌더

  1. App Render Start

Unmount 발생

Parent layoutEffect 정리(동기적)

  1. parent useLayoutEffect 정리 → "Parent useLayoutEffect cleanup"

Parent DOM 제거

  1. Parent DOM 해제 → "Parent Ref Assign null"

Child layoutEffect 정리(동기적)

  1. Child useLayoutEffect 정리 → "Child useLayoutEffect cleanup"

Child DOM 제거

  1. Child DOM 해제 → "Child Ref Assign null"

useEffect 정리 (비동기적)

  1. Child useEffect 정리 → "Child useEffect cleanup"

  2. Parent useEffect 정리 → "Parent useEffect cleanup"

위의 실행순서에 알 수 있듯이, useLayoutEffect cleanup은 해당 컴포넌트의 DOM이 제거되기 바로 직전에 실행된다는 것을 알 수 있다.

🔥실행과 달리 cleanup은 자식부터가 아니라 부모 → 자식 순으로 이루어진다.

전체적인 실행순서

요약

useLayoutEffect는 paint 직전에 실행되고, dom에서 제거되기 직전에 cleanup실행. → 동기
useEffect는 컴포넌트 렌더링 이후(paint 이후)실행되고, 컴포넌트가 언마운트 or 렌더링이 일어난 이후 의존성이 변경되기 직전에 cleanup이 실행된다. → 비동기
mount시에는 child → parent순으로 실행되지만, ummout시에는 parent → child순으로 실행된다.

profile
공유할 때 행복을 느끼는 프론트엔드 개발자

0개의 댓글