React useEffect는 언제 실행될까: 렌더링 이후 실행 흐름과 cleanup 순서
빠른 답
useEffect는 렌더링 중이 아니라 렌더링 결과가 커밋된 뒤 실행된다.- 의존성 값이 바뀌면 새 effect 본문이 실행되기 전에 이전 cleanup이 먼저 실행된다.
- 빈 배열은 프로덕션에서 한 번 setup되는 패턴이지만, 개발 환경
StrictMode에서는 점검을 위해 setup과 cleanup이 한 번 더 실행될 수 있다. - DOM 측정이나 화면 반영 전 동기 보정이 필요하면
useEffect보다useLayoutEffect가 맞는지 확인해야 한다.
목차
시간 흐름으로 이해하기
useEffect를 “마운트, 업데이트, 언마운트에 호출되는 함수”로만 외우면 cleanup이 왜 업데이트 때도 실행되는지 헷갈리기 쉽다. 더 정확히는 특정 렌더의 props와 state에 맞춰 외부 시스템을 연결하고, 다음 연결이 필요해지면 이전 연결을 정리하는 흐름으로 보는 편이 이해하기 쉽다.
흐름으로 보기
이 순서에서 effect는 렌더링을 막고 값을 계산하는 장소가 아니다. 렌더링에 필요한 값은 컴포넌트 함수 안에서 계산하고, API 요청, 이벤트 구독, 타이머, 웹소켓처럼 React 바깥의 시스템과 맞춰야 하는 일을 effect에 둔다.
데이터 흐름과 상태 소유권
React 렌더링은 props와 state를 입력으로 받아 UI 결과를 계산하는 과정이다. 그래서 props나 state로 바로 계산할 수 있는 값은 별도의 state로 복사하지 않는 편이 데이터 흐름을 단순하게 만든다.
예를 들어 firstName과 lastName으로 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
댓글
댓글 쓰기