본문 바로가기

로딩 상태와 에러 처리

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

이 글에서는 데이터를 가져오는 동안의 로딩 상태와 오류를 처리하고, 사용자에게 알맞게 알리는 방법을 더 깊이 다룹니다. 데이터 요청은 비동기 작업이라서 네트워크의 지연, 서버의 무응답, 데이터 형식의 오류처럼 여러 문제가 생길 수 있으며, 이런 상황을 사용자에게 분명히 전하는 것이 좋은 사용자 경험(UX)의 기본입니다. 이 글의 요청 URL도 앞 글처럼 로컬 목 API인 json-server(http://localhost:4000)를 기준으로 합니다.


로딩 상태 (Loading State) 관리

사용자가 데이터를 기다리는 동안 앱이 멈춘 것처럼 보이면 사용자 경험이 나빠지므로, 데이터를 불러오는 중이라는 사실을 분명히 보여 줘야 합니다. 이를 위해 useState로 loading 상태(true/false)를 관리하고, 요청을 시작할 때 true로, 요청이 끝나면 성공이든 실패든 false로 바꿉니다. loading이 true인 동안에는 스피너나 스켈레톤 UI, “데이터를 불러오는 중입니다...” 같은 메시지를 렌더링합니다.

src/components/DataFetcherWithLoading.jsx
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.jsx에 이 컴포넌트를 넣어 시험해 볼 수 있습니다. 요청하는 동안에는 loading이 true라서 첫 분기의 로딩 화면이 보이고, 응답을 받으면 /posts/1의 제목과 본문이 나타납니다.


에러 처리 (Error Handling)

네트워크 요청이 언제나 성공하지는 않습니다. 네트워크에 연결되어 있지 않거나, 서버가 응답하지 않거나, 4xx(클라이언트 오류)나 5xx(서버 오류) 상태 코드가 오거나, 응답 데이터의 파싱에 실패하거나, API 키가 만료되는 것 같은 백엔드 로직의 오류가 생길 수 있습니다.

오류를 처리하려면 useState로 error 상태(null이나 Error 객체)를 관리하고, try...catch로 비동기 함수 안에서 생길 수 있는 오류를 잡습니다. fetch를 쓴다면 response.ok(상태 코드가 200부터 299까지인지)를 확인해 서버의 응답이 성공했는지 봐야 하며, false이면 직접 Error를 던져 catch에서 처리하게 합니다. catch에서는 error 상태를 갱신하고 사용자가 이해할 수 있는 오류 메시지를 보여 주며, 필요하면 다시 시도하는 버튼을 제공하거나 로깅 시스템에 오류를 기록합니다. 앞의 DataFetcherWithLoading에 들어 있는 오류 처리 부분은 다음과 같습니다.

    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()를 호출합니다.

catch는 두 경우를 걸러 냅니다. 정리 함수가 controller.abort()로 일부러 중단한 요청의 AbortError와, 이미 정리된 setup에서 늦게 도착한 오류는 화면의 error로 남기지 않습니다. 오류 메시지는 이렇게 보여 줍니다. error가 있으면 데이터를 그리는 return에 닿기 전에 이 화면을 돌려주며, 사용자용 문장과 함께 error.message를 보여 줍니다.

  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>
    );
  }

DataFetcherWithLoading의 다시 시도 버튼이 쓰는 window.location.reload()는 요청 하나만 다시 하는 동작이 아니라, 페이지와 컴포넌트의 state를 모두 다시 시작합니다. 입력을 지킨 채 조회만 다시 하려면, 뒤의 retry 이벤트처럼 요청의 상태를 다시 시작합니다. 로딩과 오류는 따로따로 띄우는 메시지가 아니라, 요청의 상태와 데이터의 유무에 따라 고르는 렌더 분기표로 관리하면 더 안정적입니다.

요청 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는 이전 엔터티를 유지하는 같은 갱신이 아니다

위 그림은 첫 요청의 화면 상태와, 기존 데이터를 갱신하는 진행 상태가 서로 다른 축이라는 점을 보여 줍니다. 위쪽의 첫 요청에서는 결과가 없는 idle에서 시작해 첫 결과를 기다리는 loading이 되고, 최신 응답이 이행되면 항목이 있는 success:data나 빈 값인 success:empty가, 거부되면 화면을 막는 error가 되며, error에서 다시 시도하면 loading으로 돌아갑니다. 빈 결과(empty)는 실패가 아니라 성공한 결과입니다. 아래쪽의 같은 key 갱신에서는 기존의 success 화면을 그대로 둔 채 같은 key를 다시 요청해 갱신 중 표시만 더하며, 성공하면 새 결과로 바꾸고 실패하면 기존 결과를 유지한 채 화면을 막지 않는 오류와 다시 시도를 보여 줍니다. 응답을 구별하는 key가 바뀌면 이전 대상을 유지하지 않고 첫 요청의 loading으로 돌아갑니다.

그림 아래의 표는 이벤트마다 다음 상태와 보이는 화면을 정리합니다. 올바른 URL로 시작하면 스켈레톤과 진행 안내를 보여 주며 중복 실행을 막고, 항목과 함께 이행되면 데이터와 다음 행동을, 빈 결과로 이행되면 오류가 아닌 빈 상태의 안내를 보여 줍니다. 거부되면 원인의 요약과 다시 시도를 보여 주고, 다시 시도는 이전 오류를 비우고 새로 요청하는 이벤트일 뿐 별도의 화면 상태가 아닙니다. 그림의 세부 설명이 덧붙이듯 key에는 인증 주체나 locale, 검색 조건처럼 응답의 정체를 바꾸는 입력을 모두 넣어야 하며, AbortController는 불필요한 요청을 멈추고 활성 상태와 최신 요청 ID는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 막고, finally도 같은 확인을 거쳐야 합니다.


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

데이터를 가져올 때 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 안에 정의하는 것이 일반적이고 간결합니다.


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

여러 컴포넌트에서 로딩과 오류 처리, 데이터의 상태 관리 같은 비슷한 로직이 되풀이된다면, 이를 커스텀 훅으로 떼어 내 재사용성과 가독성을 높일 수 있습니다. 다음은 useFetch 커스텀 훅입니다. 이 파일에는 JSX가 없으므로 .js로 저장합니다.

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;

이 훅을 쓰는 컴포넌트는 다음과 같습니다. 컴포넌트는 URL의 postId로 요청 주소와 requestKey를 만들어 훅에 넘기고, 돌려받은 status에 따라 화면을 고릅니다. requestKey는 훅이 지금 보여 줄 결과가 어느 요청의 것인지 구별하는 데 쓰므로, 게시글이 바뀌면 키도 함께 바뀌어야 합니다.

src/components/PostDetailWithHook.jsx
import React from 'react';
import { useParams } from 'react-router';
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와 최신 요청 ID는 늦게 끝난 이전 작업이 상태를 쓰지 못하게 막습니다. 같은 requestKey의 성공 결과가 있다면, 값이 null인 빈 결과도 구별해 그대로 두고 isRefreshing만 켭니다. key가 달라지면 이전 대상을 보여 주지 않고 새 loading 상태를 씁니다.

requestKey에는 URL뿐 아니라 인증 주체와 locale, 검색 조건, 응답을 바꾸는 헤더나 본문처럼 응답의 정체를 바꾸는 모든 입력이 들어가야 합니다. 이 예제는 공개 게시글의 조회라서 post:${postId}로 충분하지만, 사용자마다 다른 응답이라면 사용자의 식별자도 key에 넣습니다. 캐시와 화면의 state, 쓰기 전의 확인이 모두 같은 key를 써야, 이전 사용자의 데이터가 잠깐 보이는 일을 막을 수 있습니다.

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

같은 key와 새 key의 화면 따라가기

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

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

PostDetailWithHook의 useParams에는 /posts/:postId 라우트가 필요하며, 이 예제도 6장과 같은 react-router 패키지를 씁니다. 라우트가 없는 곳에서 post:undefined와 /posts/undefined를 실제 게시글 요청처럼 쓰는 것은 이 실습의 조건이 아닙니다.


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와 상위 경계의 오류 범위를 결정한다

위 그림은 에러 바운더리(Error Boundary)가 잡는 오류와 그 바깥의 오류, 그리고 회복하는 순서를 보여 줍니다. 하위 React 트리에서 렌더링 중에 오류가 던져지면 가장 가까운 에러 바운더리가 그것을 잡아 실패한 하위 부분만 fallback으로 바꿉니다. 이벤트 처리 함수나 일반 타이머 같은 오류는 이 경로로 오지 않으므로 직접 처리해야 합니다. 다시 시도할 때는 먼저 입력이나 캐시, 요청처럼 오류를 일으킨 원인을 고친 뒤 reset하거나 다시 마운트하며, 다시 마운트하면 state가 초기화됩니다.

그림 아래의 칸들은 그 범위를 자세히 나눕니다. 에러 바운더리는 하위 컴포넌트 트리의 렌더링 중 오류와, 하위 클래스 컴포넌트의 생성자와 생명주기 메서드의 오류를 잡으며, getDerivedStateFromError로 fallback 상태를 만들고 componentDidCatch에서 오류와 컴포넌트 스택을 기록합니다. 반면 이벤트 처리 함수는 그 동작의 try...catch로, Promise와 setTimeout, requestAnimationFrame 같은 일반 비동기 콜백은 각자의 오류 경로로, 서버 렌더링은 서버와 프레임워크의 오류 처리로, 에러 바운더리 자체의 오류는 더 바깥의 에러 바운더리가 다룹니다. 다만 useTransition이 돌려준 startTransition의 콜백 안에서 던진 오류는 일반 비동기 콜백과 달리 에러 바운더리로 전달될 수 있습니다(React 문서의 에러 바운더리로 렌더링 오류 잡기).

그림의 회복 칸이 강조하듯 hasError만 false로 바꾸면 같은 하위 트리가 곧바로 다시 실패할 수 있으므로, 원인을 먼저 고친 뒤 상태를 reset하거나 key를 바꿔 다시 마운트합니다. 그림의 마지막 설명처럼 데이터 요청의 거부는 보통 요청 코드나 데이터 라이브러리에서 처리하며, 그 결과 때문에 이후의 렌더링이 오류를 던질 때에야 가장 가까운 에러 바운더리가 적용됩니다.

데이터 요청의 오류는 try...catch로 처리할 수 있지만, 렌더링 중에 예외가 생기면 컴포넌트 트리 전체가 깨질 수 있습니다. 이때 에러 바운더리를 두면 페이지 전체가 멈추는 대신 문제가 생긴 부분만 fallback UI로 바꿀 수 있습니다. 에러 바운더리는 지금도 클래스 컴포넌트로만 만들 수 있습니다. 다음은 TypeScript로 작성한 예이며, React.Component<Props, State>처럼 클래스 컴포넌트에 props와 state의 타입을 붙이는 방법은 타입스크립트 교재의 React 컴포넌트와 타입스크립트 통합에서 다룹니다.

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>
  );
}

TypeScript 설정을 더하지 않은 Vite의 React 자바스크립트 템플릿이라면 다음 .jsx 버전을 씁니다. Props, State, React.ErrorInfo는 타입 표기일 뿐 실행되는 자바스크립트 값이 아닙니다.

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;
  }
}

예를 들어 기존 라우터의 <Route path="/posts/:postId" ... />의 element를 <AppErrorBoundary><PostDetailWithHook /></AppErrorBoundary>로 둡니다. 에러 바운더리를 더한다고 이미 있는 라우터를 다시 만들지 않습니다. 앞의 App.tsx는 감싸는 위치만 보여 주려고 뽑은 일부이므로, 실제로는 라우터와 그 라우트의 element 안에 놓아야 합니다. 일반적인 요청의 거부는 요청의 catch가 처리하고, 이후의 렌더링에서 실제로 오류가 던져졌을 때 에러 바운더리가 하위 화면을 바꿉니다.

에러 바운더리를 어디에 둘지도 정합니다. 라우트마다 주요 화면을 감싸 앱 전체가 멈추지 않게 하는 페이지 단위와, 차트나 에디터처럼 외부 데이터에 기대는 위젯을 하나씩 감싸는 위젯 단위가 있습니다. 다시 시도할 때 hasError만 초기화하면 같은 원인으로 곧바로 다시 실패할 수 있으므로, 잘못된 입력이나 데이터, 캐시를 먼저 고치고, 하위의 state까지 새로 시작해야 할 때는 회복 버전을 key로 써서 하위 트리를 의도적으로 다시 마운트합니다. 다시 마운트하면 내부의 state도 지워지므로 무엇을 남길지 함께 정합니다.

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

더 깊이: 잡힌 오류와 잡히지 않은 오류가 보고되는 곳

React 19는 렌더 중의 오류를 보고하는 방식을 바꿨습니다. 이전 버전은 렌더 중에 던져진 오류를 잡았다가 다시 던졌고, 개발 모드에서는 console.error로도 남겨 같은 오류가 두 번 기록되곤 했습니다. React 19에서는 다시 던지지 않는 대신, 에러 바운더리가 잡은 오류는 console.error로, 어느 에러 바운더리도 잡지 못한 오류는 window.reportError로 보고합니다(React 19 업그레이드 안내). 그래서 오류 수집 도구를 window의 error 이벤트에만 연결했다면 잡히지 않은 오류만 받게 되므로, 잡힌 오류까지 모으려면 createRoot(container, { onCaughtError, onUncaughtError })처럼 루트의 옵션으로 두 경로를 함께 연결합니다.

에러 바운더리가 잡지 않는 범위에도 알아 둘 예외가 있습니다. React 문서의 Component는 이벤트 처리 함수와 서버 렌더링, setTimeout 같은 비동기 코드와 함께, 에러 바운더리 자신이 던진 오류도 잡지 않는다고 적습니다. 그래서 fallback을 그리는 코드에서 오류가 나면 그보다 바깥의 에러 바운더리가 필요합니다. 반대로 비동기 코드 가운데서도 useTransition이 돌려준 startTransition에 넘긴 함수에서 던진 오류는 예외적으로 에러 바운더리가 잡습니다.


정리

  • 요청을 시작할 때 loading을 켜고 끝나면 끄되, 현재 요청일 때만 끄도록 finally도 확인을 거칩니다.
  • fetch는 404나 500에서 거부되지 않으므로 response.ok를 확인해 직접 오류를 던지고, AbortError는 사용자에게 보여 줄 오류에서 뺍니다.
  • 다시 시도는 페이지 전체를 새로고침하기보다 요청의 상태만 다시 시작하는 이벤트로 만듭니다.
  • 첫 요청의 화면 상태와 같은 key의 갱신 상태를 나누고, 빈 결과는 실패가 아닌 성공으로 다룹니다.
  • 커스텀 훅의 requestKey에는 응답의 정체를 바꾸는 모든 입력을 넣고, 인라인 options 객체로 요청이 되풀이되지 않게 합니다.
  • 에러 바운더리는 하위 트리의 렌더링 오류만 잡으며, 이벤트 처리 함수와 일반 비동기 콜백의 오류는 따로 처리합니다.

연습 문제

1. 값이 없는 성공과 요청 실패

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

해설 보기

1번 응답은 canCommit()을 통과하지 못해 저장되지 않습니다. 2번의 null은 성공한 결과이므로 status가 'success', data가 null이 되어 “게시글을 찾을 수 없습니다.” 안내를 고릅니다. 같은 key로 다시 시도하는 동안에는 이 안내에 갱신 중 표시를 더하고, 갱신이 실패한 뒤에도 success와 null을 남긴 채 갱신 실패와 다시 시도 버튼을 함께 보여 줍니다.

처음 요청에서 HTTP 404가 왔다면 response.ok 검사에서 오류를 던져 error 경로로 갑니다. 이 코드가 404를 저절로 success와 null로 바꾸지는 않습니다.

2. 404와 null 응답

DataFetcherWithLoading에서 서버가 404를 돌려주는 경우와, 200 응답의 json()이 null을 돌려주는 경우는 각각 어떤 화면이 될까요?

해설 보기

404이면 response.ok 검사에서 오류를 던지고, catch가 error를 저장한 뒤 현재 요청의 finally가 로딩을 끕니다. 그래서 다음 렌더는 “HTTP error! status: 404”를 담은 오류 화면입니다.

200 응답이 null이면 원래 예제에는 null을 위한 분기가 없으므로, 마지막의 data.title을 읽다가 렌더링 중에 TypeError가 납니다. 이 예제는 { title, body } 모양의 게시글 응답을 전제로 하므로, 일반적인 API에 쓸 때는 저장하기 전에 모양을 확인하거나 성공했지만 값이 없는 화면의 분기를 더해야 합니다. 이 컴포넌트를 에러 바운더리로 감쌌다면 이 렌더링 오류는 fallback 화면으로 바뀝니다.

3. 인라인 options 객체

PostDetailWithHook에서 useFetch({ requestKey, url, options: { headers: { Accept: 'application/json' } } })처럼 렌더마다 새 options 객체를 넘기면 어떤 일이 생길까요?

해설 보기

options가 Effect의 의존성이라서, 렌더마다 새로 만든 객체는 Object.is 비교에서 언제나 달라집니다. 그래서 상태를 바꿀 때마다 다시 렌더링되고, 그때마다 이전 요청을 정리하고 새 요청을 시작하는 일이 되풀이되어 요청이 끝나지 않을 수 있습니다.

바뀌지 않는 옵션이라면 컴포넌트 바깥의 상수로 두거나 useMemo로 참조를 고정하고, 바뀌어야 하는 값은 원시 값으로 의존성에 둡니다.

다음 글 axios 라이브러리 소개에서는 axios 같은 HTTP 클라이언트 라이브러리로 데이터 요청을 더 편하게 만드는 방법을 알아봅니다.