🎯 학습 목표
- useEffect의 실행 순서와 cleanup 함수의 역할을 정확히 이해한다
- 의존성 배열 세 가지 형태를 각각 설명할 수 있다
- cleanup이 필요한 대표적인 사례를 코드로 구현한다
- React 18 StrictMode에서 effect가 두 번 실행되는 이유를 이해한다
- useLayoutEffect와 useEffect의 차이를 안다
📖 핵심 개념 1 — useEffect 실행 순서
useEffect는 렌더링이 완료된 후 실행됩니다. 브라우저가 화면을 그린 뒤에 실행되므로, DOM 조작이나 외부 요청에 적합합니다.
function LifecycleDemo() {
const [count, setCount] = useState(0);
console.log('1. 렌더링 시작'); // 렌더링마다 실행
useEffect(() => {
console.log('3. Effect 실행 (렌더 후)');
return () => {
console.log('2. Cleanup 실행 (다음 effect 전, 또는 언마운트 시)');
// cleanup은 다음 순서로 실행됨:
// 리렌더링 시: cleanup → 렌더 → effect
// 언마운트 시: cleanup
};
}, [count]);
console.log('1-2. 렌더링 계속');
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// count가 0→1로 바뀔 때 실행 순서:
// 1. 렌더링 시작
// 1-2. 렌더링 계속
// 2. Cleanup 실행 (이전 effect의 정리)
// 3. Effect 실행 (새 count=1 기준)
📖 핵심 개념 2 — 의존성 배열 세 가지 형태
// 형태 1: 빈 배열 [] — 마운트 시 한 번만 실행
useEffect(() => {
console.log('컴포넌트가 화면에 나타났습니다');
fetchInitialData(); // API 최초 호출
return () => {
console.log('컴포넌트가 화면에서 사라집니다');
// 리소스 정리
};
}, []); // 빈 배열 — 의존성 없음
// 형태 2: [dep1, dep2] — 명시된 값이 변경될 때마다 실행
useEffect(() => {
document.title = `검색: ${query} (${results.length}건)`;
fetchSearchResults(query);
}, [query]); // query가 변경될 때마다 실행
// 형태 3: 의존성 배열 생략 — 렌더링마다 실행 (거의 사용 안 함)
useEffect(() => {
console.log('매 렌더링 후 실행'); // 대부분의 경우 불필요
}); // 배열 없음
📖 핵심 개념 3 — Cleanup이 필요한 사례들
// 사례 1: 타이머
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// cleanup: 언마운트 시 타이머 정리
return () => clearInterval(id);
}, []);
return <p>경과 시간: {seconds}초</p>;
}
// 사례 2: 이벤트 리스너
function WindowSize() {
const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });
useEffect(() => {
function handleResize() {
setSize({ w: window.innerWidth, h: window.innerHeight });
}
window.addEventListener('resize', handleResize);
// cleanup: 이벤트 리스너 제거
return () => window.removeEventListener('resize', handleResize);
}, []);
return <p>창 크기: {size.w} × {size.h}</p>;
}
// 사례 3: fetch 취소 (AbortController)
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`, {
signal: controller.signal, // fetch에 signal 전달
});
const data = await res.json();
setUser(data);
} catch (err) {
if (err.name === 'AbortError') {
console.log('Fetch 취소됨 (컴포넌트 언마운트)');
return; // 취소 에러는 무시
}
console.error('Fetch 에러:', err);
}
}
fetchUser();
// cleanup: 컴포넌트 언마운트 또는 userId 변경 시 이전 fetch 취소
return () => controller.abort();
}, [userId]);
if (!user) return <p>로딩 중...</p>;
return <p>{user.name}</p>;
}
// 사례 4: WebSocket
function ChatRoom({ roomId }) {
const [messages, setMessages] = useState([]);
useEffect(() => {
const ws = new WebSocket(`wss://chat.example.com/rooms/${roomId}`);
ws.onmessage = (e) => {
setMessages(prev => [...prev, JSON.parse(e.data)]);
};
// cleanup: WebSocket 연결 종료
return () => ws.close();
}, [roomId]); // roomId 변경 시 이전 연결 닫고 새 연결 생성
return <ul>{messages.map((m, i) => <li key={i}>{m.text}</li>)}</ul>;
}
📖 핵심 개념 4 — StrictMode의 Effect 이중 실행
React 18 StrictMode에서는 개발 모드에서 effect를 마운트 → 언마운트 → 마운트 순으로 실행해, cleanup이 올바르게 구현됐는지 검증합니다.
// StrictMode에서 실행 순서:
// 1. 마운트 → effect 실행
// 2. cleanup 실행 (StrictMode가 강제로 언마운트)
// 3. 마운트 → effect 다시 실행
// ❌ cleanup 없이 이벤트 리스너 등록 → 이중 등록 버그
useEffect(() => {
window.addEventListener('click', handleClick);
// cleanup 없음 → StrictMode에서 리스너 2개 등록됨
}, []);
// ✅ cleanup 있음 → StrictMode에서도 리스너 1개
useEffect(() => {
window.addEventListener('click', handleClick);
return () => window.removeEventListener('click', handleClick);
}, []);
// StrictMode 이중 실행은 "cleanup이 없는 effect"를 감지하는 도구입니다.
// effect가 두 번 실행돼도 문제없으려면 cleanup을 반드시 구현해야 합니다.
📖 핵심 개념 5 — useLayoutEffect vs useEffect
// useEffect: 화면 그림 이후 비동기 실행 (일반적으로 사용)
useEffect(() => {
// DOM이 화면에 표시된 후 실행
// 깜빡임이 발생할 수 있지만, 페인팅을 차단하지 않음
}, []);
// useLayoutEffect: 화면 그림 전 동기 실행
// DOM을 측정하거나, 화면 표시 전에 DOM을 조작할 때 사용
useLayoutEffect(() => {
// DOM이 변경됐지만 브라우저가 아직 화면에 표시하기 전
// 이 시점에 DOM을 읽고/쓰면 깜빡임 없음
const rect = elementRef.current.getBoundingClientRect();
// 위치 기반 스타일 조정 등
}, []);
// 규칙: useEffect를 먼저 써보고,
// 화면 깜빡임이 보이면 그때 useLayoutEffect로 교체
⚠️ 흔한 실수 (よくあるミス)
- 의존성 배열 누락:
eslint-plugin-react-hooks의exhaustive-deps규칙을 활성화하면 누락된 의존성을 자동으로 경고해줍니다. 이 경고를 무시하면 stale closure 버그가 발생합니다. - useEffect 안에서 직접 async 함수 사용:
useEffect(async () => {...})는 금지입니다. async 함수는 Promise를 반환하는데, useEffect의 반환값은 cleanup 함수여야 합니다. 내부에 async 함수를 선언하고 호출하세요. - cleanup 없이 구독/타이머/이벤트 등록: 메모리 누수와 StrictMode 이중 등록 버그의 가장 흔한 원인입니다.
- 객체/배열을 의존성으로 사용: 매 렌더링마다 새 참조가 생성되어 무한 루프가 발생합니다. 원시값(string, number, boolean)을 의존성으로 사용하거나
useMemo로 메모이제이션하세요.
💡 실무 팁
- eslint-plugin-react-hooks 필수 설정: Vite 프로젝트 생성 시 포함되어 있습니다.
react-hooks/exhaustive-deps규칙을 error 레벨로 설정하세요. - 커스텀 훅으로 추출: useEffect 로직이 복잡해지면
useWindowSize,useLocalStorage등 커스텀 훅으로 분리하면 재사용성과 가독성이 높아집니다 (07강에서 다룹니다). - API 호출은 React Query 사용 권장: useEffect로 fetch하는 패턴은 캐싱, 로딩 상태, 에러 처리, 재시도 등을 직접 구현해야 합니다. 실무에서는 TanStack Query(React Query)를 사용하는 것이 표준입니다.