useEffect는 마운트된 뒤 실행된다. 반환한 함수는 언마운트할 때 실행된다.
처음 React를 배울 때는 이렇게 이해해도 큰 문제가 없다. 화면이 나타나면 데이터를 가져오고, 사라지면 이벤트 리스너나 타이머를 정리한다. 작은 예제에서는 설명과 실행 결과도 대체로 맞아떨어진다.
그런데 컴포넌트가 조금만 복잡해지면 이 설명으로는 부족해진다.
화면에서 사라지지도 않았는데 정리 함수가 실행된다. console.log로 확인하면 컴포넌트 함수는 실행됐는데 Effect 로그는 보이지 않는다. 개발 환경에서는 처음 접속했을 뿐인데 연결과 해제가 반복된다. 의존성 배열을 비우니 반복 실행은 멈췄지만 이번에는 예전 값으로 동작한다.
각각 다른 버그처럼 보이지만, 상당수는 세 가지를 같은 사건으로 생각하면서 생긴다.
컴포넌트 함수를 실행하는 일, 그 결과를 DOM에 반영하는 일, 외부 시스템과 동기화하는 일은 서로 다르다.
React의 라이프사이클을 깊이 이해하려면 마운트·업데이트·언마운트라는 이름을 더 외우기보다, 이 세 작업의 경계를 먼저 나누는 편이 낫다.

컴포넌트 함수가 실행됐다는 사실만으로는 부족하다
다음 컴포넌트를 보자.
1 2 3 4 5function Greeting({ name }) { console.log('render', name); return <h1>안녕하세요, {name}</h1>; }
React가 Greeting을 호출하면 우리는 흔히 “컴포넌트가 렌더링됐다”고 말한다. 이때 함수가 하는 일은 현재 입력을 바탕으로 UI의 다음 모습을 계산하는 것이다.
아직 사용자의 화면을 실제로 바꿨다는 뜻은 아니다.
React 공식 문서는 이 과정을 렌더와 커밋으로 구분한다. 렌더에서는 컴포넌트를 호출해 무엇을 표시할지 계산하고, 커밋에서는 필요한 변경을 DOM에 반영한다. 다시 렌더하더라도 결과가 같으면 DOM을 바꿀 필요가 없을 수 있다. React의 Render and Commit 설명
이 구분을 놓치면 로그를 해석할 때부터 혼란스러워진다.
render 로그가 세 번 보였다고 해서 DOM이 세 번 바뀌었다고 말할 수 없다. DOM이 바뀌었다고 해서 브라우저가 그때마다 별도의 프레임을 사용자에게 보여줬다고 단정할 수도 없다. 브라우저가 픽셀을 그리는 페인트 역시 다른 작업이다.
컴포넌트 함수 안의 로그는 “React가 이 UI를 계산해봤다”는 관찰에 가깝다. “사용자가 이 상태를 봤다”는 기록으로 사용하기에는 정보가 부족하다.
렌더 중에 외부 작업을 시작하면 왜 문제가 될까
렌더는 계산 단계이므로, 실행 횟수나 모든 계산 결과가 최종 화면에 채택될 것이라는 가정에 기대면 안 된다. React는 진행하던 렌더 작업을 다시 시도하거나, 다른 업데이트 때문에 그 결과를 사용하지 않을 수 있다.
가령 컴포넌트 본문에서 서버 연결을 시작한다고 해보자.
1 2 3 4 5 6// 잘못된 예: 렌더 중 외부 시스템을 변경한다. function Room({ roomId }) { connectToRoom(roomId); return <p>현재 방: {roomId}</p>; }
이 코드는 연결을 시작하는 시점을 “해당 방이 화면에 적용됐을 때”가 아니라 “React가 이 함수를 실행했을 때”에 묶는다.
React가 같은 컴포넌트를 다시 계산하면 연결도 다시 시도할 수 있다. 계산한 결과가 커밋되지 않더라도 이미 외부에 보낸 작업은 남는다. React가 렌더 결과를 버렸다는 이유로 서버 연결까지 알아서 되돌려주지는 않는다.
컴포넌트의 순수성을 요구하는 이유가 여기에 있다. 입력을 바탕으로 UI를 계산하는 동안, 함수 바깥에 관찰 가능한 변경을 함부로 만들지 않는 것이다. 네트워크 연결, 이벤트 구독, 타이머 같은 외부 작업은 렌더 본문과 분리해야 한다. 컴포넌트를 순수하게 유지해야 하는 이유
Effect는 이런 동기화를 커밋된 컴포넌트와 연결하는 수단이다. 커밋되지 않고 버려진 렌더에 선언된 Effect의 setup은 실행되지 않는다.
이 때문에 렌더 로그는 있지만 해당 렌더의 Effect 로그는 없는 상황이 가능하다.
하나의 컴포넌트를 끝까지 따라가보자
실제 서버 연결 대신 타이머를 사용해보자. 연결 라이브러리의 동작을 몰라도 실행 순서를 살펴볼 수 있는 예제다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37import { useEffect, useState } from 'react'; function Room({ roomId }) { console.log('[render]', roomId); useEffect(() => { console.log('[effect setup]', roomId); const timerId = window.setInterval(() => { console.log('[heartbeat]', roomId); }, 3000); return () => { window.clearInterval(timerId); console.log('[effect cleanup]', roomId); }; }, [roomId]); return <p>현재 방: {roomId}</p>; } export default function App() { const [roomId, setRoomId] = useState('A'); const [visible, setVisible] = useState(true); return ( <main> <button onClick={() => setRoomId('A')}>A 선택</button> <button onClick={() => setRoomId('B')}>B 선택</button> <button onClick={() => setVisible(value => !value)}> 표시 전환 </button> {visible ? <Room roomId={roomId} /> : null} </main> ); }
먼저 개발용 Strict Mode의 추가 검사와 중단된 렌더를 제외한 단순한 흐름으로 생각해보자. 아래 로그는 이 조건에서 설명을 위한 예상 순서이며, 특정 실행 환경에서 수집한 기록은 아니다.
처음 A가 표시되면 컴포넌트 함수가 A로 실행된다. 그 결과가 커밋된 뒤 Effect가 A를 사용하는 타이머를 시작한다.
1 2[render] A [effect setup] A
이후 B 선택 버튼을 누르면 새 roomId로 렌더한다. React는 의존성 값의 변경을 확인하고 이전 Effect를 정리한 다음 새로운 Effect를 설정한다.
1 2 3[render] B [effect cleanup] A [effect setup] B
여기서 컴포넌트는 화면에 계속 남아 있다. 그런데도 cleanup이 실행됐다.
정리 함수가 언마운트 전용 함수가 아니기 때문이다. useEffect는 의존성이 바뀐 커밋 이후 이전 값에 대한 정리를 실행하고, 새 값으로 setup을 실행한다. 컴포넌트가 제거될 때도 마지막 정리를 실행한다. useEffect의 실행 계약
표시 전환 버튼을 눌러 B를 제거하면 마지막 타이머를 정리한다.
1[effect cleanup] B
이 예제에서 타이머가 3초마다 남기는 heartbeat 로그는 컴포넌트 렌더와 별개다. 타이머 콜백이 상태를 갱신하지 않으므로, heartbeat가 찍힌다는 이유만으로 Room이 다시 렌더하는 것도 아니다.
cleanup이 A를 기억하는 이유
B가 화면에 적용되는 과정에서 cleanup은 왜 B가 아니라 A를 출력할까.
정리 함수는 “실행되는 순간의 최신 props를 찾아오는 함수”가 아니다. A를 사용해 Effect setup을 실행했을 때 만들어진 함수다. 그 함수는 당시의 roomId와 timerId를 자신의 클로저로 가지고 있다.
그래서 A의 정리 함수는 A에서 만든 타이머를 해제한다. 그 뒤 B의 setup이 새로운 타이머를 만든다.
이것은 우연히 예전 값이 남아 있는 것이 아니라, 필요한 동작이다. B로 넘어가는 과정에서 B의 자원을 정리해버리면 오히려 잘못된 결과가 된다.
React의 각 렌더는 그 시점의 props와 state를 바탕으로 계산된다. 상태를 갱신하더라도 이미 실행 중인 함수가 받은 상태 변수가 제자리에서 새 값으로 바뀌지는 않는다. 이벤트 핸들러와 콜백이 특정 렌더의 값을 기억하는 현상도 이 관점에서 이해할 수 있다. State as a Snapshot
이 차이를 모르면 예전 값이 보일 때마다 무조건 ref로 바꾸려는 대응을 하게 된다. 하지만 어떤 경우에는 예전 값을 기억하는 것이 정확한 동작이다.
cleanup은 자신이 시작한 작업을 정리해야 한다. 반면 장시간 유지하는 콜백 안에서 최신 값을 읽어야 하는 요구사항이라면 다른 설계가 필요할 수 있다. 둘을 같은 종류의 ‘오래된 값 문제’로 취급해서는 안 된다.
컴포넌트의 수명과 Effect의 수명은 일치하지 않는다
Room은 처음 나타난 뒤 A에서 B로 바뀌고, 마지막에 제거됐다.
컴포넌트 관점에서는 한 번 마운트되고 업데이트된 뒤 언마운트됐다. 하지만 타이머를 관리하는 Effect 관점에서는 A에 대한 동기화가 시작됐다가 끝났고, B에 대한 동기화가 다시 시작됐다가 끝났다.
컴포넌트 하나의 수명 안에서 Effect의 동기화 주기는 여러 번 반복될 수 있다.
React 공식 문서가 Effect를 마운트·업데이트·언마운트에 일대일로 대응시키기보다, 외부 시스템과 동기화를 시작하고 중단하는 단위로 보라고 설명하는 이유다. 반응형 Effect의 라이프사이클
이렇게 보면 코드 리뷰에서 묻는 질문도 달라진다.
“이 코드는 마운트할 때 실행하는 것인가?”보다 “이 작업은 어떤 값에 맞춰 유지돼야 하는가?”를 먼저 묻게 된다.
위 예제의 답은 roomId다. A를 표시할 때는 A의 타이머, B를 표시할 때는 B의 타이머가 있어야 한다. 의존성 배열은 이 연결을 표현한다.
반대로 테마 색상만 바뀌었는데 방 연결까지 새로 만들어진다면, 정말 그 값이 연결을 다시 시작할 조건인지 살펴봐야 한다. 서로 다른 이유로 동기화되는 작업을 한 Effect에 몰아넣었을 가능성도 있다.
의존성 배열은 실행 횟수 조절 장치가 아니다
반복 실행을 막으려고 [roomId]를 []로 바꾸면 어떻게 될까.
1 2 3 4 5 6 7 8// 잘못된 예: Effect가 사용하는 roomId를 누락했다. useEffect(() => { const timerId = window.setInterval(() => { console.log(roomId); }, 3000); return () => window.clearInterval(timerId); }, []);
처음 A로 setup이 실행됐다면 타이머 콜백은 그 렌더의 A를 기억한다. 화면의 roomId가 B로 바뀌어도 이 Effect는 그 변경에 맞춰 다시 동기화되지 않는다.
반복 실행을 없앤 대신 화면과 외부 작업의 관계를 끊은 셈이다.
의존성 배열은 Effect 내부에서 사용하는 반응형 값과 일치해야 한다. React는 각 의존성을 이전 값과 Object.is로 비교한다. 객체나 함수를 렌더마다 새로 만들면, 겉으로 같은 내용처럼 보여도 변경된 의존성으로 판단될 수 있다. useEffect 의존성 설명
그렇다고 린터가 요구하는 값을 무조건 더하고 끝내는 것도 충분하지 않다. 값이 추가될수록 Effect가 이상하게 자주 실행된다면 작업을 나눠야 하는지, 객체를 Effect 안에서 만들어도 되는지, 애초에 Effect가 필요한지 확인해야 한다.
[] 역시 앱 전체에서 정확히 한 번 실행한다는 의미는 아니다. 다시 마운트되면 setup은 다시 실행될 수 있고, 개발용 검사도 있다. “한 번만”이라는 표현에는 어느 수명 안에서 한 번인지가 빠져 있다.
Strict Mode에서 두 번 실행되는 것은 무엇을 확인하는가
개발 환경에서 루트에 Strict Mode를 적용한 경우, 처음 Effect가 다음처럼 관찰될 수 있다.
1 2 3setup(A) cleanup(A) setup(A)
추가 setup과 cleanup 주기는 개발 중 누락된 정리를 찾기 위한 검사다. Strict Mode는 순수하지 않은 렌더를 발견하도록 컴포넌트 함수를 추가 실행하는 검사도 제공한다. 이 두 검사를 전부 “React가 두 번 실행된다”로 묶으면 무엇을 고쳐야 할지 모호해진다. Strict Mode 공식 문서
타이머 예제에서 정리 함수가 없다면, setup이 반복될 때 타이머가 누적될 수 있다. Strict Mode가 문제를 만든 것처럼 보이지만 실제로는 시작한 작업을 끝내는 경로가 빠져 있다.
이때 ref에 hasRun 같은 값을 저장해 두 번째 실행을 막으면 로그는 조용해질 수 있다. 그러나 자원이 올바르게 정리되는지는 확인하지 못한다.
이벤트 리스너라면 같은 핸들러를 제거하는지, 구독이라면 해당 구독을 해제하는지, 타이머라면 자신이 생성한 타이머를 정리하는지를 봐야 한다.
다만 cleanup을 모든 부수 효과를 되돌리는 만능 롤백으로 생각해서도 안 된다. 이미 서버에 도착한 구매 요청이나 전송된 메일을 정리 함수로 없던 일로 만들 수는 없다.
사용자가 구매 버튼을 눌렀기 때문에 요청을 보내는 것이라면, 화면이 나타났다는 이유로 동작하는 Effect보다 이벤트 핸들러에 두는 것이 자연스럽다. 실행 횟수를 억지로 맞추기 전에 그 작업을 일으킨 원인이 무엇인지부터 구분해야 한다.
useEffect는 항상 화면이 그려진 뒤 실행될까
많이 보는 설명이 있다.
1render → commit → paint → useEffect
초기 이해에는 도움이 되지만, 모든 상황에 적용할 계약으로 외우면 문제가 된다.
공식 문서는 상호작용으로 발생하지 않은 Effect의 경우 일반적으로 브라우저가 먼저 화면을 그리도록 하지만, 클릭 같은 상호작용에 따른 Effect는 페인트 전에 실행될 수도 있다고 설명한다. 따라서 useEffect를 “무조건 다음 화면이 표시된 뒤 실행하는 함수”로 사용해서는 안 된다. useEffect의 페인트 관련 주의사항
반면 useLayoutEffect는 DOM 커밋 이후 브라우저가 화면을 다시 그리기 전에 실행된다. DOM 크기를 측정해 사용자가 잘못된 위치를 보기 전에 조정해야 하는 상황에서 의미가 있다.
대신 그 코드와 그 안에서 예약한 상태 업데이트는 페인트를 막을 수 있다. useEffect보다 이름에 Layout이 들어갔으니 더 정확한 시점일 것 같다는 이유로 전부 교체할 훅은 아니다. useLayoutEffect 공식 문서
위 실행 흐름 그림에 페인트를 하나의 고정된 칸으로 넣지 않은 것도 이 때문이다.
라이프사이클 그림은 이해를 돕기 위한 모델이다. 단순화한 그림이 실제 API의 보장보다 강한 약속을 해서는 안 된다.
업데이트했다고 생각했는데 사실은 새로 마운트된 경우
지금까지의 A → B 예제는 같은 위치의 Room에 다른 props가 전달되는 상황이었다.
그런데 다음처럼 key를 넣으면 의미가 달라진다.
1<Room key={roomId} roomId={roomId} />
A에서 B로 바뀔 때 React는 같은 컴포넌트의 props만 갱신하는 것이 아니라 서로 다른 식별자로 다룰 수 있다. 기존 Room의 상태를 정리하고 새로운 Room의 상태를 시작하는 방식으로 동작한다.
React는 상태를 컴포넌트 함수가 아니라 렌더 트리의 위치와 타입, key 같은 식별 관계에 연결해 보존한다. key를 바꾸면 해당 컴포넌트의 상태를 초기화하는 데 사용할 수 있다. 상태 보존과 초기화
이 차이는 입력 폼에서 잘 드러난다. 방마다 별도의 입력 초안을 시작해야 한다면 key 변경이 의도에 맞을 수 있다. 반대로 사용자가 작성하던 초안을 유지해야 한다면 예상하지 못한 초기화가 된다.
Effect 로그만 보면 두 경우 모두 이전 작업이 정리되고 새 작업이 시작되므로 비슷해 보인다. 하지만 컴포넌트가 가진 로컬 상태의 수명은 다르다.
그래서 “왜 계속 마운트되지?”라는 문제를 볼 때는 Effect 의존성만 확인하면 부족하다. 부모의 조건부 렌더, 달라지는 key, 컴포넌트 타입과 위치도 함께 확인해야 한다.
오래된 요청의 결과가 돌아오는 문제도 같은 경계에 있다
타이머는 정리하면 이후 콜백을 멈출 수 있다. 네트워크 요청은 조금 더 복잡하다.
사용자가 A 상세 화면에서 B 상세 화면으로 빠르게 이동했다고 하자. B 요청이 먼저 끝나 화면을 갱신했는데 A 요청이 늦게 도착해 같은 상태를 덮어쓰면, 현재 선택과 내용이 어긋난다.
이때 필요한 질문은 “컴포넌트가 아직 마운트돼 있는가”만이 아니다.
이 비동기 결과가 지금도 유효한 동기화 주기에 속하는가가 중요하다. 컴포넌트가 그대로 있어도 A 요청의 결과는 더 이상 현재 화면에 맞지 않을 수 있다.
직접 Effect에서 데이터를 가져온다면 이전 작업의 결과를 무시하는 처리나 취소를 고려해야 한다. 다음은 결과를 무시하는 방식의 부분 예제다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19useEffect(() => { let active = true; loadRoom(roomId) .then(result => { if (active) { setRoom(result); } }) .catch(error => { if (active) { setError(error); } }); return () => { active = false; }; }, [roomId]);
active는 컴포넌트 전체가 공유하는 값이 아니라, 각 Effect setup이 가진 지역 변수다. A의 cleanup이 실행되면 A의 결과를 더 이상 반영하지 않도록 한다.
이 코드는 네트워크 전송 자체를 취소하지 않는다. 로딩 상태, 이전 데이터 표시, 오류 초기화까지 포함한 완성된 데이터 패칭 로직도 아니다. 여기서는 정리 함수가 비동기 결과의 유효 범위를 끝내는 데 쓰일 수 있다는 점만 보여준다. 공식 useEffect 문서 역시 직접 데이터를 가져올 때 이런 경쟁 상태를 다룬다. Effect의 데이터 패칭 예제
실제 앱에서는 라우터나 서버 상태 라이브러리가 이 책임을 맡을 수 있다. 그렇더라도 원리를 알아두면 라이브러리의 취소·캐시·재검증 기능이 무엇을 대신 관리하는지 이해하기 쉬워진다.
라이프사이클을 이해하면 Effect를 덜 쓰게 된다
Effect를 이해할수록 모든 변화에 Effect를 붙이는 코드를 작성할 것 같지만, 실제로는 반대에 가깝다.
다음처럼 이름을 합치는 데 Effect를 사용할 필요는 없다.
1 2 3 4 5 6// 불필요한 파생 상태 const [fullName, setFullName] = useState(''); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]);
이 코드는 현재 값으로 UI를 계산할 수 있는데도, 커밋 이후 Effect가 상태를 다시 설정하는 경로를 만든다.
1const fullName = `${firstName} ${lastName}`;
외부 시스템과 동기화하는 일이 없다면 렌더 과정에서 계산하는 것으로 충분할 수 있다. React 공식 문서도 화면에 표시할 데이터를 변환하거나 특정 사용자 이벤트를 처리하기 위해 Effect를 남용하지 않도록 설명한다. Effect가 필요하지 않을 수 있는 경우
이렇게 보면 라이프사이클은 “어느 훅에 코드를 넣을 것인가”를 고르는 표가 아니다.
현재 입력에서 계산할 수 있는 것은 렌더에 남긴다. 사용자의 특정 행동 때문에 발생하는 일은 이벤트에서 처리한다. 화면에 적용된 상태를 외부 시스템과 맞춰야 할 때 Effect를 사용한다.
코드의 위치를 실행 시점만으로 고르는 대신, 그 작업이 왜 존재하는지를 기준으로 고르는 것이다.
로그를 더 찍기 전에 먼저 나눠볼 것
라이프사이클 문제가 생기면 로그를 여러 군데 추가하게 된다. 하지만 관찰하는 대상을 구분하지 않으면 로그가 많아져도 설명은 어려워진다.
컴포넌트 본문의 로그는 렌더 계산을 보여준다. Effect의 setup과 cleanup 로그는 동기화 주기를 보여준다. 사용자가 실제로 본 화면과 브라우저 페인트는 또 다른 관찰 대상이다.
cleanup이 실행됐다고 바로 언마운트라고 단정하지 않고, 렌더 로그가 추가됐다고 DOM이 새로 만들어졌다고 단정하지 않는 것만으로도 진단 범위가 줄어든다.
예상보다 자주 Effect가 실행된다면 의존성의 값과 동일성을 본다. 로컬 상태까지 초기화된다면 key와 트리 위치를 본다. 개발 환경에서만 setup과 cleanup이 추가된다면 Strict Mode의 검사인지 확인한다. 화면은 B인데 A의 결과가 나타난다면 비동기 작업의 유효 범위를 본다.
마운트·업데이트·언마운트라는 세 단어는 여전히 유용하다. 다만 그것은 컴포넌트가 화면에 존재하는 과정을 설명하는 이름이다. 그 안에서 렌더는 여러 번 계산될 수 있고, 각각의 Effect는 자신만의 이유로 시작하고 끝난다.
이 두 수명을 분리해서 보면, 이상하게 보이던 로그 상당수가 설명된다. 그리고 해결책도 ‘한 번만 실행되게 막기’에서 ‘시작한 작업을 올바른 조건으로 유지하고 끝내기’로 바뀐다.
함수 컴포넌트와 클라이언트의 일반적인 표시·변경·제거 흐름을 중심으로 설명했다. 서버 렌더링 중에는 Effect가 실행되지 않는다. 숨김 상태를 보존하는 Activity 등은 단순 제거와 수명 조건이 다르므로 이 글의 언마운트 흐름과 그대로 동일시하지 않는다.
