useEffect — 부수효과와 외부 시스템 동기화
useEffect의 목적과 실행 타이밍, 의존성 배열 세 가지 형태, cleanup 함수의 역할, 데이터 페칭 시 경쟁 조건 방지 패턴, 그리고 useEffect를 쓰지 말아야 할 상황까지 종합적으로 다룹니다.
지난 글에서 훅의 두 가지 핵심 규칙과 그 이유를 살펴봤다. 이번에는 가장 많이 사용하면서도 잘못 사용되는 훅인 useEffect를 다룬다. useEffect의 목적, 정확한 실행 타이밍, cleanup 패턴, 그리고 쓰지 말아야 할 상황을 이해하면 코드가 훨씬 명확해진다.
useEffect의 목적
useEffect는 컴포넌트를 외부 시스템과 동기화하기 위한 훅이다. 여기서 “외부 시스템”은 React가 직접 관리하지 않는 모든 것을 말한다.
- 브라우저 API (DOM, 타이머, 이벤트 리스너)
- 네트워크 요청 (데이터 페칭)
- WebSocket, SSE 연결
- 서드파티 위젯/라이브러리
function ChatRoom({ roomId }) {
useEffect(() => {
// 외부 시스템(WebSocket)과 동기화
const connection = createChatConnection(roomId);
connection.connect();
return () => connection.disconnect(); // 동기화 해제
}, [roomId]);
}
실행 타이밍
useEffect는 브라우저가 화면을 페인트한 후 비동기적으로 실행된다. DOM이 이미 반영된 상태다.
setState → 렌더 → DOM 반영 → 화면 페인트 → useEffect 실행
컴포넌트가 처음 화면에 표시된 후 useEffect가 실행되므로, 데이터를 가져오는 동안 로딩 UI를 보여주는 것이 자연스럽다.
의존성 배열 세 가지 형태
// 1. 배열 없음 — 매 렌더마다 실행
useEffect(() => {
document.title = `${count}회 클릭`;
}); // 위험: 무한 루프 가능
// 2. 빈 배열 — 마운트 시 1회
useEffect(() => {
initializeTracking();
return () => destroyTracking();
}, []);
// 3. 의존성 명시 — 변경 시마다
useEffect(() => {
fetchUser(userId);
}, [userId]); // userId 변경 시 재실행
cleanup 함수
cleanup 함수는 effect를 “되돌리는” 코드다. 두 시점에 실행된다.
- 언마운트 시
- 다음 effect 실행 직전 (의존성 변경 시)
// 이벤트 리스너 — 등록/제거 짝 맞추기
useEffect(() => {
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleKey]);
// 인터벌 — 설정/해제 짝 맞추기
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// 구독 — 구독/해지 짝 맞추기
useEffect(() => {
const unsubscribe = store.subscribe(onStoreChange);
return unsubscribe; // 함수 직접 반환 가능
}, []);
데이터 페칭과 경쟁 조건
useEffect에서 데이터를 가져올 때 **경쟁 조건(race condition)**이 발생할 수 있다.
// 문제가 있는 코드
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // userId가 빠르게 바뀌면 오래된 응답이 나중에 도착할 수 있음
});
}, [userId]);
}
userId가 1 → 2로 빠르게 바뀌면, userId=2 요청이 먼저 완료되고 userId=1 요청이 나중에 완료될 수 있다. 그러면 userId=2를 보고 있는데 userId=1의 데이터가 표시된다.
// 올바른 코드 — ignore 플래그 패턴
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetchUser(userId).then(data => {
if (!ignore) setUser(data); // cleanup 후엔 무시
});
return () => {
ignore = true; // userId 바뀌면 이전 요청 무시
};
}, [userId]);
return user ? <Profile user={user} /> : <Spinner />;
}
또는 AbortController를 사용해 요청 자체를 취소할 수 있다.
useEffect(() => {
const controller = new AbortController();
fetch(`/api/user/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') throw err; // 취소는 무시
});
return () => controller.abort();
}, [userId]);
useEffect를 쓰지 말아야 할 경우
useEffect를 습관적으로 사용하기 전에, 꼭 필요한지 확인하자.
props/state로 계산 가능한 값은 렌더 중에 직접 계산한다.
// 잘못된 패턴 — useEffect로 파생 state 만들기
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`); // 불필요한 리렌더 추가 발생
}, [firstName, lastName]);
// 올바른 패턴 — 렌더 중 계산
const fullName = `${firstName} ${lastName}`;
사용자 이벤트에 응답하는 코드는 이벤트 핸들러에 둔다.
// 잘못된 패턴
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendForm(data); // 이벤트가 아닌 state 변화로 작동
}
}, [submitted]);
// 올바른 패턴
function handleSubmit() {
sendForm(data); // 이벤트 핸들러에서 직접 호출
}
지난 글: 훅의 규칙
다음 글: 의존성 배열 — Object.is 비교와 함정들
읽어주셔서 감사합니다. 😊