기본 콘텐츠로 건너뛰기

React useEffect는 언제 실행될까: 렌더링 이후 실행 흐름과 cleanup 순서

React useEffect는 언제 실행될까: 렌더링 이후 실행 흐름과 cleanup 순서

빠른 답

  • useEffect는 렌더링 중이 아니라 렌더링 결과가 커밋된 뒤 실행된다.
  • 의존성 값이 바뀌면 새 effect 본문이 실행되기 전에 이전 cleanup이 먼저 실행된다.
  • 빈 배열은 프로덕션에서 한 번 setup되는 패턴이지만, 개발 환경 StrictMode에서는 점검을 위해 setup과 cleanup이 한 번 더 실행될 수 있다.
  • DOM 측정이나 화면 반영 전 동기 보정이 필요하면 useEffect보다 useLayoutEffect가 맞는지 확인해야 한다.

시간 흐름으로 이해하기

렌더링 시작
props와 state로 다음 UI를 계산한다.
DOM 반영
계산된 결과가 커밋되어 실제 DOM에 반영된다.
화면 갱신
브라우저가 변경된 화면을 그릴 수 있다.
effect 실행
현재 렌더의 값으로 setup 함수가 실행된다.
의존성 변경
이전 cleanup이 먼저 실행되고 새 setup이 이어진다.

useEffect를 “마운트, 업데이트, 언마운트에 호출되는 함수”로만 외우면 cleanup이 왜 업데이트 때도 실행되는지 헷갈리기 쉽다. 더 정확히는 특정 렌더의 props와 state에 맞춰 외부 시스템을 연결하고, 다음 연결이 필요해지면 이전 연결을 정리하는 흐름으로 보는 편이 이해하기 쉽다.

흐름으로 보기

흐름 다이어그램
React useEffect는 언제 실행될까: 렌더링 이후 실행 흐름과 cleanup 순서 흐름 다이어그램

이 순서에서 effect는 렌더링을 막고 값을 계산하는 장소가 아니다. 렌더링에 필요한 값은 컴포넌트 함수 안에서 계산하고, API 요청, 이벤트 구독, 타이머, 웹소켓처럼 React 바깥의 시스템과 맞춰야 하는 일을 effect에 둔다.

데이터 흐름과 상태 소유권

React 렌더링은 props와 state를 입력으로 받아 UI 결과를 계산하는 과정이다. 그래서 props나 state로 바로 계산할 수 있는 값은 별도의 state로 복사하지 않는 편이 데이터 흐름을 단순하게 만든다.

예를 들어 firstNamelastName으로 fullName을 만들 수 있다면 effect로 다시 state에 저장할 필요가 거의 없다.

import { useEffect, useState } from "react";

function ProfileWithCopiedState({ firstName, lastName }) {
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <span>{fullName}</span>;
}

function Profile({ firstName, lastName }) {
  const fullName = `${firstName} ${lastName}`;

  return <span>{fullName}</span>;
}

첫 번째 코드는 렌더링 후 effect가 실행되고, 그 안에서 다시 state를 바꾸므로 렌더링이 한 번 더 발생한다. 원본 값은 props인데 복사본 state가 추가되면서 “진짜 값의 소유자”도 흐려진다.

두 번째 코드처럼 현재 렌더에서 바로 계산하면 상태 소유권이 분명하다. 부모가 내려준 props가 바뀌면 컴포넌트가 다시 렌더링되고, 그 렌더에서 fullName도 함께 다시 계산된다.

생명주기보다 외부 시스템 동기화로 보기

오래된 설명에서는 useEffect를 클래스 컴포넌트의 componentDidMount, componentDidUpdate, componentWillUnmount와 연결해 설명하는 경우가 많았다. 입문 단계에서는 유용하지만, 현재 React 문서의 기준은 조금 다르다. 공식 문서는 useEffect를 “컴포넌트를 외부 시스템과 동기화하는 Hook”으로 설명한다. 참고: React useEffect 공식 문서

현재 기준으로는 세 가지를 함께 보는 편이 좋다.

  • setup: 외부 시스템에 연결한다.
  • cleanup: 이전 setup이 만든 연결을 해제한다.
  • dependencies: 어떤 렌더 값이 바뀔 때 다시 동기화할지 표현한다.

이 관점에서는 컴포넌트 전체의 생명주기보다 effect 하나의 생명주기가 더 중요하다. roomId"react"일 때 만든 구독은 roomId"next"로 바뀌기 전에 해제되어야 한다. React는 이 순서를 위해 새 setup 전에 이전 cleanup을 먼저 실행한다.

2026년 4월 기준 React 문서는 React 19 계열 기준으로 읽는 것이 맞다. React 18 이후 개발 환경 StrictMode에서는 effect setup과 cleanup을 한 번 더 실행해 cleanup 누락을 드러내며, React 19.2에서는 useEffectEvent가 문서화되어 effect 안의 이벤트성 로직과 재동기화 조건을 나누는 선택지도 생겼다. 참고: React 19.2 릴리스 글, useEffectEvent 공식 문서

effect 실행과 cleanup 순서

아래 예시는 roomId가 바뀔 때 이전 구독을 해제하고 새 구독을 시작하는 흐름을 보여준다. 채팅방, 알림 채널, 실시간 가격 구독처럼 “현재 값에 맞는 외부 연결”을 유지해야 하는 코드가 이 패턴에 가깝다.

import { useEffect } from "react";

function ChatSubscription({ roomId }) {
  useEffect(() => {
    console.log("setup", roomId);

    const unsubscribe = subscribeToRoom(roomId);

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

  return null;
}

function subscribeToRoom(roomId) {
  console.log("subscribe", roomId);

  return () => {
    console.log("unsubscribe", roomId);
  };
}

처음 roomId"react"로 렌더링되면 setup이 실행된다. 이후 roomId"next"로 바뀌면 React는 새 setup을 바로 실행하지 않고, 이전 렌더의 cleanup을 먼저 실행한다.

초기 마운트
setup react
subscribe react

roomId 변경: react -> next
cleanup react
unsubscribe react
setup next
subscribe next

개발 환경 StrictMode의 첫 마운트 점검
setup react
subscribe react
cleanup react
unsubscribe react
setup react
subscribe react

이 로그에서 cleanup은 컴포넌트가 완전히 사라졌다는 뜻만은 아니다. 이전 effect가 만든 연결을 끝낸다는 의미에 가깝다. 따라서 이벤트 리스너를 등록했다면 제거하고, 타이머를 시작했다면 멈추고, 네트워크 요청을 시작했다면 취소할 수 있는 구조를 함께 두는 편이 안전하다.

의존성 배열은 실행 횟수가 아니라 반응 범위다

의존성 배열은 effect가 어떤 렌더 값에 반응할지 나타낸다. 배열을 생략하면 매 커밋 뒤 실행되고, 빈 배열을 넘기면 effect가 반응해야 할 reactive value가 없다는 뜻에 가깝다. 특정 값을 넣으면 그 값이 이전 렌더와 달라졌을 때 cleanup과 setup이 다시 실행된다.

  • 의존성 배열 생략: 매 렌더링 커밋 이후 실행된다.
  • 빈 배열: 프로덕션에서는 마운트 후 setup, 언마운트 시 cleanup 흐름으로 동작한다.
  • 값이 있는 배열: 해당 값이 바뀐 커밋 이후 이전 cleanup, 새 setup 순서로 실행된다.

의존성 배열을 “실행 횟수 조절 장치”로만 보면 오래된 props나 state를 읽는 버그가 생기기 쉽다. effect 안에서 읽는 값이 렌더마다 바뀔 수 있다면 의존성에 포함하는 것이 기본 흐름이다. 예외가 필요할 때는 값을 ref로 옮길지, 로직을 이벤트 핸들러로 옮길지, React 19.2 기준 useEffectEvent가 맞는지 따로 나누어 봐야 한다.

프로젝트에서는 eslint-plugin-react-hooks를 켜 두면 의존성 누락을 빠르게 발견할 수 있다.

import reactHooks from "eslint-plugin-react-hooks";

export default [
  {
    plugins: {
      "react-hooks": reactHooks
    },
    rules: {
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn"
    }
  }
];

React 19.2 릴리스에서는 eslint-plugin-react-hooks v6도 함께 언급된다. 기존 프로젝트를 올릴 때는 React 버전만 보지 말고 Hooks lint 규칙도 같이 확인하는 것이 좋다.

API 요청과 타이머에서의 cleanup

API 호출은 effect에서 자주 다루는 작업이지만, 응답이 늦게 도착했을 때 이전 요청 결과가 최신 화면을 덮어쓸 수 있다. userId가 바뀌면 이전 요청을 취소하도록 cleanup을 두면 이 흐름이 더 분명해진다.

import { useEffect, useState } from "react";

function UserLoader({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function loadUser() {
      const response = await fetch(`/api/users/${userId}`, {
        signal: controller.signal
      });

      const data = await response.json();
      setUser(data);
    }

    loadUser().catch((error) => {
      if (error.name !== "AbortError") {
        console.error("user load failed", error);
      }
    });

    return () => {
      controller.abort();
    };
  }, [userId]);

  if (!user) {
    return <p>Loading...</p>;
  }

  return <p>{user.name}</p>;
}

이 코드에서 화면이 어떤 사용자를 보여줘야 하는지는 userId가 결정한다. 따라서 userId가 바뀌면 이전 요청은 더 이상 현재 화면의 소유 데이터가 아니다. cleanup에서 abort()를 호출하면 이전 요청의 결과가 늦게 도착해 현재 화면을 덮어쓰는 상황을 줄일 수 있다.

타이머도 같은 원리로 보면 된다. setInterval을 시작했다면 cleanup에서 clearInterval을 호출해야 한다. 카운트를 1씩 올리는 타이머처럼 현재 state를 기반으로 다음 state를 만들 때는 setCount((current) => current + 1) 형태의 함수형 업데이트를 사용하면, interval을 매번 다시 만들지 않아도 된다.

Strict Mode 로그가 두 번 보일 때

React 18 이후 개발 환경에서 StrictMode를 사용하면 첫 마운트 시 effect가 setup, cleanup, setup 순서로 한 번 더 실행될 수 있다. 이는 프로덕션에서 같은 작업을 두 번 수행한다는 뜻이 아니라, cleanup이 setup을 제대로 되돌리는지 확인하는 개발 전용 점검이다. 참고: React StrictMode 공식 문서

오래된 설명의 “빈 배열이면 마운트 때 한 번만 실행”이라는 문장은 프로덕션 동작을 단순화한 표현에 가깝다. 현재 기준으로는 “프로덕션에서는 컴포넌트가 화면에 추가된 뒤 한 번 setup되고, 개발 StrictMode에서는 cleanup 검증을 위해 setup과 cleanup이 한 번 더 실행될 수 있다”라고 이해하는 편이 더 정확하다.

이 점검에서 문제가 드러나는 대표 사례는 cleanup 없는 이벤트 리스너다. 등록만 하고 제거하지 않으면 개발 환경에서 같은 이벤트가 두 번 처리되는 로그가 보일 수 있다. 이때 문제는 StrictMode가 아니라, setup을 되돌리는 cleanup이 빠진 effect 구조에 있다.

useLayoutEffect와 화면 반영 전 작업

useEffect는 보통 브라우저가 화면을 갱신한 뒤 실행된다. 그래서 DOM 크기를 측정해 위치를 즉시 보정해야 하거나, 사용자가 중간 레이아웃을 보면 안 되는 경우에는 화면 깜빡임이 보일 수 있다.

이런 경우에는 useLayoutEffect를 검토할 수 있다. useLayoutEffect는 브라우저가 화면을 다시 그리기 전에 실행되는 effect이며, 그 안에서 발생한 state 업데이트는 페인트를 막을 수 있다. 따라서 레이아웃 측정이나 위치 보정처럼 화면 반영 전 처리가 필요한 좁은 영역에 쓰는 편이 부담이 적다. 참고: React useLayoutEffect 공식 문서

일반적인 API 요청, 구독, 타이머, 로그 전송은 대개 useEffect로 충분하다. 반대로 레이아웃 측정, 스크롤 위치 보정, 툴팁 위치 계산처럼 페인트 전 동기 처리가 필요한 작업은 useLayoutEffect가 더 맞을 수 있다.

흔한 안티패턴 정리

첫 번째는 파생 값을 effect로 다시 state에 저장하는 코드다. props나 state에서 계산할 수 있는 값이라면 현재 렌더에서 계산하는 편이 데이터 소유권이 분명하고 리렌더링도 줄어든다.

두 번째는 의존성 배열을 일부러 비워 오래된 값을 붙잡는 코드다. effect 안에서 어떤 값을 읽고 있다면 그 값이 바뀌었을 때 다시 동기화되어야 하는지 먼저 봐야 한다. 다시 실행되면 안 되는 이벤트성 로직이라면 effect 자체와 분리하는 방식이 더 잘 맞을 수 있다.

세 번째는 cleanup이 없는 외부 연결이다. 이벤트 리스너, 타이머, 웹소켓, 구독은 setup과 cleanup이 한 쌍으로 읽혀야 한다. 그래야 의존성 변경, 언마운트, 개발 환경 StrictMode 점검에서도 같은 연결이 중복해서 남지 않는다.

useEffect의 실행 시점은 “렌더링 후”라는 한 문장으로 시작할 수 있지만, 실제 코드를 안정적으로 만들려면 조금 더 봐야 한다. 어떤 값이 상태를 소유하는지, 어떤 외부 시스템과 연결하는지, 어떤 값이 바뀔 때 다시 연결해야 하는지, 이전 연결을 어떻게 정리할지까지 함께 봐야 cleanup 순서와 리렌더링 흐름이 선명해진다.

원문 참고

https://www.maeil-mail.kr/question/64

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...