컴포넌트 생명주기 — 마운트에서 언마운트까지 무슨 일이 벌어지나
컴포넌트가 화면에 나타나고, 변경되고, 사라지는 동안 React 내부에서는 정확히 어떤 일이 벌어질까요?
개념 정의
React 컴포넌트의 생명주기(Lifecycle)란 컴포넌트가 생성(Mount) → 갱신(Update) → 제거(Unmount) 되는 일련의 과정을 말합니다. 클래스형 컴포넌트에서는 componentDidMount 같은 메서드로 표현했지만, 함수형 컴포넌트에서는 훅(Hooks) 으로 이 흐름을 제어합니다.
왜 필요한가
컴포넌트가 화면에 나타날 때 데이터를 가져오고, 값이 바뀔 때 외부 시스템과 동기화하고, 사라질 때 리소스를 정리해야 합니다. 이런 부수효과(Side Effect) 관리가 생명주기 이해의 핵심입니다.
- API 호출 시점을 정확히 제어해야 합니다
- WebSocket, EventListener 등의 구독을 해제해야 메모리 누수를 방지합니다
- 애니메이션이나 타이머를 적절한 시점에 시작하고 정리해야 합니다
내부 동작 — 세 단계로 나누기
1단계: 마운트 (Mount)
컴포넌트가 처음으로 DOM에 추가되는 단계입니다.
function UserProfile({ userId }) {
// 1. useState 초기화 — 최초 렌더 시에만 초기값 사용
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// 2. 렌더링 — JSX 반환, DOM에 반영
// 3. useEffect 실행 — DOM이 그려진 후
useEffect(() => {
console.log('마운트: 데이터를 가져옵니다');
fetchUser(userId).then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
return loading ? <Spinner /> : <div>{user.name}</div>;
}
마운트 시 실행 순서를 정리하면 다음과 같습니다.
- 함수 컴포넌트 본문 실행 (useState 초기화 포함)
- JSX를 React Element로 변환
- React가 DOM을 업데이트
- 브라우저가 화면을 그림 (paint)
useEffect콜백 실행
2단계: 업데이트 (Update)
props나 state가 변경되면 컴포넌트가 다시 렌더링됩니다.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`count가 ${count}로 변경되었습니다`);
document.title = `클릭 ${count}회`;
return () => {
console.log(`cleanup: 이전 count ${count}에 대한 정리`);
};
}, [count]);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
업데이트 시 실행 순서입니다.
- state/props 변경 감지
- 함수 컴포넌트 본문 재실행
- 새로운 JSX와 이전 JSX 비교 (Reconciliation)
- 변경된 부분만 DOM에 반영
- 이전 effect의 cleanup 함수 실행
- 새로운
useEffect콜백 실행
cleanup이 다음 effect 실행 전 에 호출된다는 점이 중요합니다.
3단계: 언마운트 (Unmount)
컴포넌트가 DOM에서 제거되는 단계입니다.
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
console.log(`${roomId} 채팅방에 연결했습니다`);
// cleanup: 언마운트 시 또는 roomId 변경 시 실행
return () => {
connection.disconnect();
console.log(`${roomId} 채팅방에서 연결 해제했습니다`);
};
}, [roomId]);
return <div>채팅방: {roomId}</div>;
}
언마운트 시에는 마지막 effect의 cleanup 함수 가 실행됩니다. 이때 구독 해제, 타이머 정리, 연결 종료 등의 정리 작업을 수행합니다.
클래스형과 함수형의 매핑
| 클래스형 메서드 | 함수형 훅 |
|---|---|
constructor | useState 초기값, useRef 초기값 |
componentDidMount | useEffect(() => {}, []) |
componentDidUpdate | useEffect(() => {}, [deps]) |
componentWillUnmount | useEffect의 cleanup 함수 |
shouldComponentUpdate | React.memo, useMemo |
getDerivedStateFromProps | 렌더 중 state 업데이트 |
하지만 React 팀은 이 매핑으로 생각하지 말라고 권합니다. 함수형 컴포넌트에서는 "생명주기"가 아니라 "동기화" 로 사고를 전환해야 합니다.
Effect는 "생명주기"가 아니라 "동기화"
React 공식 문서에서 강조하는 중요한 관점 전환입니다.
// ❌ 이렇게 생각하지 마세요
// "마운트 시 데이터를 가져오고, userId가 바뀌면 다시 가져온다"
// ✅ 이렇게 생각하세요
// "이 effect는 userId와 동기화된다"
useEffect(() => {
const controller = new AbortController();
fetchUser(userId, { signal: controller.signal })
.then(setUser);
return () => controller.abort();
}, [userId]); // userId가 변하면 다시 동기화
effect의 시작과 정리를 마운트/언마운트 시점이 아니라, 의존성과의 동기화 로 이해하면 훨씬 자연스럽게 코드를 작성할 수 있습니다.
useLayoutEffect — paint 이전에 실행
function Tooltip({ anchorRef, children }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
const tooltipRef = useRef(null);
// DOM 측정은 paint 전에 해야 깜빡임이 없습니다
useLayoutEffect(() => {
const anchorRect = anchorRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
setPosition({
top: anchorRect.top - tooltipRect.height - 8,
left: anchorRect.left + (anchorRect.width - tooltipRect.width) / 2,
});
}, [anchorRef]);
return (
<div ref={tooltipRef} style={{ position: 'fixed', ...position }}>
{children}
</div>
);
}
useLayoutEffect는 DOM이 변경된 직후, 브라우저가 화면을 그리기 전 에 동기적으로 실행됩니다. 레이아웃을 측정하거나 DOM을 수정해야 깜빡임이 발생하지 않는 경우에 사용합니다.
StrictMode와 이중 실행
React의 StrictMode는 개발 환경에서 effect를 두 번 실행합니다.
// 개발 환경에서의 실행 순서
// 1. 마운트 → effect 실행
// 2. 언마운트 시뮬레이션 → cleanup 실행
// 3. 재마운트 → effect 다시 실행
이것은 cleanup이 제대로 구현되었는지 검증하기 위한 장치입니다. cleanup 없이 구독만 하면 두 번 구독되는 문제가 드러납니다.
// ❌ cleanup이 없으면 StrictMode에서 이중 구독
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
// cleanup이 없다!
}, []);
// ✅ cleanup을 항상 작성
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
주의할 점
cleanup 누락으로 인한 메모리 누수
이벤트 리스너, WebSocket, 타이머를 등록하고 cleanup을 작성하지 않으면, 컴포넌트가 언마운트된 후에도 핸들러가 계속 실행됩니다. 이는 메모리 누수뿐 아니라 setState on unmounted component 경고의 원인이 됩니다.
// ❌ cleanup 누락 — 메모리 누수 발생
useEffect(() => {
const ws = new WebSocket('ws://api.example.com');
ws.onmessage = (msg) => setData(JSON.parse(msg.data));
}, []);
// ✅ cleanup으로 연결 해제
useEffect(() => {
const ws = new WebSocket('ws://api.example.com');
ws.onmessage = (msg) => setData(JSON.parse(msg.data));
return () => ws.close();
}, []);
useEffect 안에서 state 업데이트 무한 루프
의존성 배열에 effect 내부에서 변경하는 state를 넣으면 무한 루프가 발생합니다. effect가 state를 변경하고, state 변경이 effect를 다시 트리거하기 때문입니다.
// ❌ 무한 루프
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // count가 바뀌면 effect 재실행 → 무한
}, [count]);
useLayoutEffect의 SSR 경고
useLayoutEffect는 서버에서 실행할 수 없으므로 SSR 환경에서 경고가 발생합니다. SSR이 필요한 경우 useEffect로 대체하거나, 조건부로 실행해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 마운트 | 함수 실행 → JSX 변환 → DOM 반영 → paint → useEffect 실행 |
| 업데이트 | 함수 재실행 → Reconciliation → DOM 반영 → 이전 cleanup → 새 effect |
| 언마운트 | 마지막 effect의 cleanup 함수 실행 |
| useEffect의 본질 | 생명주기 대체가 아닌 외부 시스템과의 동기화 |
| useLayoutEffect | paint 전 동기 실행 — DOM 측정, 깜빡임 방지 용도 |
| StrictMode | 개발 환경에서 effect를 두 번 실행하여 cleanup 누락 검증 |
생명주기를 "시점"이 아니라 "동기화"로 바라보면, effect의 의존성 배열과 cleanup 패턴이 자연스럽게 이해됩니다.