#11. Component Lifecycle & useEffect
목차10
TL;DR
- 컴포넌트의 생명주기: mount(생성) → update(재렌더) → unmount(삭제)
- sideEffect: 핵심 기능 외의 나머지 기능들
- ex) 네트워크, 타이머, 구독처럼, 화면 밖과 관련된 로직들
useEffecthook을 통해, 컴포넌트의 생명주기가 변할 때의 동작을 정의할 수 있음
개요
컴포넌트 역시 나름의 일생을 가진다.
- mount: 화면에 처음 그려짐 (생성)
- update: state/props가 바뀌어 다시 그려짐 (재렌더)
- unmount: 화면에서 제거됨 (삭제)
이전까지의 state/props가 UI의 update를 촉발했다면,
이번에 다룰 useEffect는 사용하기에 따라 mount, update, unmount 시점의 행동을 정의한다.
useEffect
side effect
컴포넌트의 본업은 “props/state를 받아 JSX를 반환” 하는 순수한 함수이다.
즉, 자신의 본업을 벗어나 “외부 세계와 주고받는 모든 것들”은 side effect라고 한다.
useEffect라는 이름에서처럼, 함수는 위와 같은 side effect를 적절한 시기에 호출하기 위해 사용한다.
사용
import { useEffect } from 'react';
function Detail() {
useEffect(() => {
console.log('안녕'); // 렌더 이후에 실행됨
});
return (/* 생략 */);
}
사실 그냥 console.log()로 로그를 찍어도 눈에 보이는 결과는 같지만, 그 과정이 다르다.
일반적으로 삽입한 함수는 언제든지 감싼 컴포넌트가 update 될 시, 재호출 될 수 있다.
따라서 Card 컴포넌트 안에 데이터를 그리겠다고 Card 안에서 data fetch 로직을 작성할 경우,
모종의 이유로 카드 상태가 변경되거나 다시 그려질 경우에도 무의미한 data fetch가 발생할 수 있다.
혹은 컴포넌트를 그리는 도중 무거운 동기 함수(반복문 등)가 존재할 경우,
이 작업이 끝나기 전까지 해당 컴포넌트가 온전히 그려지지 않고 대기하게 되어 UI가 깨져보일 수 있다.
그에 반해, useEffect()는 선언 위치와 상관 없이 모든 HTML이 화면에 그려진 이후에 비동기로 동작한다.
따라서 “일단 화면 다 띄우고 실행하고, 데이터 들어오면 그때 데이터 채워넣자”와 같은 시나리오,
혹은 “일단 UI 띄워서 골격은 맞춰두고, 이후에 필요한 작업 끝나면 그때 마저 그리자”와 같은 상황에서 유용하다.
DOM을 측정해서 화면에 반영해야 하는, paint 전에 동기로 돌아야 하는 예외적인 경우엔
useLayoutEffect를 쓴다.
대부분은useEffect로 충분하다.
실행 조건 추가
useEffect는 어떤 인자를 주느냐에 따라서 실행 시점이 달라진다.
특히 두 번째 인자로 의존성 배열을 주면 실행 시점을 통제할 수 있는데, 크게 다음의 3가지 사용법이 있다.
| 형태 | 실행 시점 |
|---|---|
useEffect(fn) | 매 렌더 후 (mount + 모든 update) |
useEffect(fn, []) | mount 시 1회만 |
useEffect(fn, [a, b]) | mount + a or b가 바뀔 때 |
여기서, a, b는 effect 안에서 사용하는 state나 props를 모두 포함하여야 한다.
그런데 여기서 조심해야 할 점이 있다.
React는 의존성을 Object.is로 참조 비교(주소값이 같은지)한다 — 자세한 내용은 여기 참고.
따라서 만약 아래와 같은 코드가 있을 경우, useEffect는 의도치 않게 실행될 수 있다:
const arr = [1, 2, 3]; // JS의 입장에서, re-render 이전의 arr과 지금의 arr은 다른 주소값을 참조.
useEffect(() => { ... }, [ arr ]);
// 사용자 생각에는 값이 바뀌지 않았지만, re-render가 발생하면 useEffect가 트리거된다.
Cleanup function
Cleanup 함수는 ‘정리하는’ 함수이다.
useEffect 안에서 어떤 다른 함수를 return하면, 이것이 곧 cleanup function이 된다.
useEffect(() => {
// 2. 새 effect 실행
return () => {
// 1. (이전) cleanup 먼저 실행
};
}, [id]);
순서상 1, 2번으로 구분하긴 했는데, 처음 mount 시에는 해제할 자원이 없으므로 cleanup이 실행되지 않는다는 점에 유의.
즉, mount 이후에는 처음 2번(새 effect 실행)만 동작하고, 이후 update가 발생하면 그때 (이전) Cleanup과 새 effect가 실행된다.
그리고 여기서 중요한 사실이 두 가지 있다.
- React는 재렌더가 일어나도 이전 렌더의 비동기 작업을 자동으로 취소하지 않는다.
- 대신, useEffect가 호출되기 전, 이전 작업을 우아하게 종료할 수 있도록 Clean up 함수를 호출한다.
예를들어 상품을 보여주는 화면이 있다고 할 때, 아래와 같은 흐름으로 진행될 것이다:
- 사용자가 id=1인 상품을 조회
- 브라우저가 id=1인 상품의 데이터를 서버에 요청
- 사용자가 id=2인 상품을 조회
- 브라우저가 id=2인 상품의 데이터를 서버에 요청
이를 코드로 간단히 나타내자면 아래와 같을 것이다:
useEffect(() => {
let ignore = false;
fetchData(id).then(res => {
if (!ignore) setData(res); // 최신 요청 결과만 반영
});
return () => { ignore = true; }; // 이전 effect의 응답은 버린다
}, [id]);
이때, 만약 id=1인 상품의 정보가 우연히 id=2인 상품보다 나중에 도착하게 된다면 어떻게 될까?
이때는 url 상에서는 id=2인데, 화면에는 id=1인 상품의 정보가 나타나는 이상한 현상이 발생할 수 있다!
이런 현상을 막기 위해, 사용자가 id=2인 상품을 조회한다면 →
누군가는 이전 id=1인 상품의 조회를 멈추거나, 그 결과를 화면에 반영하지 말아야 한다.
그런데 여기서 또 한가지 문제가 발생한다.
3번에서 화면이 Re-render되면 → useEffect와 cleanup 함수도 다시 구성되는데 →
이미 이전에 실행중인 비동기 작업을 끝내거나, 결과를 반영하라 하지마라를 어떻게 조작할 수 있을까?
다행히 JavaScript에서 정의된 함수는 **“Closure(클로저)” **함수로,
본인이 생성되는 시점(즉 Re-render 이전)의 상태를 그대로 기억하고 있다.
즉, Re-render 되기 이전, id=1인 시절 생성되었던 ignore과 id 값과,
Re-render 이후 id=2로 바뀌고 새로 생성된 ignore과 id는 별도의 저장 공간에 생성된다.
따라서 id=1인 시절에 호출되었던 fetchData가 온전히 데이터를 받아오기 전에 return () => { ignore = true; };이 호출되었다면,
id=1인 시절의 ignore가 true로 바뀌므로 setData(res)가 호출되지 않고 함수가 종료되는 것이다.
물론 이와 무관하게, re-render 이후 생성된 useEffect는 id=2인 상품의 정보를 조회할 것이고,
본인의 ignore 값이 false에서 바뀌지 않았으므로 setData(res)를 호출해서 데이터를 집어넣을 것이다.
React.StrictMode
그런데 정작, npm run dev를 쳐서 콘솔을 확인해보면,
import { useEffect } from 'react';
function Detail() {
useEffect(() => {
console.log('안녕'); // 렌더 이후에 실행됨
});
return (/* 생략 */);
}
“안녕”이 두번 떠있을 것이다.
이는 치명적인 문제가 아니라, 의도적으로 설계된 디버깅 동작이다.
main.jsx를 보면, 최상단 <App />이 <React.StrictMode>로 감싸여 있음을 확인할 수 있다:
<React.StrictMode>
<App />
</React.StrictMode>
StrictMode는 개발 빌드에서만, 컴포넌트를 일부러 mount → unmount → mount 로 한 번 더 돌려준다.
개발자는 이 과정에서 mount/unmount 시 useEffect와 cleanup 함수가 잘 동작했는지를 확인할 수 있다.
프로덕션에서는 문제 없으므로, 굳이 뺄 필요는 없고 오히려 놔두는 것이 권장된다.
참고로 StrictMode는 effect뿐 아니라 렌더 함수와 setState도 이중 호출한다.
그런데, 대부분 useEffect는 필요 없다
공부하면서 제일 인상 깊었던 파트다.
useEffect의 정당한 용도는 사실상 하나 — 화면 “밖”의 무언가와 동기화할 때다 (네트워크, 타이머, DOM API, 구독).
그 외엔 대개 필요 없다. 짧게 정리해보자면,
- 기존 state로 계산 가능한 값
- effect로 다른 state에 복사하지 말고, 그냥 렌더 중에 계산한다.
- 사용자 행동에 대한 반응
- 버튼 클릭처럼 특정 행동으로 벌어지는 일은 effect가 아니라 이벤트 핸들러를 사용한다.
- props가 바뀔 때 state 초기화
- 컴포넌트에
key를 준다. - key가 바뀌면 컴포넌트가 통째로 새로 mount돼서 state가 초기화된다.
- 컴포넌트에
Data fetching
raw하게 useEffect로 fetch하면 레이스 컨디션, 중복 요청, 캐시 같은 여러 보일러플레이트들을 매번 다시 짜야한다.
그래서 요즘은 React Router의 loader, TanStack Query 같은 라이브러리를 쓰는 추세이다.
