이런 증상인가요?
- 페이지에 스피너(빙글빙글 도는 아이콘)만 계속 돌고 화면이 안 뜬다
- 'Loading...' 글자가 사라지지 않는다
- 새로고침해도 똑같다
- 개발자도구(F12) Console에 빨간 에러가 있거나, Network 탭에 실패(빨강) 요청이 보인다
무한 로딩은 거의 항상 "데이터를 다 못 받았는데 로딩 상태를 끄지 못한" 상황입니다. 원인은 크게 4가지이고, 대부분 코드 두 줄로 잡힙니다.
원인 1. 요청이 실패하면 로딩을 못 끄게 짜여 있다 (가장 흔함)
보통 이렇게 짭니다.
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false); // 성공할 때만 로딩을 끔
});
}, []);
문제: 요청이 실패하면(네트워크 끊김, CORS 차단 등) .then 안으로 못 들어가서 setLoading(false)가 영원히 안 불립니다. 그래서 스피너가 멈추지 않습니다. MDN 문서상 fetch는 네트워크 오류나 잘못된 URL(그리고 CORS 실패) 때 promise가 reject 됩니다.
해결: 로딩은 finally에서 끈다
try / catch / finally로 감싸면, 성공하든 실패하든 finally가 항상 실행됩니다. MDN 기준 finally(그리고 Promise.prototype.finally)는 promise가 성공/실패 어느 쪽이든 항상 실행되는 정리(cleanup) 자리입니다.
useEffect(() => {
const load = async () => {
try {
setLoading(true);
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`); // ★ 아래 원인 2 참고
setData(await res.json());
} catch (e) {
setError(e.message); // 에러 화면 보여주기
} finally {
setLoading(false); // ★ 무조건 실행 → 스피너가 반드시 멈춘다
}
};
load();
}, []);
.then() 체인을 쓰고 있다면 맨 끝에 .finally(() => setLoading(false))를 붙이세요.
> CORS 에러(Network 탭에 'blocked by CORS policy')라면, finally 덕분에 스피너는 멈추지만 데이터는 여전히 못 받습니다. 근본 해결은 API 서버가 응답에 Access-Control-Allow-Origin 헤더를 넣어주는 것입니다(프런트엔드에선 못 고칩니다).
원인 2. 404/500인데 '성공'으로 처리된다
fetch()는 서버가 404, 500을 돌려줘도 에러로 치지 않습니다. 그냥 .then으로 들어옵니다. 그래서 에러 페이지(HTML)를 res.json() 하다가 터지거나, 데이터가 비어서 화면이 안 그려집니다. MDN: fetch는 404·504 같은 HTTP 오류 상태에는 reject하지 않으므로, Response.ok / Response.status를 직접 확인해야 합니다.
해결: res.ok를 반드시 확인
위 코드의 if (!res.ok) throw ... 한 줄이 그 역할입니다. 이러면 404가 catch로 가서 에러 화면이 뜨고, 무한 로딩이 아니라 "문제 있음"이 보입니다.
원인 3. useEffect가 무한 반복된다
의존성 배열을 잘못 쓰면 컴포넌트가 계속 리렌더 → fetch 재실행 → 또 리렌더… 스피너가 절대 안 멈춥니다.
흔한 실수 2가지:
useEffect(() => { /* ... */ }); // 🚩 배열이 아예 없음 → 매 렌더마다 재실행
useEffect(() => { /* ... */ }, [options]); // 🚩 options가 객체면 매 렌더마다 새로 만들어져 매번 다름
React 공식 문서: 객체·배열·함수는 렌더마다 새로 만들어지므로 의존성 배열에 넣으면 매 커밋마다 값이 달라져 무한 루프가 납니다.
해결
- 한 번만 실행할 거면 빈 배열
[]을 붙인다:useEffect(() => { ... }, []); - 객체/함수는 useEffect 안에서 만들고, 의존성엔 원시값(문자열·숫자)만 넣는다:
useEffect(() => {
const options = { userId, roomId }; // Effect 안에서 생성
// ...
}, [userId, roomId]); // 원시값만 의존성에
- 자기 상태로 자기를 갱신할 땐 업데이터 함수:
setCount(c => c + 1)(←setCount(count + 1)+[count]조합은 루프 위험)
원인 4. 응답 순서가 꼬여 로딩이 안 풀린다 (탭/검색 빠르게 전환 시)
입력값을 빠르게 바꾸면 예전 요청이 나중에 도착해 상태가 꼬입니다. React 공식 fetch 패턴은 정리(cleanup) ignore 플래그를 씁니다.
useEffect(() => {
let ignore = false;
const load = async () => {
try {
const res = await fetch(`/api/data?id=${id}`);
const data = await res.json();
if (!ignore) setData(data);
} finally {
if (!ignore) setLoading(false);
}
};
load();
return () => { ignore = true; }; // 정리: 옛 요청 결과는 무시
}, [id]);
3분 점검 순서
- F12 → Console: 빨간 에러가 있나? 있으면 그 메시지가 진짜 원인입니다.
- F12 → Network: 실패(빨강) 요청이 있나? 상태 코드가 404 / 500 인지, CORS 에러인지 확인.
- 코드에서
setLoading(false)가 finally 안에 있나? 없으면 → 원인 1. res.ok확인이 있나? 없으면 → 원인 2.useEffect에 의존성 배열이 제대로 있나? 없거나 객체면 → 원인 3.
> 확정 수정: 원인 1·2·3은 React·MDN 공식 문서로 검증된 정석 수정입니다. 대부분의 무한 로딩은 "로딩을 finally에서 끄기" + "res.ok 확인" 두 줄이면 잡힙니다.
