본문으로 건너뛰기

안동민 개발노트

본문 시작

로딩 상태와 에러 처리

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

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

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

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

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

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


로딩 상태 (Loading State) 관리

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

데이터가 로딩 중임을 명확히 표시하여 사용자가 기다리고 있음을 인지하게 해야 합니다.

구현 방법
  • useState 훅을 사용하여 loading 상태(true/false)를 관리합니다.
  • 데이터 요청 시작 시 loadingtrue로 설정합니다.
  • 데이터 요청 완료 시 (성공 또는 실패와 무관하게) loadingfalse로 설정합니다.
  • 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.okfalse이면 직접 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>
    );
  }

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

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

Primary status × background refresh

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

주 상태 · 배타적

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를 그대로 유지
refresh 최신 요청 resolve success 새 결과로 교체하고 갱신 표시 종료
refresh 최신 요청 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는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.


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

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

  • 빈 배열 ([]): Effect가 읽는 반응형 값이 없을 때 사용합니다. 프로덕션 마운트에서는 한 번 setup되지만, 개발 Strict Mode는 cleanup 검증을 위해 추가 setup → cleanup → setup을 실행하므로 “정확히 한 번” 계약으로 사용하면 안 됩니다.
  • 변수 포함 ([id, category]): 특정 propsstate 값이 변경될 때마다 데이터를 다시 가져옵니다. 예를 들어, 사용자 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 내부에 정의하는 것이 일반적이고 간결합니다.

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

여러 컴포넌트에서 비슷한 데이터 페칭 로직(로딩, 에러 처리, 데이터 상태 관리)이 반복된다면, 이를 커스텀 훅(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를 임의로 빼면 오래된 옵션을 읽게 됩니다.


Error Boundary로 실패 범위 격리하기

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

Catch radius · recovery contract

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

경계가 포착

하위 컴포넌트 트리

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

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

별도 처리

경계 밖의 실행 문맥

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

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

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

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

  2. 먼저 원인을 복구

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

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

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

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

데이터 페칭 에러는 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도 지우므로 보존 정책을 함께 정합니다.

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

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

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

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

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

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