
언마운트된 컴포넌트에서 setState 호출 경고
컴포넌트가 사라진 후 setState를 호출해서 생기는 메모리 누수 경고 해결

컴포넌트가 사라진 후 setState를 호출해서 생기는 메모리 누수 경고 해결
습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

React나 Vue 프로젝트를 빌드해서 배포했는데, 홈 화면은 잘 나오지만 새로고침만 하면 'Page Not Found'가 뜨나요? CSR의 원리와 서버 설정(Nginx, Apache, S3)을 통해 이를 해결하는 완벽 가이드.

진짜 집을 부수고 다시 짓는 건 비쌉니다. 설계도(가상돔)에서 미리 그려보고 바뀐 부분만 공사하는 '똑똑한 리모델링'의 기술.

전역 상태 관리를 위해 Redux 대신 Context API를 선택했습니다. 하지만 `UserContext`에 모든 정보를 담자마자 앱 전체가 리렌더링되기 시작했습니다. Context 분리(Splitting) 전략.

제 서비스에서 사용자가 페이지를 빠르게 이동할 때 콘솔에 경고가 뜨기 시작했습니다:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
코드는 이랬습니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // 🔥 컴포넌트가 이미 언마운트됐을 수 있음!
});
}, [userId]);
return <div>{user?.name}</div>;
}
사용자가 프로필 페이지에 들어갔다가 빠르게 나가면, API 응답이 오기 전에 컴포넌트가 사라집니다. 그런데 응답이 오면 setUser를 호출하려고 하니까 경고가 뜨는 거였어요.
제가 가진 오개념은 이거였습니다: "useEffect 안의 비동기 작업은 자동으로 취소된다"
하지만 React는 비동기 작업을 자동으로 취소하지 않습니다. 컴포넌트가 언마운트돼도 Promise는 계속 실행됩니다.
타임라인:
setUser 호출 시도이 문제를 이해한 건 이런 비유를 들었을 때였습니다:
"컴포넌트는 식당 테이블이고, API 호출은 주문이다. 손님이 나가도 (언마운트) 주방은 계속 요리한다 (Promise 실행). 음식이 나왔을 때 (응답 도착) 테이블이 비어있으면 (언마운트됨) 문제가 생긴다."해결책은 cleanup 함수로 "손님이 나갔으니 주문 취소"를 알려주는 겁니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // 취소 플래그
fetchUser(userId).then(data => {
if (!cancelled) { // ✅ 취소 안 됐을 때만 setState
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: 취소 플래그 설정
};
}, [userId]);
return <div>{user?.name}</div>;
}
더 나은 방법은 AbortController로 실제로 요청을 취소하는 겁니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch cancelled');
} else {
console.error(err);
}
});
return () => {
controller.abort(); // ✅ 실제로 요청 취소
};
}, [userId]);
return <div>{user?.name}</div>;
}
이제 네트워크 요청도 취소됩니다!
이 패턴을 자주 쓰니까 커스텀 훅으로 만들었습니다:
function useSafeState(initialValue) {
const [value, setValue] = useState(initialValue);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const setSafeValue = useCallback((newValue) => {
if (mountedRef.current) {
setValue(newValue);
}
}, []);
return [value, setSafeValue];
}
// 사용
function UserProfile({ userId }) {
const [user, setUser] = useSafeState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // ✅ 안전!
}, [userId]);
return <div>{user?.name}</div>;
}
setTimeout이나 setInterval도 정리해야 합니다:
function Notification({ message }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);
return () => clearTimeout(timer); // ✅ cleanup
}, []);
if (!visible) return null;
return <div>{message}</div>;
}
이벤트 리스너도 빼먹지 마세요:
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
handleResize(); // 초기값 설정
return () => {
window.removeEventListener('resize', handleResize); // ✅ cleanup
};
}, []);
return <div>{size.width} x {size.height}</div>;
}
제 프로필 페이지를 이렇게 개선했습니다:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
실시간 검색에서도 cleanup을 적용했습니다:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
}, 300); // 디바운싱
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
When users quickly navigated pages in my service, console warnings started appearing:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
The code was:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(data => {
setUser(data); // 🔥 Component might already be unmounted!
});
}, [userId]);
return <div>{user?.name}</div>;
}
When users entered the profile page then quickly left, the component disappeared before the API response arrived. But when the response came, it tried to call setUser, triggering the warning.
My misconception: "Async operations in useEffect are automatically cancelled"
But React doesn't automatically cancel async operations. Even when components unmount, Promises continue executing.
Timeline:
setUserI understood with this analogy:
"A component is a restaurant table, an API call is an order. Even if the customer leaves (unmount), the kitchen keeps cooking (Promise executes). When food arrives (response) and the table is empty (unmounted), there's a problem."The solution is a cleanup function to say "customer left, cancel the order."
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // Cancel flag
fetchUser(userId).then(data => {
if (!cancelled) { // ✅ Only setState if not cancelled
setUser(data);
}
});
return () => {
cancelled = true; // cleanup: set cancel flag
};
}, [userId]);
return <div>{user?.name}</div>;
}
A better approach is actually cancelling the request with AbortController:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('Fetch cancelled');
} else {
console.error(err);
}
});
return () => {
controller.abort(); // ✅ Actually cancel request
};
}, [userId]);
return <div>{user?.name}</div>;
}
Now network requests are cancelled too!
Since I use this pattern often, I made a custom hook:
function useSafeState(initialValue) {
const [value, setValue] = useState(initialValue);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const setSafeValue = useCallback((newValue) => {
if (mountedRef.current) {
setValue(newValue);
}
}, []);
return [value, setSafeValue];
}
// Usage
function UserProfile({ userId }) {
const [user, setUser] = useSafeState(null);
useEffect(() => {
fetchUser(userId).then(setUser); // ✅ Safe!
}, [userId]);
return <div>{user?.name}</div>;
}
setTimeout and setInterval also need cleanup:
function Notification({ message }) {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 3000);
return () => clearTimeout(timer); // ✅ cleanup
}, []);
if (!visible) return null;
return <div>{message}</div>;
}
Don't forget event listeners:
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', handleResize);
handleResize(); // Set initial value
return () => {
window.removeEventListener('resize', handleResize); // ✅ cleanup
};
}, []);
return <div>{size.width} x {size.height}</div>;
}
I improved my profile page like this:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
setLoading(false);
}
});
return () => controller.abort();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}
Also applied cleanup to real-time search:
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
if (!query) {
setResults([]);
return;
}
const controller = new AbortController();
const timer = setTimeout(() => {
fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
}, 300); // Debouncing
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
isMounted RefYou might see this pattern in old tutorials:
/* 🚫 Anti-Pattern: Do not use this */
function Profile() {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => { isMounted.current = false };
}, []);
const fetchUser = async () => {
const data = await api.getUser();
if (isMounted.current) { // Check if mounted
setUser(data);
}
};
}
Why is this bad?
It suppresses the memory leak warning but doesn't fix the underlying problem (the Promise is still running and using resources).
Using AbortController not only prevents the state update but actually cancels the network request, saving data and battery.
Always prefer cancellation over just hiding the warning.
In React 18 Strict Mode (Development), useEffect runs twice.
Mount -> Unmount -> Mount.
This is intentional. It forces you to write correct cleanup functions.
If your cleanup logic is missing, you might see two API calls or weird bugs. This "double mount" behavior is React's way of training you to handle unmounts properly.
If you see your API being called twice:
AbortController or a caching mechanism (like React Query) to handle it gracefully anyway.