본문 바로가기

로딩 상태와 에러 처리

비동기 요청의 대기·성공·실패 상태를 분리해 사용자 피드백을 제공하고 커스텀 훅과 Error Boundary로 실패를 격리합니다.

데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 처리하고 사용자에게 적절히 피드백하는 방법을 더 깊이 있게 다루겠습니다.

데이터 페칭은 비동기 작업이므로, 네트워크 지연, 서버 응답 없음, 데이터 형식 오류 등 다양한 문제가 발생할 수 있습니다.

이 상황들을 사용자에게 명확히 전달하는 것은 좋은 사용자 경험(UX)을 제공하는 데 매우 중요합니다.

이 절의 요청 URL은 실습 재현성을 위해 로컬 Mock API(json-server, http://localhost:4000) 기준으로 작성합니다.

포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두면 트랙 병행 실습 시 충돌을 줄일 수 있습니다.


로딩 상태 (Loading State) 관리

사용자가 데이터를 기다리는 동안 애플리케이션이 멈춰 있는 것처럼 보인다면 사용자 경험이 저해됩니다.

데이터가 로딩 중임을 명확히 표시하여, 사용자가 요청이 처리되고 있음을 알 수 있게 해야 합니다.

구현 방법
  • useState 훅을 사용하여 loading 상태(true/false)를 관리합니다.
  • 데이터 요청 시작 시 loading을 true로 설정합니다.
  • 데이터 요청 완료 시 (성공 또는 실패와 무관하게) loading을 false로 설정합니다.
  • loading 상태가 true일 때 스피너, 스켈레톤 UI, 또는 데이터 로딩 중...과 같은 메시지를 렌더링합니다.
src/components/DataFetcherWithLoading.js (로딩 상태 관리 예시)
import React, { useState, useEffect } from 'react';

function DataFetcherWithLoading() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true); // 초기값은 true (마운트 시 바로 로딩 시작)
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    let active = true;

    const fetchData = async () => {
      try {
        setLoading(true); // 💡 요청 시작 시 로딩 상태 true
        setError(null); // 이전 에러 초기화

        const response = await fetch('http://localhost:4000/posts/1', {
          signal: controller.signal,
        }); // 예시 API
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        if (!active) return;
        setData(result);
      } catch (err) {
        if (err?.name === 'AbortError' || !active) return;
        setError(err); // 💡 에러 발생 시 에러 상태 업데이트
      } finally {
        if (active) {
          setLoading(false); // 현재 요청만 로딩 상태를 종료
        }
      }
    };

    fetchData();

    return () => {
      active = false;
      controller.abort();
    };
  }, []);

  if (loading) {
    // 💡 로딩 중일 때 사용자에게 피드백 제공
    return (
      <div style={{ textAlign: 'center', padding: '30px', fontSize: '1.2em', color: '#555' }}>
        <p>데이터를 불러오는 중입니다...</p>
        {/* 간단한 스피너 CSS 예시 */}
        <div style={{
          border: '4px solid rgba(0, 0, 0, 0.1)',
          borderTop: '4px solid #3498db',
          borderRadius: '50%',
          width: '30px',
          height: '30px',
          animation: 'spin 1s linear infinite',
          margin: '20px auto',
        }}></div>
        <style>{`
          @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
          }
        `}</style>
      </div>
    );
  }

  if (error) {
    // 💡 에러 발생 시 에러 메시지 표시
    return (
      <div style={{ textAlign: 'center', padding: '30px', fontSize: '1.2em', color: 'red', border: '1px solid #e74c3c', borderRadius: '8px', backgroundColor: '#fdebeb' }}>
        <p>데이터 로딩 중 오류가 발생했습니다!</p>
        <p>오류 메시지: {error.message}</p>
        <button
          onClick={() => window.location.reload()}
          style={{ marginTop: '20px', padding: '10px 20px', backgroundColor: '#e74c3c', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
        >
          다시 시도
        </button>
      </div>
    );
  }

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)', backgroundColor: '#fdfdfd' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>로딩/에러 처리된 데이터</h2>
      <h3 style={{ color: '#3498db', marginBottom: '10px' }}>{data.title}</h3>
      <p>{data.body}</p>
    </div>
  );
}

export default DataFetcherWithLoading;

App.js에 이 컴포넌트를 추가하여 테스트할 수 있습니다.


에러 처리 (Error Handling)

네트워크 요청은 항상 성공하는 것이 아닙니다.

다음과 같은 다양한 이유로 실패할 수 있습니다.

  • 네트워크 연결 없음
  • 서버 응답 없음
  • HTTP 상태 코드 4xx (클라이언트 오류) 또는 5xx (서버 오류)
  • 응답 데이터 파싱 오류
  • API 키 만료 등 백엔드 로직 오류
구현 방법
  • useState 훅을 사용하여 error 상태(null 또는 Error 객체)를 관리합니다.
  • try...catch 블록을 사용하여 비동기 함수 내에서 발생할 수 있는 에러를 포착합니다.
  • fetch API의 경우, response.ok (HTTP 상태 코드가 200-299 범위인지 여부)를 확인하여 서버 응답이 성공적인지 확인해야 합니다. response.ok가 false이면 직접 Error를 던져 catch 블록에서 처리하도록 합니다.
  • catch 블록에서 error 상태를 업데이트하고, 사용자에게 친화적인 에러 메시지를 표시합니다.
  • 필요에 따라 에러 발생 시 재시도 버튼을 제공하거나, 로깅 시스템에 에러를 기록합니다.
코드 예시 (위 DataFetcherWithLoading.js에 포함되어 있습니다)
    const controller = new AbortController();
    let active = true;

    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null); // 이전 에러 상태 초기화

        const response = await fetch('http://localhost:4000/posts/1', {
          signal: controller.signal,
        });

        // 💡 응답이 성공적인지 확인 (HTTP 상태 코드 200-299)
        if (!response.ok) {
          // 💡 성공적이지 않으면 에러를 던져 catch 블록으로 보냄
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const result = await response.json();
        if (!active) return;
        setData(result);
      } catch (err) {
        if (err?.name === 'AbortError' || !active) return;
        setError(err); // 💡 발생한 에러를 상태에 저장
      } finally {
        if (active) setLoading(false);
      }
    };

    // Effect cleanup에서는 active=false로 바꾸고 controller.abort()를 호출합니다.
에러 메시지 표시
  if (error) {
    return (
      <div style={{ textAlign: 'center', padding: '30px', fontSize: '1.2em', color: 'red', border: '1px solid #e74c3c', borderRadius: '8px', backgroundColor: '#fdebeb' }}>
        <p>데이터 로딩 중 오류가 발생했습니다!</p>
        <p>오류 메시지: {error.message}</p>
      </div>
    );
  }

로딩과 에러는 개별 메시지가 아니라 요청 상태와 데이터 유무에 따라 선택되는 렌더 분기표로 관리하면 더 안정적입니다.

요청 UI의 주 상태와 background refresh 축 — 상태 추적

첫 요청의 배타 status와 기존 data/empty를 유지하는 isRefreshing 축은 다르다. 같은 key 갱신 성공/실패 및 key 변경의 값과 화면 보존을 추적할 가치가 있다.

idle, loading, error, success의 data·empty 분기와 retry 전이 위에 기존 데이터를 유지하는 background isRefreshing 축을 분리한 요청 UI 상태 모델

Primary status × background refresh

첫 요청 상태와 백그라운드 갱신을 분리하기

첫 요청의 화면 상태와 기존 데이터를 갱신하는 진행 상태는 같은 축이 아닙니다. 주 상태는 한 번에 하나를 선택하고, isRefreshing은 data 또는 empty 화면을 유지한 채 뒤에서 새 요청이 진행 중임을 덧붙입니다.

첫 요청: 실패하면 결과 화면을 차단한다

첫 요청: 실패하면 결과 화면을 차단한다최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.시작resolverejectretry항목빈 값idle결과 없음loading첫 결과 대기resolve최신 응답error화면 차단retry 가능successdatasuccessempty
첫 요청: 실패하면 결과 화면을 차단한다최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.시작resolverejectretry항목빈 값idle결과 없음loading첫 결과 대기resolve최신응답error화면차단retry가능successdatasuccessempty

최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.

같은 key 갱신: 기존 결과를 유지한다

같은 key 갱신: 기존 결과를 유지한다같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.refetchresolverejectretry기존 successdata / empty같은 key refetch기존 UI + refreshing성공새 결과 교체실패기존 결과 유지비차단 오류
같은 key 갱신: 기존 결과를 유지한다같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.refetchresolverejectretry기존 successdata / empty같은 keyrefetch기존 UI +refreshing성공새 결과교체실패기존결과유지비차단오류

같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.

원래 설명과 상세 기준
주 상태 · 배타적

idle → loading → success | error

  • idle: URL이나 시작 조건이 아직 없음
  • loading: 표시할 기존 데이터가 없는 첫 요청
  • success:data: 성공했고 표시할 항목이 있음
  • success:empty: 성공했지만 결과가 비어 있음
  • error: 첫 요청이 실패해 복구 행동이 필요함
직교 축 · 비차단

isRefreshing은 기존 UI를 보존

data 또는 empty 상태에서 같은 resource key를 refetch하면 기존 콘텐츠를 지우지 않고 진행 표시만 추가합니다. key에는 인증 주체·locale·query처럼 응답 정체성을 바꾸는 입력을 모두 포함합니다. 새 결과가 오면 교체하고, 갱신 실패는 기존 결과와 함께 비차단 오류·재시도 동선으로 보여줍니다.

AbortController는 불필요한 요청을 중단하고, active/latest request ID gate는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 합니다. finally도 같은 gate를 통과해야 합니다.

요청 이벤트가 주 상태와 화면 계약을 바꾸는 규칙
현재 상태 이벤트 다음 상태 사용자에게 보이는 화면
idle 유효한 URL로 시작 loading 스켈레톤·진행 안내, 중복 실행 제한
loading 항목과 함께 resolve success:data 데이터와 다음 행동
loading 빈 결과로 resolve success:empty 오류가 아닌 빈 상태 안내
loading reject error 원인 요약과 retry 행동
error retry loading 이전 오류를 정리하고 다시 요청
success 같은 key를 background refetch success + 갱신 중 기존 data·empty UI를 그대로 유지
success + 갱신 중 최신 요청 resolve success 새 결과로 교체하고 갱신 표시 종료
success + 갱신 중 최신 요청 reject success + 갱신 오류 기존 결과를 유지하고 비차단 retry 제공
idle → loading

유효한 요청을 시작한다

표시할 결과가 없으므로 스켈레톤과 진행 안내를 보여주고 중복 실행을 제한합니다.

loading → success

resolve 결과는 data 또는 empty

항목이 있으면 데이터와 다음 행동을, 값이 비었으면 오류가 아닌 빈 상태 안내를 보여줍니다.

loading → error

reject 원인과 복구 행동을 제시한다

원인을 요약하고 사용자가 다시 요청할 수 있는 retry 동선을 제공합니다.

error → loading

retry는 새 요청 이벤트다

이전 오류를 정리하고 새 요청을 시작하며, 별도의 영구 화면 상태로 취급하지 않습니다.

success + refreshing

같은 key의 결과를 뒤에서 갱신한다

기존 data·empty 화면은 유지하고 진행 표시만 더합니다. 응답 정체성 key가 바뀌면 loading으로 돌아갑니다.

refresh completion

최신 완료만 현재 화면에 반영한다

resolve면 새 결과로 교체합니다. reject면 기존 결과를 유지하고 비차단 오류와 retry를 보여줍니다.

retry는 별도 화면 상태가 아니라 error에서 새 loading 요청을 시작하는 이벤트입니다. 응답 정체성 key가 바뀌면 이전 엔터티를 유지하지 않고 loading으로 돌아가며, empty는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명첫 요청의 화면 상태와 기존 데이터를 갱신하는 진행 상태는 같은 축이 아닙니다. 주 상태는 한 번에 하나를 선택하고, isRefreshing은 data 또는 empty 화면을 유지한 채 뒤에서 새 요청이 진행 중임을 덧붙입니다.
원도표 조건·주석idle: URL이나 시작 조건이 아직 없음
원도표 조건·주석loading: 표시할 기존 데이터가 없는 첫 요청
원도표 조건·주석success:data: 성공했고 표시할 항목이 있음
원도표 조건·주석success:empty: 성공했지만 결과가 비어 있음
원도표 조건·주석error: 첫 요청이 실패해 복구 행동이 필요함
원도표 설명isRefreshing은 기존 UI를 보존
원도표 설명data 또는 empty 상태에서 같은 resource key를 refetch하면 기존 콘텐츠를 지우지 않고 진행 표시만 추가합니다. key에는 인증 주체·locale·query처럼 응답 정체성을 바꾸는 입력을 모두 포함합니다. 새 결과가 오면 교체하고, 갱신 실패는 기존 결과와 함께 비차단 오류·재시도 동선으로 보여 줍니다.
원도표 설명AbortController는 불필요한 요청을 중단하고, active/latest request ID gate는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 합니다. finally도 같은 gate를 통과해야 합니다.
원도표 표 행현재 상태 | 이벤트 | 다음 상태 | 사용자에게 보이는 화면
원도표 표 행idle | 유효한 URL로 시작 | loading | 스켈레톤·진행 안내, 중복 실행 제한
원도표 표 행loading | 항목과 함께 resolve | success:data | 데이터와 다음 행동
원도표 표 행loading | 빈 결과로 resolve | success:empty | 오류가 아닌 빈 상태 안내
원도표 표 행loading | reject | error | 원인 요약과 retry 행동
원도표 표 행error | retry | loading | 이전 오류를 정리하고 다시 요청
원도표 표 행success | 같은 key를 background refetch | success + 갱신 중 | 기존 data·empty UI를 그대로 유지
원도표 표 행success + 갱신 중 | 최신 요청 resolve | success | 새 결과로 교체하고 갱신 표시 종료
원도표 표 행success + 갱신 중 | 최신 요청 reject | success + 갱신 오류 | 기존 결과를 유지하고 비차단 retry 제공
원도표 설명표시할 결과가 없으므로 스켈레톤과 진행 안내를 보여 주고 중복 실행을 제한합니다.
원도표 설명항목이 있으면 데이터와 다음 행동을, 값이 비었으면 오류가 아닌 빈 상태 안내를 보여 줍니다.
원도표 설명resolve면 새 결과로 교체합니다. reject면 기존 결과를 유지하고 비차단 오류와 retry를 보여 줍니다.
원도표 설명retry는 별도 화면 상태가 아니라 error에서 새 loading 요청을 시작하는 이벤트입니다. 응답 정체성 key가 바뀌면 이전 엔터티를 유지하지 않고 loading으로 돌아가며, empty는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
첫 결과 없음status: 배타적인 주 상태 / 기존 data: 없음첫 요청은 loading 뒤 data·empty·error로 끝난다표시할 자료가 없는 대기를 분리한다
같은 key 갱신기존 결과: 유지 / refreshing: truesuccess 화면을 유지하며 isRefreshing을 켠다주 상태 success와 진행 축은 함께 있을 수 있다
갱신 완료성공: 새 data/empty / 실패: 기존 결과+retry성공은 새 결과, 실패는 기존 결과와 비차단 오류로 남는다다른 resource key는 이전 엔터티를 유지하는 같은 갱신이 아니다

요청 UI의 주 상태와 background refresh 축 — 상태 추적

첫 요청의 배타 status와 기존 data/empty를 유지하는 isRefreshing 축은 다르다. 같은 key 갱신 성공/실패 및 key 변경의 값과 화면 보존을 추적할 가치가 있다.

idle, loading, error, success의 data·empty 분기와 retry 전이 위에 기존 데이터를 유지하는 background isRefreshing 축을 분리한 요청 UI 상태 모델

Primary status × background refresh

첫 요청 상태와 백그라운드 갱신을 분리하기

첫 요청의 화면 상태와 기존 데이터를 갱신하는 진행 상태는 같은 축이 아닙니다. 주 상태는 한 번에 하나를 선택하고, isRefreshing은 data 또는 empty 화면을 유지한 채 뒤에서 새 요청이 진행 중임을 덧붙입니다.

첫 요청: 실패하면 결과 화면을 차단한다

첫 요청: 실패하면 결과 화면을 차단한다최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.시작resolverejectretry항목빈 값idle결과 없음loading첫 결과 대기resolve최신 응답error화면 차단retry 가능successdatasuccessempty
첫 요청: 실패하면 결과 화면을 차단한다최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.시작resolverejectretry항목빈 값idle결과 없음loading첫 결과 대기resolve최신응답error화면차단retry가능successdatasuccessempty

최신 요청의 첫 reject는 차단 error이며 retry는 loading으로 돌아갑니다. empty는 실패가 아니라 성공 결과입니다.

같은 key 갱신: 기존 결과를 유지한다

같은 key 갱신: 기존 결과를 유지한다같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.refetchresolverejectretry기존 successdata / empty같은 key refetch기존 UI + refreshing성공새 결과 교체실패기존 결과 유지비차단 오류
같은 key 갱신: 기존 결과를 유지한다같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.refetchresolverejectretry기존 successdata / empty같은 keyrefetch기존 UI +refreshing성공새 결과교체실패기존결과유지비차단오류

같은 key 갱신 실패는 기존 data/empty를 유지하며 비차단 오류와 retry를 표시합니다. 새 resource key는 위 첫 요청의 loading으로 돌아가므로 이전 엔터티를 유지하지 않습니다.

원래 설명과 상세 기준
주 상태 · 배타적

idle → loading → success | error

  • idle: URL이나 시작 조건이 아직 없음
  • loading: 표시할 기존 데이터가 없는 첫 요청
  • success:data: 성공했고 표시할 항목이 있음
  • success:empty: 성공했지만 결과가 비어 있음
  • error: 첫 요청이 실패해 복구 행동이 필요함
직교 축 · 비차단

isRefreshing은 기존 UI를 보존

data 또는 empty 상태에서 같은 resource key를 refetch하면 기존 콘텐츠를 지우지 않고 진행 표시만 추가합니다. key에는 인증 주체·locale·query처럼 응답 정체성을 바꾸는 입력을 모두 포함합니다. 새 결과가 오면 교체하고, 갱신 실패는 기존 결과와 함께 비차단 오류·재시도 동선으로 보여줍니다.

AbortController는 불필요한 요청을 중단하고, active/latest request ID gate는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 합니다. finally도 같은 gate를 통과해야 합니다.

요청 이벤트가 주 상태와 화면 계약을 바꾸는 규칙
현재 상태 이벤트 다음 상태 사용자에게 보이는 화면
idle 유효한 URL로 시작 loading 스켈레톤·진행 안내, 중복 실행 제한
loading 항목과 함께 resolve success:data 데이터와 다음 행동
loading 빈 결과로 resolve success:empty 오류가 아닌 빈 상태 안내
loading reject error 원인 요약과 retry 행동
error retry loading 이전 오류를 정리하고 다시 요청
success 같은 key를 background refetch success + 갱신 중 기존 data·empty UI를 그대로 유지
success + 갱신 중 최신 요청 resolve success 새 결과로 교체하고 갱신 표시 종료
success + 갱신 중 최신 요청 reject success + 갱신 오류 기존 결과를 유지하고 비차단 retry 제공
idle → loading

유효한 요청을 시작한다

표시할 결과가 없으므로 스켈레톤과 진행 안내를 보여주고 중복 실행을 제한합니다.

loading → success

resolve 결과는 data 또는 empty

항목이 있으면 데이터와 다음 행동을, 값이 비었으면 오류가 아닌 빈 상태 안내를 보여줍니다.

loading → error

reject 원인과 복구 행동을 제시한다

원인을 요약하고 사용자가 다시 요청할 수 있는 retry 동선을 제공합니다.

error → loading

retry는 새 요청 이벤트다

이전 오류를 정리하고 새 요청을 시작하며, 별도의 영구 화면 상태로 취급하지 않습니다.

success + refreshing

같은 key의 결과를 뒤에서 갱신한다

기존 data·empty 화면은 유지하고 진행 표시만 더합니다. 응답 정체성 key가 바뀌면 loading으로 돌아갑니다.

refresh completion

최신 완료만 현재 화면에 반영한다

resolve면 새 결과로 교체합니다. reject면 기존 결과를 유지하고 비차단 오류와 retry를 보여줍니다.

retry는 별도 화면 상태가 아니라 error에서 새 loading 요청을 시작하는 이벤트입니다. 응답 정체성 key가 바뀌면 이전 엔터티를 유지하지 않고 loading으로 돌아가며, empty는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명첫 요청의 화면 상태와 기존 데이터를 갱신하는 진행 상태는 같은 축이 아닙니다. 주 상태는 한 번에 하나를 선택하고, isRefreshing은 data 또는 empty 화면을 유지한 채 뒤에서 새 요청이 진행 중임을 덧붙입니다.
원도표 조건·주석idle: URL이나 시작 조건이 아직 없음
원도표 조건·주석loading: 표시할 기존 데이터가 없는 첫 요청
원도표 조건·주석success:data: 성공했고 표시할 항목이 있음
원도표 조건·주석success:empty: 성공했지만 결과가 비어 있음
원도표 조건·주석error: 첫 요청이 실패해 복구 행동이 필요함
원도표 설명isRefreshing은 기존 UI를 보존
원도표 설명data 또는 empty 상태에서 같은 resource key를 refetch하면 기존 콘텐츠를 지우지 않고 진행 표시만 추가합니다. key에는 인증 주체·locale·query처럼 응답 정체성을 바꾸는 입력을 모두 포함합니다. 새 결과가 오면 교체하고, 갱신 실패는 기존 결과와 함께 비차단 오류·재시도 동선으로 보여 줍니다.
원도표 설명AbortController는 불필요한 요청을 중단하고, active/latest request ID gate는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 합니다. finally도 같은 gate를 통과해야 합니다.
원도표 표 행현재 상태 | 이벤트 | 다음 상태 | 사용자에게 보이는 화면
원도표 표 행idle | 유효한 URL로 시작 | loading | 스켈레톤·진행 안내, 중복 실행 제한
원도표 표 행loading | 항목과 함께 resolve | success:data | 데이터와 다음 행동
원도표 표 행loading | 빈 결과로 resolve | success:empty | 오류가 아닌 빈 상태 안내
원도표 표 행loading | reject | error | 원인 요약과 retry 행동
원도표 표 행error | retry | loading | 이전 오류를 정리하고 다시 요청
원도표 표 행success | 같은 key를 background refetch | success + 갱신 중 | 기존 data·empty UI를 그대로 유지
원도표 표 행success + 갱신 중 | 최신 요청 resolve | success | 새 결과로 교체하고 갱신 표시 종료
원도표 표 행success + 갱신 중 | 최신 요청 reject | success + 갱신 오류 | 기존 결과를 유지하고 비차단 retry 제공
원도표 설명표시할 결과가 없으므로 스켈레톤과 진행 안내를 보여 주고 중복 실행을 제한합니다.
원도표 설명항목이 있으면 데이터와 다음 행동을, 값이 비었으면 오류가 아닌 빈 상태 안내를 보여 줍니다.
원도표 설명resolve면 새 결과로 교체합니다. reject면 기존 결과를 유지하고 비차단 오류와 retry를 보여 줍니다.
원도표 설명retry는 별도 화면 상태가 아니라 error에서 새 loading 요청을 시작하는 이벤트입니다. 응답 정체성 key가 바뀌면 이전 엔터티를 유지하지 않고 loading으로 돌아가며, empty는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
첫 결과 없음status: 배타적인 주 상태 / 기존 data: 없음첫 요청은 loading 뒤 data·empty·error로 끝난다표시할 자료가 없는 대기를 분리한다
같은 key 갱신기존 결과: 유지 / refreshing: truesuccess 화면을 유지하며 isRefreshing을 켠다주 상태 success와 진행 축은 함께 있을 수 있다
갱신 완료성공: 새 data/empty / 실패: 기존 결과+retry성공은 새 결과, 실패는 기존 결과와 비차단 오류로 남는다다른 resource key는 이전 엔터티를 유지하는 같은 갱신이 아니다

의존성 배열과 데이터 페칭 최적화

데이터 페칭 시 useEffect의 의존성 배열을 올바르게 사용하는 것은 매우 중요합니다.

  • 빈 배열 ([]): Effect가 읽는 반응형 값이 없을 때 사용합니다. 프로덕션 마운트에서는 한 번 setup되지만, 개발 Strict Mode는 cleanup 검증을 위해 추가 setup → cleanup → setup을 실행하므로 “정확히 한 번” 계약으로 사용하면 안 됩니다.
  • 변수 포함 ([id, category]): 특정 props나 state 값이 변경될 때마다 데이터를 다시 가져옵니다. 예를 들어, 사용자 ID나 검색 카테고리가 변경될 때 유용합니다.
주의사항
  • 함수나 객체 참조: Effect가 읽는 함수나 객체가 렌더마다 새로 만들어지면 Effect도 다시 실행될 수 있습니다. 불필요한 의존성을 먼저 Effect 안으로 옮기고, 참조 안정성이 실제로 필요할 때 useCallback이나 useMemo를 사용합니다.
    // 매 렌더마다 fetchData 참조가 바뀌므로 Effect도 다시 실행됨
    const fetchDataEveryRender = async () => { /* ... */ };
    useEffect(() => {
      fetchDataEveryRender();
    }, [fetchDataEveryRender]);
    
    // 일반적인 해결: 요청 함수를 Effect 안에 정의
    useEffect(() => {
      const fetchData = async () => { /* ... */ };
      fetchData();
    }, []);
    
    // 외부에서 같은 함수 참조가 필요할 때만 안정화
    const stableFetchData = useCallback(async () => { /* ... */ }, []);
    useEffect(() => {
      stableFetchData();
    }, [stableFetchData]);
    데이터 페칭 함수의 경우, 대부분 useEffect 내부에 정의하는 것이 일반적이고 간결합니다.

예시

DataFetcherWithLoading에서 요청 중에는 loading=true가 첫 분기를 선택합니다. 404 응답이면 response.ok 검사에서 throw하고, catch가 error를 저장한 뒤 현재 요청의 finally가 로딩을 끕니다. 다음 렌더는 오류 화면입니다. 200 응답의 json()이 null을 돌려주면 원 예제에는 null 분기가 없어 마지막의 data.title에서 렌더 오류가 납니다. 기본 예제는 { title, body } 형태의 게시물 응답을 전제로 합니다. 일반 API에 적용할 때는 저장 전에 모양을 확인하거나, 성공했지만 값이 없는 화면 분기를 더해야 합니다.

재시도 버튼의 window.location.reload()는 요청 하나만 다시 하는 동작이 아닙니다. 페이지와 컴포넌트 state를 다시 시작합니다. 입력을 보존하며 조회만 재시도하려면 뒤의 retry 이벤트처럼 요청 상태를 다시 시작합니다.

데이터 페칭 로직의 재사용: 커스텀 훅

여러 컴포넌트에서 비슷한 데이터 페칭 로직(로딩, 에러 처리, 데이터 상태 관리)이 반복된다면, 이를 커스텀 훅(Custom Hook)으로 분리하여 재사용성과 가독성을 높일 수 있습니다.

useFetch 커스텀 훅 예시
src/hooks/useFetch.js
import { useEffect, useRef, useState } from 'react';

const IDLE_STATE = {
  key: null,
  status: 'idle',
  data: null,
  isRefreshing: false,
  error: null,
};

const useFetch = ({ requestKey, url, options }) => {
  const [request, setRequest] = useState(IDLE_STATE);
  const [retryKey, setRetryKey] = useState(0);
  const cachedResult = useRef({
    key: null,
    hasResolved: false,
    value: null,
  });
  const latestRequestId = useRef(0);

  useEffect(() => {
    if (!requestKey || !url) {
      cachedResult.current = {
        key: null,
        hasResolved: false,
        value: null,
      };
      setRequest(IDLE_STATE);
      return;
    }

    const controller = new AbortController();
    const requestId = ++latestRequestId.current;
    let active = true;
    const canCommit = () =>
      active && latestRequestId.current === requestId;

    const previous = cachedResult.current;
    const hasPreviousResult =
      previous.key === requestKey && previous.hasResolved;
    setRequest({
      key: requestKey,
      status: hasPreviousResult ? 'success' : 'loading',
      data: hasPreviousResult ? previous.value : null,
      isRefreshing: hasPreviousResult,
      error: null,
    });

    const fetchData = async () => {
      try {
        const response = await fetch(url, {
          ...options,
          signal: controller.signal,
        });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();

        if (!canCommit()) return;
        cachedResult.current = {
          key: requestKey,
          hasResolved: true,
          value: json,
        };
        setRequest((current) =>
          current.key === requestKey
            ? { ...current, status: 'success', data: json, error: null }
            : current
        );
      } catch (err) {
        if (err?.name === 'AbortError' || !canCommit()) return;
        setRequest((current) =>
          current.key === requestKey
            ? {
                ...current,
                status: current.status === 'success' ? 'success' : 'error',
                error: err,
              }
            : current
        );
      } finally {
        // 이전 요청의 finally가 새 요청의 진행 표시를 끄지 못하게 합니다.
        if (canCommit()) {
          setRequest((current) =>
            current.key === requestKey
              ? { ...current, isRefreshing: false }
              : current
          );
        }
      }
    };

    fetchData();

    return () => {
      active = false;
      controller.abort();
    };
  }, [requestKey, url, options, retryKey]);

  const retry = () => setRetryKey((key) => key + 1);
  const visibleRequest =
    request.key === requestKey
      ? request
      : {
          ...IDLE_STATE,
          key: requestKey,
          status: requestKey && url ? 'loading' : 'idle',
        };

  return {
    data: visibleRequest.data,
    status: visibleRequest.status,
    isRefreshing: visibleRequest.isRefreshing,
    error: visibleRequest.error,
    retry,
  };
};

export default useFetch;
useFetch 커스텀 훅 사용 예시
src/components/PostDetailWithHook.js
import React from 'react';
import { useParams } from 'react-router-dom';
import useFetch from '../hooks/useFetch'; // 커스텀 훅 임포트

function PostDetailWithHook() {
  const { postId } = useParams();
  const url = `http://localhost:4000/posts/${postId}`;
  const requestKey = `post:${postId}`;
  const { data: post, status, isRefreshing, error, retry } = useFetch({
    requestKey,
    url,
  });

  if (status === 'loading') {
    return <div style={{ textAlign: 'center', padding: '20px' }}>게시글을 불러오는 중...</div>;
  }

  if (status === 'error') {
    return (
      <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>
        오류 발생: {error.message}
        <button onClick={retry}>다시 시도</button>
      </div>
    );
  }

  if (status === 'success' && !post) {
    return (
      <div style={{ textAlign: 'center', padding: '20px' }}>
        <p>게시글을 찾을 수 없습니다.</p>
        {isRefreshing && <p>최신 내용으로 갱신 중...</p>}
        {error && <button onClick={retry}>갱신 실패 · 다시 시도</button>}
      </div>
    );
  }

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)', backgroundColor: '#fdfdfd' }}>
      {isRefreshing && <p>최신 내용으로 갱신 중...</p>}
      {error && <button onClick={retry}>갱신 실패 · 다시 시도</button>}
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>{post.title}</h2>
      <p>{post.body}</p>
    </div>
  );
}

export default PostDetailWithHook;

이 구현에서 AbortController는 중단 가능한 네트워크 작업을 취소하고, active와 최신 request ID는 늦게 끝난 이전 작업의 상태 쓰기를 차단합니다. 같은 requestKey의 성공 결과가 있으면 값이 null인 empty 결과도 구분해 보존하고 isRefreshing만 켭니다. key가 달라지면 이전 엔터티를 노출하지 않고 새 loading 상태를 사용합니다.

requestKey는 URL뿐 아니라 인증 주체, locale, query, 응답을 바꾸는 header나 body처럼 응답의 정체성을 바꾸는 모든 입력을 포함해야 합니다. 이 예제는 공개 게시글 조회라서 post:${postId}면 충분하지만 사용자별 응답이라면 사용자 식별자도 key에 넣습니다. cache, 화면 state, commit guard가 모두 같은 key를 써야 이전 사용자의 데이터가 잠깐 노출되는 일을 막을 수 있습니다.

options는 Effect의 반응형 의존성입니다. 호출부에서 인라인 객체를 렌더링할 때마다 만들면 참조가 계속 바뀌어 요청이 반복되므로, 바뀌어야 하는 원시 값으로 options를 구성하거나 실제로 필요할 때 useMemo로 참조를 안정화합니다. 의존성 배열에서 options를 임의로 빼면 오래된 옵션을 읽게 됩니다.


같은 key와 새 key의 화면 추적

useFetch의 cachedResult는 모든 게시글을 보관하는 캐시가 아니라 가장 최근 한 key의 성공 결과 보관함입니다. 같은 key의 재시도는 그 값을 유지하지만 다른 key로 바꾸면 이 예제는 다시 요청합니다. hasResolved는 성공한 값이 null인 경우도 기억하기 위해 필요합니다.

조건주 상태와 데이터추가 표시
post:1 첫 요청, 이전 결과 없음loading, null초기 로딩
같은 key의 성공 값 { title: 'A' } 뒤 retrysuccess, 기존 AisRefreshing=true
그 갱신 요청이 실패success, 기존 A갱신 오류·retry, refreshing 종료
같은 key의 성공 값 null 뒤 retrysuccess, null빈 결과 화면에서 갱신 중
key를 post:2로 변경loading, null이전 A를 보여 주지 않음

PostDetailWithHook의 useParams에는 /posts/:postId 라우트가 필요합니다. App.tsx (일부)의 Error Boundary 예제는 경계 배치만 발췌한 것으로, Router와 해당 Route의 element 안에 배치하는 나머지 구성이 필요합니다. 라우트가 없는 문맥에서 post:undefined와 /posts/undefined를 실제 게시글 요청으로 사용하는 것은 이 실습의 조건이 아닙니다.

혼자 풀기

현재 key가 post:2이고, 이전 post:1 setup의 cleanup은 끝났다고 가정합니다. 1번 응답이 늦게 성공하고, 현재 2번 요청은 HTTP 200·JSON null로 성공합니다. 화면에는 어떤 게시글과 안내가 나와야 하나요? 이어서 같은 post:2를 retry했는데 네트워크 실패가 나면 무엇을 남겨야 하나요? 이 가상의 null 응답을 실제 서버의 HTTP 404와 구별하세요.

값이 없는 성공과 요청 실패 구별

1번 응답은 canCommit()을 통과하지 못해 저장하지 않습니다. 2번 null은 성공 결과이므로 status='success', data=null이며 게시글 없음 안내를 고릅니다. 같은 key의 retry 중에는 이 안내와 갱신 중 표시를 유지합니다. 갱신 실패 뒤에도 success·null을 남기고 갱신 오류·retry를 함께 표시합니다. 최초 요청의 HTTP 404라면 response.ok에서 throw하여 error 경로로 갑니다. 이 코드가 404를 자동으로 success·null로 바꾸지는 않습니다.

JavaScript 프로젝트에서 경계 예제 사용하기

아래 원 TSX 예제는 TypeScript 문법을 이해하는 독자를 위한 별도 형식입니다. Vite의 React JavaScript 템플릿에서 TypeScript 설정을 추가하지 않았다면 다음 .jsx 버전을 선택합니다. Props, State, React.ErrorInfo는 타입 표기이지 실행할 JavaScript 값이 아닙니다.

src/components/AppErrorBoundary.jsx · JavaScript 변형
import React from 'react';

export default class AppErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    console.error('UI render error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback ?? <p>문제가 발생했습니다.</p>;
    }
    return this.props.children;
  }
}

예를 들어 기존 Router의 <Route path="/posts/:postId" ... />가 갖는 element를 <AppErrorBoundary><PostDetailWithHook /></AppErrorBoundary>로 둡니다. 경계를 추가한다는 이유로 이미 있는 Router를 다시 만들지 않습니다. 일반 요청 rejection은 요청의 catch가 처리하고, 이후 렌더에서 실제 throw가 발생했을 때 경계가 하위 화면을 대체합니다. useTransition이 반환한 startTransition 안의 throw는 현행 React 문서가 설명하는 별도 예외이며, 일반 타이머·Promise 콜백과 같은 범위로 일반화하지 않습니다.

Error Boundary로 실패 범위 격리하기

Error Boundary가 격리하는 오류와 경계 밖 오류 — 상태 추적

하위 렌더 throw 뒤 fallback 상태와 원인 수정/reset 또는 remount가 다른 단계다. 같은 경계/하위 조건/표시 상태를 추적해 reset만으로 동일 실패가 복구된다는 오해를 줄인다.

Error Boundary가 하위 컴포넌트의 렌더·생성자·생명주기 오류를 fallback으로 격리하는 범위와 이벤트·일반 비동기 콜백·서버 렌더·경계 자체 오류를 처리할 별도 경로

Catch radius · recovery contract

Error Boundary의 포착 범위와 복구 순서

Error Boundary는 자신이 감싼 하위 React 트리의 렌더 실패를 fallback UI로 격리합니다. 모든 JavaScript 오류를 잡는 전역 예외 처리기가 아니므로, 경계 안과 밖을 먼저 구분해야 복구 동선도 정확해집니다.

Error Boundary가 감싼 하위 렌더 실패

Error Boundary가 감싼 하위 렌더 실패이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.하위 렌더 범위포착재시도하위 React 트리render throw가장 가까운Error Boundaryfallback실패한하위만 격리이벤트일반 timer직접 처리먼저 원인 교정입력 / 캐시 / 요청reset 또는 remountremount는 state 초기화
Error Boundary가 감싼 하위 렌더 실패이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.하위 렌더 범위포착재시도하위 React 트리render throw가장 가까운Error Boundaryfallback실패한하위만격리이벤트일반timer직접처리먼저 원인 교정입력 / 캐시 /요청reset 또는remountremount는 state초기화

이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.

원래 설명과 상세 기준
경계가 포착

하위 컴포넌트 트리

  • 렌더 중 오류: 컴포넌트가 UI를 계산하다 throw
  • 생성자 오류: 하위 class 컴포넌트의 constructor
  • 생명주기 오류: 하위 class 컴포넌트의 lifecycle

getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 오류와 component stack을 기록합니다.

별도 처리

경계 밖의 실행 문맥

  • 이벤트 핸들러: 해당 동작의 try...catch
  • 일반 비동기 콜백: Promise, setTimeout, requestAnimationFrame의 오류 경로
  • 서버 렌더링: 서버·프레임워크의 오류 처리 경로
  • 경계 자체의 오류: 더 바깥의 상위 Error Boundary

useTransition이 반환한 startTransition 콜백 안에서 throw된 오류는 일반 비동기 콜백과 달리 Error Boundary로 전달될 수 있습니다.

  1. 실패한 하위 트리만 fallback으로 교체

    페이지·위젯처럼 사용자가 독립적으로 이해할 수 있는 단위에 경계를 두고, 오류 보고에는 component stack을 함께 남깁니다.

  2. 먼저 원인을 복구

    잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.

  3. 복구 뒤 reset 또는 의도적인 remount

    경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.

데이터 요청의 reject는 보통 요청 코드나 데이터 라이브러리에서 처리합니다. 그 결과 때문에 이후 렌더가 throw될 때 비로소 가장 가까운 Error Boundary의 격리 범위가 적용됩니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석렌더 중 오류: 컴포넌트가 UI를 계산하다 throw
원도표 조건·주석생성자 오류: 하위 class 컴포넌트의 constructor
원도표 조건·주석생명주기 오류: 하위 class 컴포넌트의 lifecycle
원도표 설명getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 오류와 component stack을 기록합니다.
원도표 조건·주석이벤트 핸들러: 해당 동작의 try...catch
원도표 조건·주석일반 비동기 콜백: Promise, setTimeout, requestAnimationFrame의 오류 경로
원도표 조건·주석서버 렌더링: 서버·프레임워크의 오류 처리 경로
원도표 조건·주석경계 자체의 오류: 더 바깥의 상위 Error Boundary
원도표 설명useTransition이 반환한 startTransition 콜백 안에서 throw된 오류는 일반 비동기 콜백과 달리 Error Boundary로 전달될 수 있습니다.
원도표 설명잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.
원도표 조건·주석먼저 원인을 복구 잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.
원도표 설명경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.
원도표 조건·주석복구 뒤 reset 또는 의도적인 remount 경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
하위 render 실패경계: hasError로 전이 / 하위: 실패경계 안의 하위 컴포넌트가 render 중 throw한다이벤트와 일반 timer의 오류는 별도 범위다
fallback 격리UI: fallback / 원인: 입력/캐시 등 교정실패한 하위 트리만 fallback으로 표시하고 원인을 복구한다상태 reset만으로 반복 throw의 조건을 고치지는 않는다
reset 또는 remountreset: 경계 상태 복원 / remount: 내부 state 초기화원인 복구 뒤 명시한 정책으로 하위 UI를 다시 시작한다보존할 state와 상위 경계의 오류 범위를 결정한다

Error Boundary가 격리하는 오류와 경계 밖 오류 — 상태 추적

하위 렌더 throw 뒤 fallback 상태와 원인 수정/reset 또는 remount가 다른 단계다. 같은 경계/하위 조건/표시 상태를 추적해 reset만으로 동일 실패가 복구된다는 오해를 줄인다.

Error Boundary가 하위 컴포넌트의 렌더·생성자·생명주기 오류를 fallback으로 격리하는 범위와 이벤트·일반 비동기 콜백·서버 렌더·경계 자체 오류를 처리할 별도 경로

Catch radius · recovery contract

Error Boundary의 포착 범위와 복구 순서

Error Boundary는 자신이 감싼 하위 React 트리의 렌더 실패를 fallback UI로 격리합니다. 모든 JavaScript 오류를 잡는 전역 예외 처리기가 아니므로, 경계 안과 밖을 먼저 구분해야 복구 동선도 정확해집니다.

Error Boundary가 감싼 하위 렌더 실패

Error Boundary가 감싼 하위 렌더 실패이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.하위 렌더 범위포착재시도하위 React 트리render throw가장 가까운Error Boundaryfallback실패한하위만 격리이벤트일반 timer직접 처리먼저 원인 교정입력 / 캐시 / 요청reset 또는 remountremount는 state 초기화
Error Boundary가 감싼 하위 렌더 실패이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.하위 렌더 범위포착재시도하위 React 트리render throw가장 가까운Error Boundaryfallback실패한하위만격리이벤트일반timer직접처리먼저 원인 교정입력 / 캐시 /요청reset 또는remountremount는 state초기화

이벤트 handler·일반 비동기 콜백은 이 포착 경로가 아닙니다. startTransition 콜백의 throw는 전달될 수 있습니다. 원인을 고치지 않은 hasError reset은 같은 실패를 반복할 수 있습니다.

원래 설명과 상세 기준
경계가 포착

하위 컴포넌트 트리

  • 렌더 중 오류: 컴포넌트가 UI를 계산하다 throw
  • 생성자 오류: 하위 class 컴포넌트의 constructor
  • 생명주기 오류: 하위 class 컴포넌트의 lifecycle

getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 오류와 component stack을 기록합니다.

별도 처리

경계 밖의 실행 문맥

  • 이벤트 핸들러: 해당 동작의 try...catch
  • 일반 비동기 콜백: Promise, setTimeout, requestAnimationFrame의 오류 경로
  • 서버 렌더링: 서버·프레임워크의 오류 처리 경로
  • 경계 자체의 오류: 더 바깥의 상위 Error Boundary

useTransition이 반환한 startTransition 콜백 안에서 throw된 오류는 일반 비동기 콜백과 달리 Error Boundary로 전달될 수 있습니다.

  1. 실패한 하위 트리만 fallback으로 교체

    페이지·위젯처럼 사용자가 독립적으로 이해할 수 있는 단위에 경계를 두고, 오류 보고에는 component stack을 함께 남깁니다.

  2. 먼저 원인을 복구

    잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.

  3. 복구 뒤 reset 또는 의도적인 remount

    경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.

데이터 요청의 reject는 보통 요청 코드나 데이터 라이브러리에서 처리합니다. 그 결과 때문에 이후 렌더가 throw될 때 비로소 가장 가까운 Error Boundary의 격리 범위가 적용됩니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 조건·주석렌더 중 오류: 컴포넌트가 UI를 계산하다 throw
원도표 조건·주석생성자 오류: 하위 class 컴포넌트의 constructor
원도표 조건·주석생명주기 오류: 하위 class 컴포넌트의 lifecycle
원도표 설명getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 오류와 component stack을 기록합니다.
원도표 조건·주석이벤트 핸들러: 해당 동작의 try...catch
원도표 조건·주석일반 비동기 콜백: Promise, setTimeout, requestAnimationFrame의 오류 경로
원도표 조건·주석서버 렌더링: 서버·프레임워크의 오류 처리 경로
원도표 조건·주석경계 자체의 오류: 더 바깥의 상위 Error Boundary
원도표 설명useTransition이 반환한 startTransition 콜백 안에서 throw된 오류는 일반 비동기 콜백과 달리 Error Boundary로 전달될 수 있습니다.
원도표 설명잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.
원도표 조건·주석먼저 원인을 복구 잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.
원도표 설명경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.
원도표 조건·주석복구 뒤 reset 또는 의도적인 remount 경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
하위 render 실패경계: hasError로 전이 / 하위: 실패경계 안의 하위 컴포넌트가 render 중 throw한다이벤트와 일반 timer의 오류는 별도 범위다
fallback 격리UI: fallback / 원인: 입력/캐시 등 교정실패한 하위 트리만 fallback으로 표시하고 원인을 복구한다상태 reset만으로 반복 throw의 조건을 고치지는 않는다
reset 또는 remountreset: 경계 상태 복원 / remount: 내부 state 초기화원인 복구 뒤 명시한 정책으로 하위 UI를 다시 시작한다보존할 state와 상위 경계의 오류 범위를 결정한다

데이터 페칭 에러는 try...catch로 처리할 수 있지만, 렌더링 중 예외가 발생하면 컴포넌트 트리 전체가 깨질 수 있습니다.

이때 Error Boundary를 두면 전체 페이지 다운 대신 문제 구역만 폴백 UI로 격리할 수 있습니다. 하위 트리의 렌더·생성자·생명주기 오류는 잡지만, 이벤트 핸들러, 일반 비동기 콜백, 서버 렌더링, Error Boundary 자체에서 발생한 오류는 잡지 않습니다.

src/components/AppErrorBoundary.tsx
import React from 'react';

type Props = {
  children: React.ReactNode;
  fallback?: React.ReactNode;
};

type State = {
  hasError: boolean;
};

export default class AppErrorBoundary extends React.Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('UI render error:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: 16, border: '1px solid #f2b8b5', borderRadius: 8 }}>
          {this.props.fallback ?? <p>문제가 발생했습니다.</p>}
        </div>
      );
    }
    return this.props.children;
  }
}
src/App.tsx (일부)
import AppErrorBoundary from './components/AppErrorBoundary';
import PostDetailWithHook from './components/PostDetailWithHook';

export default function App() {
  return (
    <AppErrorBoundary fallback={<p>게시글 화면을 불러오지 못했습니다.</p>}>
      <PostDetailWithHook />
    </AppErrorBoundary>
  );
}
배치 가이드
  • 페이지 단위 경계: 라우트별 주요 화면을 감싸 전체 앱 장애를 방지합니다.
  • 위젯 단위 경계: 외부 데이터 의존 위젯(차트, 에디터 등)을 개별 격리합니다.
  • 재시도 정책: hasError만 초기화하면 같은 원인으로 즉시 다시 실패할 수 있습니다. 잘못된 입력·데이터·캐시를 먼저 복구하고, 하위 state까지 새로 시작해야 할 때는 복구 버전을 key로 사용해 subtree를 의도적으로 remount합니다. remount는 내부 state도 지우므로 보존 정책을 함께 정합니다.

로딩 상태와 에러 처리는 여기까지입니다.

이 장에서는 비동기 데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 관리하고 사용자에게 피드백하는 일의 중요성에 대해 배웠습니다.

useState와 try...catch를 이용한 기본적인 구현 방법부터, useEffect의 의존성 배열 사용 시 주의사항, 그리고 커스텀 훅을 통한 로직 재사용까지 심화된 내용을 다루었습니다.

이제는 리액트 애플리케이션에서 견고하고 사용자 친화적인 데이터 페칭 로직을 구현할 수 있는 기초를 마련했습니다.

다음 절에서는 axios와 같은 인기 있는 HTTP 클라이언트 라이브러리를 사용하여 데이터 페칭을 더욱 편리하게 만드는 방법을 알아보겠습니다.

로딩과 에러 처리는 데이터 요청 주변의 부가 UI가 아니라, 사용자가 다음 행동을 판단할 수 있게 만드는 상태 설계입니다.