이런 증상인가요?

  • 페이지에 스피너(빙글빙글 도는 아이콘)만 계속 돌고 화면이 안 뜬다
  • 'Loading...' 글자가 사라지지 않는다
  • 새로고침해도 똑같다
  • 개발자도구(F12) Console에 빨간 에러가 있거나, Network 탭에 실패(빨강) 요청이 보인다

무한 로딩은 거의 항상 "데이터를 다 못 받았는데 로딩 상태를 끄지 못한" 상황입니다. 원인은 크게 4가지이고, 대부분 코드 두 줄로 잡힙니다.

원인 1. 요청이 실패하면 로딩을 못 끄게 짜여 있다 (가장 흔함)

보통 이렇게 짭니다.

jsx

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) 자리입니다.

jsx

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가지:

jsx

useEffect(() => { /* ... */ });            // 🚩 배열이 아예 없음 → 매 렌더마다 재실행
useEffect(() => { /* ... */ }, [options]); // 🚩 options가 객체면 매 렌더마다 새로 만들어져 매번 다름

React 공식 문서: 객체·배열·함수는 렌더마다 새로 만들어지므로 의존성 배열에 넣으면 매 커밋마다 값이 달라져 무한 루프가 납니다.

해결

  • 한 번만 실행할 거면 빈 배열 []을 붙인다: useEffect(() => { ... }, []);
  • 객체/함수는 useEffect 안에서 만들고, 의존성엔 원시값(문자열·숫자)만 넣는다:
jsx

useEffect(() => {
  const options = { userId, roomId }; // Effect 안에서 생성
  // ...
}, [userId, roomId]); // 원시값만 의존성에
  • 자기 상태로 자기를 갱신할 땐 업데이터 함수: setCount(c => c + 1) (← setCount(count + 1) + [count] 조합은 루프 위험)

원인 4. 응답 순서가 꼬여 로딩이 안 풀린다 (탭/검색 빠르게 전환 시)

입력값을 빠르게 바꾸면 예전 요청이 나중에 도착해 상태가 꼬입니다. React 공식 fetch 패턴은 정리(cleanup) ignore 플래그를 씁니다.

jsx

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분 점검 순서

  1. F12 → Console: 빨간 에러가 있나? 있으면 그 메시지가 진짜 원인입니다.
  2. F12 → Network: 실패(빨강) 요청이 있나? 상태 코드가 404 / 500 인지, CORS 에러인지 확인.
  3. 코드에서 setLoading(false)finally 안에 있나? 없으면 → 원인 1.
  4. res.ok 확인이 있나? 없으면 → 원인 2.
  5. useEffect에 의존성 배열이 제대로 있나? 없거나 객체면 → 원인 3.

> 확정 수정: 원인 1·2·3은 React·MDN 공식 문서로 검증된 정석 수정입니다. 대부분의 무한 로딩은 "로딩을 finally에서 끄기" + "res.ok 확인" 두 줄이면 잡힙니다.