useEffect — 사용 시점과 회피 시점
부수 효과의 정의, 의존성 배열, cleanup의 역할, 그리고 useEffect를 쓰지 말아야 할 경우를 함께 정리합니다.
9장으로 1부가 마무리됐습니다. 지금까지 만든 컴포넌트들은 자기 안에서 모든 일이 시작되고 끝났습니다. 사용자 입력을 받고, state로 보관하고, 화면에 그리는 한 사이클이 컴포넌트 안에 닫혀 있었습니다. 본 챕터부터 2부가 시작됩니다. 컴포넌트가 외부 세계와 상호작용해야 할 때 쓰는 도구인 useEffect를 다루겠습니다.
useEffect는 강력한 만큼 오용도 잦습니다. 본 챕터에서는 사용법뿐 아니라 **“useEffect를 쓰지 말아야 할 경우”**까지 함께 짚겠습니다. 그리고 25장(데이터 페칭과 캐싱)에서는 Next.js RSC 환경이 useEffect + fetch의 가장 흔한 용도를 어떻게 대체하는지도 살펴보겠습니다.
Side Effect란 #
**Side effect (부수 효과)**는 컴포넌트의 핵심 역할인 “props / state로부터 JSX를 만드는 것” 외의 모든 작업입니다.
- 서버에서 데이터 가져오기 (
fetch) - 타이머 설정 (
setTimeout,setInterval) - 브라우저 API 사용 (
localStorage,document.title변경 등) - 외부 라이브러리 초기화
- 이벤트 리스너 등록 (
window.addEventListener)
이런 작업들은 모두 렌더링 결과 (JSX)를 만드는 것과 별개입니다. 그렇다고 컴포넌트와 무관한 것도 아닙니다. 어떤 데이터를 가져올지, 언제 타이머를 켜고 끌지는 컴포넌트의 props / state에 따라 결정되기 때문입니다.
리액트는 이런 작업을 컴포넌트 함수 본문에 직접 쓰지 않고 useEffect 안에 넣어 처리하는 것을 권장합니다.
왜 함수 본문에 직접 쓰면 안 되나 #
다음 코드를 보세요.
function Profile({ userId }) {
const [user, setUser] = useState(null);
// 🚫 함수 본문에서 직접 fetch
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
return user ? <p>{user.name}</p> : <p>로딩 중...</p>;
}문제는 두 가지입니다.
- 렌더링할 때마다 fetch가 일어남.
setUser로 state가 바뀌면 다시 렌더링되고, 또 fetch가 일어나고, 또 setUser가 호출되는 무한 루프가 됩니다. - 렌더링은 빠르고 순수해야 한다는 리액트의 원칙에 어긋납니다.
useEffect는 이런 작업을 렌더링이 끝난 뒤에, 그것도 필요할 때만 실행하도록 분리해 주는 장치입니다.
useEffect 기본 사용법 #
import { useState, useEffect } from 'react';
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user ? <p>{user.name}</p> : <p>로딩 중...</p>;
}
export default Profile;핵심 모양:
useEffect(() => {
// 실행할 코드
}, [의존성, 배열]);- 첫 번째 인자 — effect 함수 (실행할 코드)
- 두 번째 인자 — 의존성 배열 (이 값들이 바뀔 때만 effect를 다시 실행)
리액트는 컴포넌트가 화면에 그려진 뒤 effect 함수를 실행합니다. 다음 렌더링 시 의존성 배열의 값들이 이전과 같으면 effect를 건너뛰고, 다르면 다시 실행합니다.
의존성 배열의 세 가지 형태 #
1. 빈 배열 [] — 처음 한 번만
#
useEffect(() => {
console.log('컴포넌트가 처음 화면에 나타났습니다');
}, []);배열이 비어 있으면 의존하는 값이 없으므로 effect는 처음 한 번만 실행됩니다. 초기 데이터 로딩, 한 번만 실행하면 되는 초기화 작업에 자주 쓰입니다.
2. 의존성 명시 [a, b] — 그 값이 바뀔 때마다
#
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, [userId]);userId가 바뀌면 다시 fetch합니다. 동일한 값이 들어오면 다시 실행하지 않으니 효율적입니다. effect 안에서 쓴 모든 props / state는 의존성 배열에 넣어야 한다는 게 기본 규칙입니다. 안 넣으면 오래된 값을 참조하는 버그가 생기기 쉽습니다.
3. 배열 자체를 안 적음 — 매 렌더링마다 #
useEffect(() => {
console.log('렌더링됨');
});의존성 배열을 아예 빼면 매 렌더링마다 effect가 실행됩니다. 거의 쓸 일이 없고, 보통은 의도치 않은 무한 루프의 원인이 되니 의식적으로 쓰지 않는 편이 좋습니다.
react-hooks/exhaustive-deps 규칙이 빠진 의존성을 자동으로 잡아 줍니다. Vite의 기본 ESLint 설정에 포함돼 있어 코드 작성 중에 경고가 뜹니다. 경고를 무시하지 말고 그대로 따르면 대부분 정확합니다.Cleanup 함수 #
effect가 등록한 리소스 (타이머, 이벤트 리스너, 구독 등)는 정리가 필요할 때가 많습니다. 컴포넌트가 화면에서 사라지거나, 의존성이 바뀌어 effect가 다시 실행되기 직전에 이전 effect의 정리 작업을 해야 합니다.
useEffect의 effect 함수가 함수를 반환 하면, 그 함수를 리액트가 cleanup 시점에 호출해 줍니다.
import { useState, useEffect } from 'react';
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(id); // cleanup
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}
export default Clock;이 컴포넌트가 사라질 때 리액트가 반환된 함수를 호출해 clearInterval로 타이머를 정리합니다. cleanup이 없으면 컴포넌트가 사라진 뒤에도 타이머가 살아 돌아다녀 메모리 누수와 알 수 없는 버그를 일으킵니다.
의존성이 바뀔 때도 cleanup이 호출됩니다 #
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => {
cancelled = true;
};
}, [userId]);userId가1일 때 fetch 시작- 응답이 오기 전에 사용자가 다른 페이지로 이동해
userId가2로 바뀜 - 리액트가 이전 effect의 cleanup (
cancelled = true)을 먼저 실행 - 새 effect가 실행되어
2에 대한 fetch 시작 - 늦게 도착한 1번 응답은
cancelled === true라 무시됨
이런 race condition 처리가 cleanup의 또 하나의 흔한 용도입니다.
흔한 패턴들 #
데이터 가져오기 #
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('요청 실패');
return res.json();
})
.then(data => setUser(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <p>불러오는 중...</p>;
if (error) return <p>에러: {error}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}loading / error / data 세 state로 비동기 요청의 모든 상태를 표현하는 패턴이 정석입니다. Promise의 .finally()에서 로딩을 끄면 성공이든 실패든 일관되게 처리됩니다.
실무에서는 직접 useEffect + fetch를 짜는 것보다 TanStack Query 같은 데이터 페칭 라이브러리를 쓰는 경우가 많습니다. 캐싱, 재시도, 백그라운드 동기화 등을 알아서 처리해 줍니다. 다만 그 라이브러리들도 결국 useEffect로 만들어진 것이라 동작 원리를 이해하는 데 도움이 됩니다.
그리고 이 책의 4부 (모던 Next.js)에서는 한 발 더 나아갑니다. Server Components 환경에서는 데이터 페칭을 클라이언트 useEffect 안에서 하지 않고 서버 컴포넌트 함수 본문에서 직접 합니다. 25장 (데이터 페칭과 캐싱)에서 그 모델을 다룹니다.
이벤트 리스너 등록 #
function WindowSize() {
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>;
}addEventListener ↔ removeEventListener는 짝이 맞아야 하므로 cleanup이 필수입니다.
document.title 같은 외부 상태 동기화 #
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `카운트: ${count}`;
}, [count]);
// ...
}document.title은 리액트가 관리하는 영역 밖이므로, 우리 state와 동기화하려면 effect가 필요합니다.
localStorage 동기화 #
function Settings() {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('theme') ?? 'light';
});
useEffect(() => {
localStorage.setItem('theme', theme);
}, [theme]);
// ...
}useState의 초기값에 함수를 전달하면 처음 마운트 시에만 실행됩니다. localStorage.getItem이 매 렌더링마다 호출되는 것을 막을 수 있습니다. 이후 theme이 바뀔 때마다 effect가 새 값을 저장합니다.
useEffect를 쓰지 말아야 할 경우 #
리액트 공식 문서가 강조하는 점입니다. 계산으로 끝낼 수 있는 일은 useEffect로 처리하지 마세요. useEffect 오용의 절반은 여기서 시작됩니다.
1. 다른 state로부터 계산되는 값 #
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`;fullName은 firstName / lastName으로부터 늘 계산할 수 있는 값입니다. 별도 state로 두면 두 값을 항상 일치시키는 책임이 우리에게 떨어집니다. 그냥 변수로 두면 매번 자동으로 정확한 값이 나옵니다. 11장 (상태 끌어올리기)의 “Single Source of Truth"와 같은 맥락입니다.
2. 이벤트 핸들러 안에서 처리할 일 #
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
sendAnalytics('submit');
}
}, [submitted]);
function handleSubmit() {
setSubmitted(true);
}function handleSubmit() {
sendAnalytics('submit');
}“클릭이 일어났을 때 X 한다"는 일은 클릭 핸들러 안에서 직접 하면 됩니다. state를 거쳐 effect로 우회할 이유가 없습니다.
3. 일회성 초기화 #
function App() {
useEffect(() => {
initializeAnalytics();
}, []);
// ...
}initializeAnalytics();
function App() {
// ...
}앱 전체에서 한 번만 실행되면 되는 초기화 코드는 컴포넌트 안에 둘 필요가 없습니다. 모듈 최상위에서 한 번 실행되면 충분합니다.
판별 기준 #
useEffect가 정말 필요한지는 다음 한 줄로 판단하면 됩니다.
이 작업이 외부 세계 (서버, 타이머, DOM API, 브라우저 API)와 관련 있는가?
그게 아니면 거의 useEffect는 필요 없습니다.
흔한 실수 #
의존성 빠뜨리기 #
useEffect(() => {
fetch(`/api/users/${userId}`).then(/* ... */);
}, []);빈 배열로 두면 첫 렌더링의 userId로만 fetch하고, 이후 userId가 바뀌어도 다시 가져오지 않습니다. ESLint가 잡아 주니 경고를 따르세요.
effect 안에서 무한히 setState #
useEffect(() => {
setCount(count + 1); // 🚫 의존성 [count] 안에서 count를 변경
}, [count]);state를 바꾸면 다시 렌더링되고, 의존성이 바뀌었으니 effect가 다시 실행되고, 또 state가 바뀌는 끝없는 루프입니다. effect는 외부 세계를 변경하는 역할이지, 자기 자신의 state를 끊임없이 바꾸기 위한 도구가 아닙니다.
직접 해보기 #
간단한 시계 + 페이지 제목 동기화 컴포넌트를 만들어 봅니다.
src/ClockTitle.jsx:
import { useState, useEffect } from 'react';
function ClockTitle() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
document.title = `현재 시각: ${time.toLocaleTimeString()}`;
}, [time]);
return (
<div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>{time.toLocaleTimeString()}</h2>
<p>브라우저 탭 제목도 같이 변하는지 확인해 보세요.</p>
</div>
);
}
export default ClockTitle;저장하면 시계가 1초마다 갱신되고, 브라우저 탭의 제목도 같이 바뀝니다. 두 개의 useEffect가 각자 다른 일을 하면서 협력하는 모습을 볼 수 있습니다. 첫 번째는 1초마다 시간을 업데이트하고, 두 번째는 시간이 바뀔 때마다 탭 제목을 동기화합니다.
연습문제 #
WindowSize컴포넌트를 만들어window.innerWidth와window.innerHeight를 화면에 표시하고, 창 크기가 바뀔 때마다 자동으로 갱신되도록 만들어 보세요.resize이벤트 리스너 등록 + cleanup으로 해제. 마운트 시점에 초기값도 정확히 읽어 와야 합니다.- useEffect 오용 잡아내기 연습. 다음 두 변수가 있을 때 어느 쪽이 useEffect가 필요한지 판단해 보세요. (a)
firstName과lastName으로부터fullName계산, (b)userId가 바뀔 때마다/api/users/:id에서 정보 가져오기. (a)는 useEffect 없이 변수로, (b)는 useEffect + cleanup으로 구현합니다. - 자가 정리 타이머.
useState로seconds를 두고, 마운트 시점부터 1초마다 +1 증가시키는 컴포넌트를 만들어 보세요. 컴포넌트가 사라질 때 (예: 부모에서 조건부로 제거할 때)setInterval이 깔끔하게 해제되어 메모리 누수가 없는지 콘솔에서 확인합니다. React strict mode에서 dev 시 effect가 두 번 실행되는 것도 함께 관찰하면 좋습니다.
한 줄 요약:
useEffect(fn, deps)는deps가 바뀔 때마다fn을 실행한다.[]면 처음 한 번,[a]면a가 바뀔 때, 생략하면 매 렌더링. 함수를 반환하면 cleanup. effect 안에서 쓴 props / state는 모두 의존성에 포함. 단순 계산 · 이벤트 처리 · 일회성 초기화는 useEffect로 처리하지 않는다.
다음 챕터 #
지금까지 다룬 모든 컴포넌트는 자기 자신의 state를 가지고 있었습니다. 그런데 두 개의 형제 컴포넌트가 같은 state를 공유해야 하는 상황이라면 어떻게 해야 할까요? 다음 11장 상태 끌어올리기에서는 이런 경우 쓰는 핵심 패턴인 lifting state up을 배우겠습니다.