2022. 12. 24. 22:26ㆍFrontend/React

React를 처음 배울 때 우리는 보통 컴포넌트 생명주기를 다음 세 단계로 이해한다.
Mount → Update → Unmount
컴포넌트가 화면에 처음 나타나고, 상태나 props가 바뀌면서 다시 렌더링되고, 더 이상 필요 없어지면 화면에서 제거된다. 이 설명은 틀리지 않다. 하지만 현대 React를 실무에서 제대로 사용하려면 이 정도 이해만으로는 부족하다.
특히 함수 컴포넌트와 Hooks 중심의 React에서는 생명주기를 단순히 componentDidMount, componentDidUpdate, componentWillUnmount에 대응시키는 방식으로 이해하면 오히려 코드를 잘못 설계하기 쉽다.
React의 생명주기를 제대로 이해하려면 다음 질문으로 접근해야 한다.
컴포넌트는 언제 다시 실행되는가?
렌더링은 실제 DOM 변경과 같은가?
Effect는 컴포넌트 생명주기와 동일하게 움직이는가?
상태는 컴포넌트 내부에 저장되는가, React가 따로 기억하는가?
cleanup은 언제 실행되는가?
Strict Mode에서 Effect가 두 번 실행되는 이유는 무엇인가?
이 글에서는 React 생명주기를 클래스 컴포넌트의 과거 모델이 아니라, 현대 React의 동작 방식에 맞춰 다시 정리한다.
1. React 생명주기를 바라보는 두 가지 관점
React 생명주기는 크게 두 가지 관점으로 나눠볼 수 있다.
첫 번째는 컴포넌트의 생명주기다.
Mount: 컴포넌트가 화면에 처음 추가됨
Update: props, state, context 변경 등으로 다시 렌더링됨
Unmount: 컴포넌트가 화면에서 제거됨
두 번째는 렌더링 작업의 생명주기다.
Trigger: 렌더링을 유발하는 변화 발생
Render: React가 컴포넌트를 호출해 UI 결과 계산
Commit: 계산된 결과를 실제 DOM에 반영
예전에는 컴포넌트 생명주기를 중심으로 React를 설명하는 경우가 많았다. 클래스 컴포넌트에서는 componentDidMount, componentDidUpdate, componentWillUnmount 같은 메서드가 명확히 존재했기 때문이다.
하지만 함수 컴포넌트와 Hooks 중심의 React에서는 “컴포넌트가 마운트됐을 때 이 코드를 실행한다”보다 “이 컴포넌트가 외부 시스템과 어떤 동기화를 해야 하는가”라는 관점이 더 중요하다.
즉, 현대 React에서 생명주기를 이해한다는 것은 다음 세 가지를 구분하는 일이다.
1. 컴포넌트가 화면에 존재하는 기간
2. React가 UI를 계산하고 DOM에 반영하는 과정
3. Effect가 외부 시스템과 동기화되는 과정
이 세 가지를 섞어서 이해하면 useEffect를 과하게 사용하거나, 불필요한 상태를 만들거나, cleanup을 놓치거나, stale closure 문제를 만나기 쉽다.
2. React의 화면 업데이트는 Trigger → Render → Commit 순서로 진행된다
React에서 화면이 업데이트되는 과정은 크게 세 단계로 나눌 수 있다.
Trigger → Render → Commit
2.1 Trigger: 렌더링이 시작되는 순간
렌더링은 아무 때나 일어나지 않는다. React가 다시 UI를 계산해야 할 이유가 생겼을 때 렌더링이 트리거된다.
대표적인 렌더링 트리거는 다음과 같다.
1. 컴포넌트의 state가 변경됨
2. 부모 컴포넌트가 다시 렌더링되면서 props가 변경됨
3. context 값이 변경됨
4. 외부 store 구독 값이 변경됨
5. root에서 다시 render가 호출됨
예를 들어 다음 코드를 보자.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
버튼을 클릭하면 setCount가 호출된다. 이때 React는 count 값이 바뀌었다는 것을 알고 해당 컴포넌트를 다시 렌더링할 준비를 한다.
중요한 점은 setCount가 호출되는 순간 즉시 DOM이 바뀌는 것이 아니라는 점이다. setCount는 React에게 “상태가 바뀌었으니 다음 UI를 다시 계산해야 한다”고 알리는 요청에 가깝다.
2.2 Render: 컴포넌트를 호출해 다음 UI를 계산하는 단계
렌더 단계에서 React는 컴포넌트 함수를 다시 호출한다.
function Counter() {
console.log("Counter render");
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
count가 변경되면 Counter 함수가 다시 실행된다. 함수가 다시 실행되면서 새로운 JSX가 만들어지고, React는 이전 렌더 결과와 새 렌더 결과를 비교해 무엇이 달라졌는지 계산한다.
여기서 주의해야 할 점이 있다.
렌더링은 DOM 변경이 아니다.
렌더링은 다음 UI가 어떻게 생겨야 하는지 계산하는 단계다.
렌더 단계에서는 실제 DOM을 직접 변경하면 안 된다. 렌더링은 순수해야 한다.
좋지 않은 예시는 다음과 같다.
function BadComponent() {
document.title = "렌더 중 변경";
return <div>Hello</div>;
}
이 코드는 컴포넌트 함수가 실행되는 렌더 단계에서 외부 세계인 document.title을 변경한다. 이런 작업은 렌더링의 순수성을 깨뜨린다.
더 적절한 방식은 Effect를 사용하는 것이다.
function GoodComponent() {
useEffect(() => {
document.title = "렌더 이후 변경";
}, []);
return <div>Hello</div>;
}
React 컴포넌트는 같은 props, state, context가 주어졌을 때 같은 JSX를 반환하는 순수 함수처럼 동작해야 한다. 외부 시스템과 동기화하는 작업은 렌더 단계가 아니라 commit 이후의 Effect 단계로 분리하는 것이 좋다.
2.3 Commit: 계산된 변경 사항을 실제 DOM에 반영하는 단계
렌더 단계가 끝나면 React는 실제로 변경이 필요한 부분을 DOM에 반영한다. 이 단계가 commit이다.
예를 들어 Count: 0에서 Count: 1로 변경되었다면 React는 필요한 DOM 텍스트만 업데이트한다.
Render 단계:
다음 UI를 계산한다.
Commit 단계:
계산 결과를 실제 DOM에 반영한다.
React는 렌더링 결과가 이전과 같다면 불필요하게 DOM을 변경하지 않는다. 따라서 “컴포넌트 함수가 다시 실행되었다”와 “실제 DOM이 변경되었다”는 같은 말이 아니다.
이 구분은 성능 최적화에서 매우 중요하다.
리렌더링이 발생했다 = 컴포넌트 함수가 다시 호출되었다
DOM 업데이트가 발생했다 = React가 실제 DOM을 변경했다
리렌더링 자체가 항상 문제는 아니다. 문제는 불필요한 계산이 많거나, DOM 변경이 과도하거나, 렌더링으로 인해 무거운 자식 컴포넌트까지 계속 다시 계산되는 상황이다.
3. Mount, Update, Unmount 다시 이해하기
이제 전통적인 컴포넌트 생명주기를 현대 React 관점에서 다시 보자.
3.1 Mount: 컴포넌트가 처음 화면에 등장하는 시점
Mount는 컴포넌트가 렌더 트리에 처음 추가되고, 그 결과가 DOM에 반영되는 시점이다.
function UserProfile() {
useEffect(() => {
console.log("mounted");
return () => {
console.log("unmounted");
};
}, []);
return <div>User Profile</div>;
}
의존성 배열이 빈 배열인 useEffect는 일반적으로 컴포넌트가 처음 commit된 뒤 한 번 실행된다.
초기 렌더링
→ DOM 반영
→ Effect 실행
여기서 “마운트 시점에 실행한다”고 말해도 크게 틀리지는 않지만, 더 정확히는 “초기 commit 이후 외부 시스템과 동기화한다”라고 이해하는 것이 좋다.
마운트 시점에 자주 하는 작업은 다음과 같다.
1. API 요청 시작
2. 이벤트 리스너 등록
3. 타이머 시작
4. 외부 라이브러리 초기화
5. 브라우저 API와 동기화
예를 들어 브라우저 resize 이벤트를 구독하는 코드는 다음처럼 작성할 수 있다.
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return <p>현재 너비: {width}px</p>;
}
이 코드는 컴포넌트가 화면에 나타난 뒤 resize 이벤트를 구독하고, 컴포넌트가 사라질 때 구독을 해제한다.
여기서 중요한 점은 setup과 cleanup이 한 쌍이라는 점이다.
setup: window.addEventListener
cleanup: window.removeEventListener
Effect는 단순히 “마운트 때 실행되는 코드”가 아니라, 외부 시스템과의 연결을 시작하고 끊는 동기화 단위로 이해해야 한다.
3.2 Update: state, props, context 변경으로 다시 계산되는 시점
Update는 컴포넌트가 이미 화면에 있는 상태에서 다시 렌더링되는 과정이다.
function SearchResult({ keyword }) {
useEffect(() => {
console.log("keyword changed:", keyword);
}, [keyword]);
return <div>검색어: {keyword}</div>;
}
keyword prop이 변경되면 컴포넌트가 다시 렌더링된다. 그리고 commit 이후 Effect가 다시 실행된다.
이때 React는 새 Effect를 실행하기 전에 이전 Effect의 cleanup을 먼저 실행한다.
keyword = "react"
→ Effect 실행
keyword = "next"
→ 이전 Effect cleanup
→ 새 Effect 실행
이 동작은 외부 시스템을 최신 값과 동기화하는 데 매우 중요하다.
예를 들어 채팅방 연결 코드를 보자.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <div>{roomId}번 채팅방</div>;
}
roomId가 바뀌면 기존 방 연결은 끊고, 새 방에 연결해야 한다.
roomId = 1
→ 1번 방 connect
roomId = 2
→ 1번 방 disconnect
→ 2번 방 connect
만약 cleanup이 없다면 기존 채팅방 연결이 계속 살아 있을 수 있다. 그러면 중복 메시지를 받거나, 메모리 누수가 생기거나, 잘못된 방의 이벤트가 현재 UI에 반영될 수 있다.
3.3 Unmount: 컴포넌트가 화면에서 제거되는 시점
Unmount는 컴포넌트가 렌더 트리에서 제거되는 시점이다.
function Timer() {
useEffect(() => {
const timerId = setInterval(() => {
console.log("tick");
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
return <div>타이머 실행 중</div>;
}
이 컴포넌트가 화면에서 사라지면 cleanup 함수가 실행된다.
Mount
→ setInterval 시작
Unmount
→ clearInterval 실행
cleanup을 하지 않으면 컴포넌트가 사라진 뒤에도 타이머가 계속 실행될 수 있다. 이 경우 더 이상 존재하지 않는 UI에 대해 상태 업데이트를 시도하거나, 불필요한 작업이 백그라운드에서 계속 돌 수 있다.
실무에서 unmount cleanup이 필요한 경우는 많다.
1. 이벤트 리스너 제거
2. 타이머 정리
3. WebSocket 연결 해제
4. AbortController로 fetch 취소
5. 외부 라이브러리 destroy
6. observer 해제
7. subscription unsubscribe
예를 들어 IntersectionObserver를 사용하는 코드는 다음처럼 cleanup을 반드시 포함해야 한다.
function ImpressionTracker({ onImpression }) {
const targetRef = useRef(null);
useEffect(() => {
if (!targetRef.current) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
onImpression();
}
});
observer.observe(targetRef.current);
return () => {
observer.disconnect();
};
}, [onImpression]);
return <div ref={targetRef}>노출 추적 영역</div>;
}
observer는 브라우저의 외부 시스템이다. React가 자동으로 정리해주지 않는다. 따라서 Effect에서 연결했다면 cleanup에서 직접 끊어야 한다.
4. useEffect는 컴포넌트 생명주기 메서드가 아니다
React를 처음 Hooks로 배울 때 흔히 이런 식으로 이해한다.
componentDidMount → useEffect(..., [])
componentDidUpdate → useEffect(..., [deps])
componentWillUnmount → useEffect cleanup
입문 단계에서는 도움이 될 수 있지만, 이 대응 관계는 완전히 정확하지 않다.
useEffect는 컴포넌트 생명주기 메서드라기보다 외부 시스템과의 동기화 도구다.
React 공식 문서의 관점에 맞춰 보면 Effect는 다음 두 가지 일을 한다.
1. 동기화 시작
2. 동기화 중단
예를 들어 채팅 연결 Effect는 다음과 같다.
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]);
이 Effect의 생명주기는 컴포넌트의 mount/update/unmount와 완전히 같지 않다.
serverUrl 또는 roomId가 바뀜
→ 기존 동기화 중단
→ 새로운 값으로 다시 동기화 시작
즉, Effect는 컴포넌트가 살아 있는 동안 여러 번 시작되고 중단될 수 있다.
컴포넌트 생명주기:
Mount → Update → Update → Unmount
Effect 생명주기:
Start → Stop → Start → Stop
이 관점을 이해하면 의존성 배열도 더 자연스럽게 이해된다.
의존성 배열은 “이 Effect가 어떤 값과 동기화되어야 하는가”를 나타낸다. Effect 내부에서 roomId를 사용한다면, 그 Effect는 roomId와 동기화되어야 한다. 따라서 roomId가 변경되면 기존 Effect를 정리하고 새 Effect를 실행해야 한다.
5. Effect를 사용하지 않아도 되는 경우
React 코드를 작성하다 보면 무언가 값이 바뀔 때 다른 값을 업데이트해야 하는 경우가 있다. 이때 많은 개발자가 습관적으로 useEffect를 사용한다.
예를 들어 다음 코드를 보자.
function ProductList({ products }) {
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
setFilteredProducts(
products.filter((product) => product.isAvailable)
);
}, [products]);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
이 코드는 동작은 한다. 하지만 좋은 구조는 아니다.
filteredProducts는 외부 시스템과 동기화되는 값이 아니다. 단지 products로부터 계산할 수 있는 파생 데이터다. 이런 경우에는 state와 Effect를 만들 필요가 없다.
더 간단한 코드는 다음과 같다.
function ProductList({ products }) {
const filteredProducts = products.filter(
(product) => product.isAvailable
);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
이 방식이 더 좋은 이유는 다음과 같다.
1. 불필요한 state가 없다.
2. 추가 렌더링이 발생하지 않는다.
3. products와 filteredProducts가 불일치할 가능성이 없다.
4. 코드가 더 읽기 쉽다.
계산 비용이 크다면 useMemo를 사용할 수 있다.
function ProductList({ products, keyword }) {
const filteredProducts = useMemo(() => {
return products.filter((product) => {
return (
product.isAvailable &&
product.name.toLowerCase().includes(keyword.toLowerCase())
);
});
}, [products, keyword]);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
하지만 이 경우에도 핵심은 같다.
외부 시스템과 동기화해야 한다 → useEffect
렌더링 중 계산 가능하다 → 일반 계산 또는 useMemo
사용자 이벤트에 반응한다 → 이벤트 핸들러
모든 변화에 useEffect를 붙이면 컴포넌트의 데이터 흐름이 복잡해지고, 상태 불일치와 무한 루프가 생기기 쉽다.
6. 이벤트 핸들러와 Effect는 다르다
React에서 이벤트 핸들러와 Effect를 구분하는 것은 매우 중요하다.
이벤트 핸들러는 사용자의 특정 행동에 반응한다.
function SaveButton({ form }) {
async function handleClick() {
await saveForm(form);
alert("저장되었습니다.");
}
return <button onClick={handleClick}>저장</button>;
}
이 코드는 사용자가 버튼을 클릭했을 때만 실행되어야 한다. 따라서 Effect가 아니라 이벤트 핸들러에 있어야 한다.
반면 Effect는 화면이 특정 상태로 렌더링된 결과를 외부 시스템과 동기화한다.
function PageTitle({ title }) {
useEffect(() => {
document.title = title;
}, [title]);
return null;
}
이 코드는 사용자의 특정 클릭 때문이 아니라, 현재 화면의 title 상태와 브라우저 문서 제목을 동기화하는 작업이다. 따라서 Effect가 적절하다.
구분 기준은 다음과 같다.
특정 사용자 행동 때문에 실행되어야 한다 → 이벤트 핸들러
컴포넌트가 화면에 보이는 상태와 외부 시스템을 맞춰야 한다 → Effect
나쁜 예시는 다음과 같다.
function CheckoutPage({ cart }) {
useEffect(() => {
if (cart.isSubmitted) {
sendOrder(cart);
}
}, [cart]);
return <div>결제 페이지</div>;
}
주문 전송은 사용자의 명확한 제출 행동에 의해 실행되어야 한다. 그런데 Effect에 넣으면 상태 변화나 리렌더링 흐름에 의해 의도치 않게 다시 실행될 위험이 있다.
더 나은 방식은 다음과 같다.
function CheckoutPage({ cart }) {
async function handleSubmit() {
await sendOrder(cart);
}
return (
<button onClick={handleSubmit}>
주문하기
</button>
);
}
Effect는 강력하지만, 모든 작업을 넣는 공간은 아니다. Effect는 React 바깥의 시스템과 동기화해야 할 때 사용하는 탈출구에 가깝다.
7. useEffect, useLayoutEffect, useInsertionEffect의 차이
React에는 Effect 계열 Hook이 여러 개 있다.
useEffect
useLayoutEffect
useInsertionEffect
대부분의 경우에는 useEffect를 사용하면 된다. 하지만 DOM 측정이나 스타일 삽입처럼 타이밍이 중요한 작업에서는 다른 Hook이 필요할 수 있다.
7.1 useEffect
useEffect는 commit 이후 실행된다. 일반적인 외부 동기화 작업에 적합하다.
useEffect(() => {
const subscription = store.subscribe(() => {
console.log("store changed");
});
return () => {
subscription.unsubscribe();
};
}, []);
적합한 작업은 다음과 같다.
1. 데이터 요청
2. 이벤트 리스너 등록
3. 타이머 시작
4. WebSocket 연결
5. analytics 전송
6. document.title 변경
useEffect는 브라우저가 화면을 그린 뒤 실행될 수 있기 때문에, 레이아웃을 즉시 측정하거나 화면 깜빡임을 막아야 하는 작업에는 적합하지 않을 수 있다.
7.2 useLayoutEffect
useLayoutEffect는 DOM이 변경된 뒤, 브라우저가 화면을 그리기 전에 실행된다. 따라서 DOM을 측정하고 그 결과에 따라 동기적으로 레이아웃을 조정해야 할 때 사용할 수 있다.
예를 들어 tooltip 위치를 계산해야 하는 경우를 보자.
function Tooltip({ targetRef, children }) {
const tooltipRef = useRef(null);
const [position, setPosition] = useState(null);
useLayoutEffect(() => {
if (!targetRef.current || !tooltipRef.current) return;
const targetRect = targetRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: targetRect.bottom + 8,
left:
targetRect.left +
targetRect.width / 2 -
tooltipRect.width / 2,
});
}, [targetRef]);
return (
<div
ref={tooltipRef}
style={{
position: "fixed",
top: position?.top ?? 0,
left: position?.left ?? 0,
visibility: position ? "visible" : "hidden",
}}
>
{children}
</div>
);
}
이런 작업을 useEffect에서 처리하면 화면이 한 번 잘못된 위치로 그려진 뒤 다시 보정되면서 깜빡임이 발생할 수 있다.
하지만 useLayoutEffect는 브라우저 페인팅을 막을 수 있으므로 과도하게 사용하면 성능에 좋지 않다.
기본은 useEffect
화면이 그려지기 전에 DOM 측정과 보정이 꼭 필요할 때만 useLayoutEffect
7.3 useInsertionEffect
useInsertionEffect는 CSS-in-JS 라이브러리처럼 스타일을 DOM에 삽입해야 하는 특수한 경우를 위해 제공된다.
일반 애플리케이션 코드에서 직접 사용할 일은 거의 없다.
useEffect: 일반적인 외부 동기화
useLayoutEffect: 페인트 전 DOM 측정/레이아웃 보정
useInsertionEffect: 스타일 삽입 같은 라이브러리 레벨 작업
실무에서는 useInsertionEffect를 직접 사용할 일이 많지 않지만, CSS-in-JS 라이브러리들이 왜 이런 타이밍을 신경 쓰는지 이해하는 데 도움이 된다.
8. 상태는 컴포넌트 안에 있는 것이 아니라 React가 위치로 기억한다
React를 처음 사용할 때는 state가 컴포넌트 함수 안에 들어 있다고 생각하기 쉽다.
function Counter() {
const [count, setCount] = useState(0);
return <button>{count}</button>;
}
하지만 함수 컴포넌트는 렌더링될 때마다 다시 실행된다. 만약 state가 단순히 함수 내부 변수라면 매번 초기화되어야 한다. 그런데 실제로는 count가 렌더링 사이에 유지된다.
그 이유는 state가 컴포넌트 함수 안에 저장되는 것이 아니라, React가 렌더 트리의 위치를 기준으로 state를 보관하기 때문이다.
같은 위치에 같은 컴포넌트가 렌더링된다
→ React는 이전 state를 유지한다
다른 위치에 렌더링되거나 key가 바뀐다
→ React는 새로운 컴포넌트로 보고 state를 초기화한다
다음 예제를 보자.
function App() {
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={(e) => setIsFancy(e.target.checked)}
/>
fancy mode
</label>
</div>
);
}
조건문이 바뀌어도 Counter가 같은 위치에 렌더링된다면 React는 같은 컴포넌트로 보고 state를 유지한다.
반대로 key를 바꾸면 state를 초기화할 수 있다.
function ProfileForm({ userId }) {
return <Form key={userId} userId={userId} />;
}
userId가 바뀌면 Form은 완전히 새로운 컴포넌트 인스턴스처럼 취급된다. 기존 state는 버려지고 새 state가 만들어진다.
이 패턴은 실무에서 매우 유용하다.
예를 들어 사용자 프로필 수정 화면에서 다른 유저를 선택했는데 이전 유저의 입력값이 남아 있으면 문제가 된다. 이때 key={userId}를 사용하면 유저가 바뀔 때 폼 상태를 자연스럽게 초기화할 수 있다.
function UserEditPage({ selectedUserId }) {
return (
<section>
<UserSelector />
<UserEditForm key={selectedUserId} userId={selectedUserId} />
</section>
);
}
state 보존과 초기화는 생명주기와 직접 연결된다.
같은 위치 + 같은 key
→ 기존 state 유지
위치 변경 또는 key 변경
→ 기존 컴포넌트 unmount
→ 새 컴포넌트 mount
→ state 초기화
9. 조건부 렌더링과 Unmount
조건부 렌더링은 컴포넌트의 mount/unmount를 직접적으로 만든다.
function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen((prev) => !prev)}>
Toggle
</button>
{open && <Modal />}
</>
);
}
open이 true가 되면 Modal이 mount된다. open이 false가 되면 Modal이 unmount된다.
function Modal() {
useEffect(() => {
console.log("Modal mounted");
return () => {
console.log("Modal unmounted");
};
}, []);
return <div>Modal</div>;
}
이 구조는 모달, 드롭다운, 탭, 토스트, 사이드바 등에서 흔히 사용된다.
하지만 “숨김”과 “제거”는 다르다.
{open && <Modal />}
이 코드는 open이 false일 때 Modal을 React 트리에서 제거한다. 따라서 내부 state도 사라지고 cleanup도 실행된다.
반면 다음 코드는 컴포넌트를 계속 유지하면서 CSS로만 숨긴다.
<Modal hidden={!open} />
이 경우 Modal은 unmount되지 않는다. 내부 state도 유지되고, Effect cleanup도 실행되지 않는다.
실무에서는 두 방식을 명확히 구분해야 한다.
모달을 닫을 때 내부 입력값도 초기화되어야 한다
→ 조건부 렌더링으로 unmount
모달을 닫았다 열어도 입력값이 유지되어야 한다
→ 컴포넌트를 유지하고 CSS로 숨김
비용이 큰 컴포넌트라 매번 mount하기 부담스럽다
→ 유지 전략 고려
화면에서 사라지면 구독이나 타이머를 반드시 끊어야 한다
→ unmount 전략 고려
React 생명주기는 UI 표시 여부뿐 아니라 상태 보존, 리소스 정리, 성능에도 영향을 준다.
10. Strict Mode에서 Effect가 두 번 실행되는 이유
React 개발 환경에서 useEffect가 두 번 실행되는 것을 보고 당황하는 경우가 많다.
예를 들어 다음 코드를 보자.
function ChatRoom() {
useEffect(() => {
console.log("connect");
return () => {
console.log("disconnect");
};
}, []);
return <div>Chat Room</div>;
}
Strict Mode가 켜진 개발 환경에서는 다음처럼 보일 수 있다.
connect
disconnect
connect
이것은 React의 버그가 아니다. React가 개발 환경에서 의도적으로 Effect setup과 cleanup을 한 번 더 실행해보며, cleanup이 제대로 작성되었는지 확인하는 과정이다.
이 동작은 다음과 같은 버그를 빨리 발견하게 해준다.
function BadChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
// cleanup 없음
}, []);
return <div>Chat Room</div>;
}
이 코드는 컴포넌트가 unmount되거나 Effect가 재실행될 때 연결을 끊지 않는다. 개발 환경에서 Effect가 다시 실행되면 중복 연결 문제가 더 빨리 드러난다.
올바른 코드는 다음과 같다.
function GoodChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
return <div>Chat Room</div>;
}
Strict Mode에서 Effect가 두 번 실행된다는 사실을 피하려고 우회 코드를 작성하는 것은 좋지 않다.
나쁜 예시는 다음과 같다.
function ChatRoom() {
const didRunRef = useRef(false);
useEffect(() => {
if (didRunRef.current) return;
didRunRef.current = true;
const connection = createConnection();
connection.connect();
}, []);
return <div>Chat Room</div>;
}
이 코드는 개발 환경에서 중복 실행을 막아주는 것처럼 보이지만, 실제 문제인 cleanup 누락을 해결하지 않는다.
React가 원하는 방향은 “Effect가 여러 번 실행되어도 안전한 코드”를 작성하는 것이다.
Effect setup은 cleanup으로 되돌릴 수 있어야 한다.
setup → cleanup → setup이 실행되어도 문제가 없어야 한다.
11. 데이터 fetching과 생명주기
전통적으로 컴포넌트가 mount될 때 데이터를 가져오는 패턴은 매우 흔하다.
function UserPage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then((data) => {
setUser(data);
});
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
단순한 예제에서는 괜찮아 보이지만, 실무에서는 몇 가지 문제가 생길 수 있다.
1. userId가 빠르게 바뀌면 이전 요청 결과가 늦게 도착할 수 있다.
2. 컴포넌트가 unmount된 뒤 응답이 도착할 수 있다.
3. 로딩, 에러, 재시도, 캐싱 처리가 반복된다.
4. 같은 데이터를 여러 컴포넌트에서 중복 요청할 수 있다.
최소한 요청 취소나 무시 처리가 필요하다.
function UserPage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
setUser(null);
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
});
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name === "AbortError") {
return;
}
console.error(error);
}
}
fetchUser();
return () => {
controller.abort();
};
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
userId가 바뀌면 이전 Effect의 cleanup이 실행되고, 이전 요청은 abort된다. 그 다음 새로운 userId로 요청을 시작한다.
userId = 1
→ 1번 유저 요청 시작
userId = 2
→ 1번 요청 abort
→ 2번 유저 요청 시작
하지만 규모가 있는 애플리케이션에서는 이런 로직을 모든 컴포넌트에 직접 작성하기보다 TanStack Query, SWR, Relay, Apollo, framework-level data fetching 등을 사용하는 경우가 많다.
이유는 데이터 fetching이 단순히 “mount 때 요청”의 문제가 아니기 때문이다.
캐싱
중복 요청 제거
재시도
백그라운드 갱신
pagination
infinite query
optimistic update
에러 상태 관리
서버 렌더링 연동
React 생명주기 관점에서 보면, 데이터 fetching은 컴포넌트의 mount/update와 연결되지만, 실제 제품에서는 별도의 서버 상태 관리 계층으로 분리하는 것이 더 안정적일 때가 많다.
12. 외부 subscription과 생명주기
외부 store, WebSocket, BroadcastChannel, Firebase, DOM 이벤트처럼 React 바깥에서 변경이 발생하는 시스템은 subscription 관리가 필요하다.
예를 들어 간단한 online 상태 감지 Hook을 만들어보자.
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
return () => {
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
};
}, []);
return isOnline;
}
사용하는 컴포넌트는 단순하다.
function NetworkStatusBadge() {
const isOnline = useOnlineStatus();
return (
<span>
{isOnline ? "온라인" : "오프라인"}
</span>
);
}
이 구조에서 생명주기는 custom Hook 안에 숨겨진다.
컴포넌트 mount
→ useOnlineStatus 내부 Effect 실행
→ online/offline 이벤트 구독
컴포넌트 unmount
→ cleanup 실행
→ 이벤트 구독 해제
custom Hook은 생명주기 로직을 재사용 가능한 단위로 추출하는 좋은 방법이다.
실무에서는 다음과 같은 로직을 custom Hook으로 분리하면 좋다.
1. 브라우저 이벤트 구독
2. media query 감지
3. localStorage 동기화
4. WebSocket 연결
5. IntersectionObserver
6. ResizeObserver
7. 외부 SDK 초기화
예를 들어 media query Hook은 다음처럼 작성할 수 있다.
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
return window.matchMedia(query).matches;
});
useEffect(() => {
const mediaQueryList = window.matchMedia(query);
function handleChange(event) {
setMatches(event.matches);
}
mediaQueryList.addEventListener("change", handleChange);
setMatches(mediaQueryList.matches);
return () => {
mediaQueryList.removeEventListener("change", handleChange);
};
}, [query]);
return matches;
}
사용 예시는 다음과 같다.
function Layout() {
const isMobile = useMediaQuery("(max-width: 768px)");
return isMobile ? <MobileLayout /> : <DesktopLayout />;
}
이런 구조는 컴포넌트가 “무엇을 보여줄지”에 집중하게 만들고, 외부 시스템과의 생명주기 동기화는 Hook이 책임지게 만든다.
13. stale closure와 생명주기
React 생명주기를 이해할 때 stale closure 문제도 함께 이해해야 한다.
다음 코드를 보자.
function CounterLogger() {
const [count, setCount] = useState(0);
useEffect(() => {
const timerId = setInterval(() => {
console.log(count);
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
이 코드는 버튼을 눌러 count를 증가시켜도 콘솔에 계속 0이 찍힐 수 있다.
왜 그럴까?
Effect의 의존성 배열이 빈 배열이기 때문에, 이 Effect는 초기 렌더링 시점의 count를 기억한다. setInterval 콜백도 그 시점의 count를 클로저로 참조한다.
즉, 컴포넌트는 업데이트되지만 interval 콜백은 최신 렌더링의 count를 자동으로 바라보지 않는다.
가장 단순한 해결책은 의존성 배열에 count를 포함하는 것이다.
function CounterLogger() {
const [count, setCount] = useState(0);
useEffect(() => {
const timerId = setInterval(() => {
console.log(count);
}, 1000);
return () => {
clearInterval(timerId);
};
}, [count]);
return (
<button onClick={() => setCount((prev) => prev + 1)}>
Count: {count}
</button>
);
}
이제 count가 바뀔 때마다 기존 interval을 정리하고 새 interval을 만든다.
count = 0
→ interval 생성
count = 1
→ 기존 interval 제거
→ 새 interval 생성
count = 2
→ 기존 interval 제거
→ 새 interval 생성
하지만 interval을 매번 다시 만들고 싶지 않고, 최신 count만 읽고 싶다면 ref를 사용할 수 있다.
function CounterLogger() {
const [count, setCount] = useState(0);
const latestCountRef = useRef(count);
useEffect(() => {
latestCountRef.current = count;
}, [count]);
useEffect(() => {
const timerId = setInterval(() => {
console.log(latestCountRef.current);
}, 1000);
return () => {
clearInterval(timerId);
};
}, []);
return (
<button onClick={() => setCount((prev) => prev + 1)}>
Count: {count}
</button>
);
}
여기서 첫 번째 Effect는 ref를 최신 상태로 동기화하고, 두 번째 Effect는 interval의 생명주기를 관리한다.
이처럼 React에서 생명주기를 이해한다는 것은 단순히 “언제 실행되는가”만 아는 것이 아니다.
각 렌더링은 자신만의 state snapshot을 가진다.
Effect와 이벤트 핸들러는 특정 렌더링 시점의 값을 클로저로 기억한다.
의존성 배열은 Effect를 최신 reactive value와 동기화하기 위한 장치다.
14. cleanup은 unmount 때만 실행되는 것이 아니다
useEffect cleanup을 componentWillUnmount와 동일하게 이해하면 중요한 동작을 놓치게 된다.
cleanup은 unmount 때만 실행되지 않는다. 의존성이 바뀌어 Effect가 다시 실행되기 전에도 실행된다.
function Logger({ value }) {
useEffect(() => {
console.log("setup:", value);
return () => {
console.log("cleanup:", value);
};
}, [value]);
return <div>{value}</div>;
}
value가 A에서 B로 바뀌면 흐름은 다음과 같다.
setup: A
cleanup: A
setup: B
컴포넌트가 사라진 것이 아니라, Effect의 동기화 대상이 바뀌었기 때문에 이전 동기화를 정리한 것이다.
이 관점은 WebSocket, 채팅방, 타이머, observer, API 요청 취소에서 특히 중요하다.
function ChatRoom({ roomId }) {
useEffect(() => {
const socket = connectToRoom(roomId);
socket.on("message", handleMessage);
return () => {
socket.off("message", handleMessage);
socket.disconnect();
};
}, [roomId]);
return <div>{roomId}번 방</div>;
}
roomId가 바뀌면 이전 방의 socket을 정리해야 한다. 이때 cleanup은 unmount 때문이 아니라 update 과정에서 실행된다.
1번 방 입장
→ 1번 방 socket 연결
2번 방으로 변경
→ 1번 방 socket 해제
→ 2번 방 socket 연결
cleanup을 unmount 전용으로만 생각하면 이런 update cleanup을 놓치기 쉽다.
15. 생명주기와 성능 최적화
React 생명주기를 이해하면 성능 최적화도 더 정확하게 할 수 있다.
성능 최적화에서 흔히 하는 실수는 “리렌더링이 발생했다”는 사실만으로 문제라고 판단하는 것이다. 하지만 리렌더링 자체는 React의 정상적인 동작이다.
중요한 질문은 다음과 같다.
1. 렌더링이 너무 자주 발생하는가?
2. 렌더링 중 무거운 계산이 반복되는가?
3. 자식 컴포넌트까지 불필요하게 다시 렌더링되는가?
4. DOM 업데이트가 과도하게 발생하는가?
5. Effect가 불필요하게 반복 실행되는가?
예를 들어 무거운 필터링 로직이 매 렌더마다 실행되는 경우를 보자.
function ProductTable({ products, keyword }) {
const filteredProducts = products
.filter((product) => product.name.includes(keyword))
.sort((a, b) => a.price - b.price);
return <Table rows={filteredProducts} />;
}
데이터가 작다면 문제 되지 않는다. 하지만 수천 개 이상의 데이터를 다룬다면 비용이 커질 수 있다. 이 경우 useMemo를 고려할 수 있다.
function ProductTable({ products, keyword }) {
const filteredProducts = useMemo(() => {
return products
.filter((product) => product.name.includes(keyword))
.sort((a, b) => a.price - b.price);
}, [products, keyword]);
return <Table rows={filteredProducts} />;
}
또 다른 예로, 부모가 렌더링될 때마다 자식에게 새로운 함수 객체를 넘기는 경우가 있다.
function ProductPage({ products }) {
const [selectedId, setSelectedId] = useState(null);
return (
<ProductList
products={products}
onSelect={(id) => setSelectedId(id)}
/>
);
}
이 자체가 항상 문제는 아니다. 하지만 ProductList가 memo로 최적화되어 있고 props 참조 안정성이 중요하다면 useCallback을 고려할 수 있다.
function ProductPage({ products }) {
const [selectedId, setSelectedId] = useState(null);
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
return (
<ProductList
products={products}
onSelect={handleSelect}
/>
);
}
하지만 useMemo, useCallback, memo를 무조건 붙이는 것도 좋지 않다. 이 도구들은 렌더링 비용과 참조 안정성이 실제로 문제가 될 때 사용하는 것이 좋다.
생명주기 관점에서 성능 최적화는 다음과 같이 정리할 수 있다.
렌더 단계가 무겁다
→ 계산 분리, useMemo, 리스트 가상화 고려
commit 이후 Effect가 자주 실행된다
→ 의존성 검토, Effect 분리, 불필요한 Effect 제거
자식 렌더링이 과도하다
→ props 안정화, memo, 컴포넌트 분리 고려
컴포넌트 mount/unmount 비용이 크다
→ 조건부 렌더링과 visibility 전략 재검토
16. 클래스 컴포넌트 생명주기와 함수 컴포넌트의 차이
React의 생명주기를 이야기할 때 클래스 컴포넌트를 완전히 빼놓을 수는 없다. 기존 코드베이스나 오래된 자료에서는 여전히 클래스 생명주기 메서드를 볼 수 있기 때문이다.
클래스 컴포넌트의 대표적인 생명주기는 다음과 같다.
Mount:
constructor
render
componentDidMount
Update:
render
componentDidUpdate
Unmount:
componentWillUnmount
예를 들어 클래스 컴포넌트에서 채팅 연결을 관리하면 다음과 같다.
class ChatRoom extends React.Component {
componentDidMount() {
this.connect();
}
componentDidUpdate(prevProps) {
if (prevProps.roomId !== this.props.roomId) {
this.disconnect();
this.connect();
}
}
componentWillUnmount() {
this.disconnect();
}
connect() {
this.connection = createConnection(this.props.roomId);
this.connection.connect();
}
disconnect() {
this.connection?.disconnect();
}
render() {
return <div>{this.props.roomId}번 방</div>;
}
}
같은 로직을 함수 컴포넌트로 작성하면 다음과 같다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
return <div>{roomId}번 방</div>;
}
클래스 컴포넌트에서는 같은 목적의 로직이 여러 생명주기 메서드에 흩어진다.
componentDidMount에서 connect
componentDidUpdate에서 props 변화 감지 후 reconnect
componentWillUnmount에서 disconnect
함수 컴포넌트에서는 하나의 Effect 안에 setup과 cleanup이 함께 들어간다.
roomId와 동기화 시작
roomId가 바뀌거나 컴포넌트가 사라지면 동기화 중단
이 차이는 단순히 문법 차이가 아니다. 사고방식이 다르다.
클래스 컴포넌트:
컴포넌트 생명주기 이벤트에 맞춰 코드를 배치한다.
함수 컴포넌트:
특정 값과 외부 시스템의 동기화 관계를 선언한다.
현대 React에서는 후자의 사고방식이 더 중요하다.
17. 실무 예제: WebView 메시지 구독 생명주기
React Native나 WebView 기반 앱에서는 웹과 네이티브 사이의 메시지 구독을 관리해야 하는 경우가 많다.
웹 환경에서 window.message 이벤트를 구독하는 예제를 보자.
function WebViewMessageListener({ onMessage }) {
useEffect(() => {
function handleMessage(event) {
try {
const payload = JSON.parse(event.data);
onMessage(payload);
} catch (error) {
console.error("Invalid message:", event.data);
}
}
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [onMessage]);
return null;
}
이 컴포넌트의 생명주기는 다음과 같다.
Mount:
message 이벤트 구독
Update:
onMessage 참조가 바뀌면 기존 구독 해제 후 새 구독 등록
Unmount:
message 이벤트 구독 해제
여기서 onMessage가 부모 렌더링마다 새 함수로 만들어진다면 Effect가 계속 재실행될 수 있다.
부모에서 useCallback을 사용하면 참조 안정성을 높일 수 있다.
function App() {
const handleMessage = useCallback((payload) => {
if (payload.type === "LOGIN_SUCCESS") {
console.log("로그인 성공:", payload.user);
}
}, []);
return <WebViewMessageListener onMessage={handleMessage} />;
}
또는 최신 콜백을 ref로 관리하고 이벤트 리스너는 한 번만 등록하는 방식도 가능하다.
function WebViewMessageListener({ onMessage }) {
const onMessageRef = useRef(onMessage);
useEffect(() => {
onMessageRef.current = onMessage;
}, [onMessage]);
useEffect(() => {
function handleMessage(event) {
try {
const payload = JSON.parse(event.data);
onMessageRef.current(payload);
} catch (error) {
console.error("Invalid message:", event.data);
}
}
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
return null;
}
이 방식은 이벤트 리스너의 생명주기와 콜백의 최신성 문제를 분리한다.
이벤트 리스너 생명주기:
mount 시 등록, unmount 시 제거
콜백 최신성:
props가 바뀔 때 ref 업데이트
실무에서는 이런 분리가 꽤 중요하다. 특히 WebView, Socket, SDK 이벤트처럼 구독 비용이 있거나 중복 구독이 위험한 경우에는 Effect 생명주기를 세밀하게 설계해야 한다.
18. 실무 예제: 모달의 mount/unmount와 상태 초기화
모달은 React 생명주기를 이해하기 좋은 예제다.
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
열기
</button>
{isOpen && (
<CreatePostModal onClose={() => setIsOpen(false)} />
)}
</>
);
}
이 구조에서는 모달을 닫을 때 CreatePostModal이 unmount된다. 따라서 모달 내부 state도 사라진다.
function CreatePostModal({ onClose }) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
return (
<div role="dialog">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용"
/>
<button onClick={onClose}>닫기</button>
</div>
);
}
닫았다가 다시 열면 title과 content는 초기화된다.
반대로 모달을 닫아도 입력값을 유지하고 싶다면 컴포넌트를 unmount하지 않아야 한다.
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
열기
</button>
<CreatePostModal
open={isOpen}
onClose={() => setIsOpen(false)}
/>
</>
);
}
function CreatePostModal({ open, onClose }) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
if (!open) {
return null;
}
return (
<div role="dialog">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
/>
<button onClick={onClose}>닫기</button>
</div>
);
}
주의할 점은 이 코드도 CreatePostModal 컴포넌트 자체는 유지되지만, 내부에서 return null을 하므로 DOM은 사라진다는 것이다. state는 유지된다.
이처럼 모달 설계에서는 다음 선택이 필요하다.
닫을 때 내부 상태를 버릴 것인가?
닫아도 내부 상태를 유지할 것인가?
닫을 때 외부 구독도 정리해야 하는가?
애니메이션 종료 후 unmount해야 하는가?
생명주기 설계는 UI 요구사항과 직접 연결된다.
19. 실무 예제: 페이지 이동과 리소스 정리
SPA에서는 페이지가 바뀌어도 브라우저 전체가 새로고침되지 않는다. 대신 라우터가 렌더 트리를 바꾸면서 이전 페이지 컴포넌트를 unmount하고 새 페이지 컴포넌트를 mount한다.
예를 들어 대시보드 페이지에서 주기적으로 데이터를 갱신한다고 하자.
function DashboardPage() {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
async function fetchData() {
const response = await fetch("/api/dashboard");
const nextData = await response.json();
if (!ignore) {
setData(nextData);
}
}
fetchData();
const intervalId = setInterval(fetchData, 5000);
return () => {
ignore = true;
clearInterval(intervalId);
};
}, []);
if (!data) return <p>Loading...</p>;
return <Dashboard data={data} />;
}
이 페이지를 벗어나면 cleanup이 실행되어 interval이 제거된다.
DashboardPage mount
→ 즉시 데이터 요청
→ 5초마다 polling
DashboardPage unmount
→ polling 중단
만약 cleanup이 없다면 사용자가 페이지를 벗어난 뒤에도 polling이 계속될 수 있다. 이는 네트워크 낭비, 불필요한 서버 부하, 예측하기 어려운 상태 업데이트를 유발한다.
실무에서는 polling보다는 TanStack Query 같은 도구의 refetchInterval, focus refetch, stale time 등을 사용하는 경우가 많지만, 내부적으로는 결국 “컴포넌트가 구독을 시작하고, 필요 없어지면 정리한다”는 생명주기 개념이 바탕에 있다.
20. React 생명주기 설계 원칙
React 생명주기를 실무적으로 다룰 때는 다음 원칙을 기억하면 좋다.
20.1 렌더링은 순수하게 유지한다
컴포넌트 함수 안에서는 JSX를 계산하는 데 집중한다.
좋지 않은 코드:
function Page({ title }) {
document.title = title;
return <h1>{title}</h1>;
}
좋은 코드:
function Page({ title }) {
useEffect(() => {
document.title = title;
}, [title]);
return <h1>{title}</h1>;
}
렌더링 중에는 외부 시스템을 변경하지 않는다.
20.2 파생 데이터는 state로 만들지 않는다
좋지 않은 코드:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
좋은 코드:
const fullName = `${firstName} ${lastName}`;
렌더링 중 계산할 수 있는 값은 state와 Effect로 만들지 않는다.
20.3 Effect는 외부 시스템과의 동기화에 사용한다
Effect가 적절한 경우:
1. DOM API와 동기화
2. 브라우저 이벤트 구독
3. 네트워크 요청
4. WebSocket 연결
5. third-party library 초기화
6. timer/observer/subscription 관리
Effect가 불필요한 경우:
1. props로부터 값 계산
2. state로부터 값 계산
3. 사용자 클릭에 따른 일회성 작업
4. 렌더링 결과만으로 표현 가능한 UI 상태
20.4 setup과 cleanup을 한 쌍으로 생각한다
Effect에서 무언가를 시작했다면, cleanup에서 되돌릴 수 있어야 한다.
addEventListener → removeEventListener
setInterval → clearInterval
connect → disconnect
subscribe → unsubscribe
observe → disconnect/unobserve
start → stop
create → destroy
setup만 있고 cleanup이 없다면 생명주기 버그가 숨어 있을 가능성이 있다.
20.5 의존성 배열은 “실행 횟수 조절 도구”가 아니다
의존성 배열은 Effect가 어떤 reactive value와 동기화되어야 하는지를 나타낸다.
좋지 않은 사고방식:
이 Effect를 한 번만 실행하고 싶으니 []를 넣자.
더 나은 사고방식:
이 Effect는 어떤 props/state를 사용하고 있는가?
그 값이 바뀌면 외부 시스템과 다시 동기화해야 하는가?
의존성을 일부러 누락하면 stale closure 문제가 생길 수 있다.
20.6 mount/unmount 여부로 상태 보존을 설계한다
컴포넌트가 unmount되면 내부 state는 사라진다. 같은 위치에 유지되면 state는 보존된다. key가 바뀌면 React는 새 컴포넌트로 보고 state를 초기화한다.
<Form key={userId} userId={userId} />
이 패턴은 폼 초기화, 탭 전환, 상세 페이지 전환, 모달 리셋 등에서 유용하다.
21. 마무리
React 생명주기는 단순히 “마운트, 업데이트, 언마운트”라는 순서로 끝나는 개념이 아니다.
현대 React에서는 생명주기를 다음 관점으로 이해해야 한다.
1. React는 상태 변경을 계기로 렌더링을 트리거한다.
2. 렌더 단계에서는 다음 UI를 계산한다.
3. commit 단계에서 실제 DOM을 변경한다.
4. Effect는 commit 이후 외부 시스템과 동기화한다.
5. Effect의 cleanup은 unmount 때뿐 아니라 의존성 변경 전에도 실행된다.
6. state는 컴포넌트 함수 안이 아니라 React가 렌더 트리 위치로 기억한다.
7. key와 조건부 렌더링은 state 보존과 초기화에 직접 영향을 준다.
8. Strict Mode는 setup과 cleanup이 안전하게 작성되었는지 드러내준다.
React 생명주기를 제대로 이해하면 단순히 Hook을 사용하는 수준을 넘어, 컴포넌트가 언제 계산되고, 언제 DOM에 반영되며, 언제 외부 시스템과 연결되고 해제되는지 설계할 수 있다.
좋은 React 코드는 생명주기를 억지로 제어하려 하지 않는다. 대신 React의 흐름에 맞게 렌더링은 순수하게 유지하고, 외부 시스템과의 동기화는 Effect에 모으고, setup과 cleanup을 명확한 한 쌍으로 만든다.
결국 React 생명주기를 이해한다는 것은 “언제 코드가 실행되는가”를 아는 것이 아니라, “어떤 코드를 어느 단계에 두어야 하는가”를 판단할 수 있게 되는 것이다.
'Frontend > React' 카테고리의 다른 글
| Next.js가 서버 사이드 렌더링 React 앱의 미래인 5가지 이유 (0) | 2022.11.11 |
|---|---|
| [React] Custom Hook - 리액트의 관심사 분리 (0) | 2022.11.02 |
| [React] Context API (0) | 2022.10.31 |
| [React] 리액트 앱에서 렌더링 최적화하기 (0) | 2022.10.30 |
| [React] 리액트에서 CSR을 최적화하는 원리 (0) | 2022.10.03 |