icon
8장 : 비동기 처리 및 데이터 페칭

로딩 상태와 에러 처리


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

데이터 페칭은 비동기 작업이므로, 네트워크 지연, 서버 응답 없음, 데이터 형식 오류 등 다양한 문제가 발생할 수 있습니다. 이러한 상황들을 사용자에게 명확하게 전달하는 것은 좋은 사용자 경험(UX)을 제공하는 데 매우 중요합니다.


로딩 상태 (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 fetchData = async () => {
      try {
        setLoading(true); // 💡 요청 시작 시 로딩 상태 true
        setError(null); // 이전 에러 초기화

        const response = await fetch('https://jsonplaceholder.typicode.com/posts/1'); // 예시 API
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err); // 💡 에러 발생 시 에러 상태 업데이트
      } finally {
        setLoading(false); // 💡 요청 완료 (성공/실패 무관) 시 로딩 상태 false
      }
    };

    fetchData();
  }, []);

  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 fetchData = async () => {
      try {
        setLoading(true);
        setError(null); // 이전 에러 상태 초기화

        const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
        
        // 💡 응답이 성공적인지 확인 (HTTP 상태 코드 200-299)
        if (!response.ok) {
          // 💡 성공적이지 않으면 에러를 던져 catch 블록으로 보냄
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err); // 💡 발생한 에러를 상태에 저장
      } finally {
        setLoading(false); // 💡 성공/실패 여부와 관계없이 로딩 종료
      }
    };

에러 메시지 표시

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

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

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

  • 빈 배열 ([]): 컴포넌트가 처음 마운트될 때 한 번만 요청합니다. 정적 데이터나 초기 로딩에 적합합니다.
  • 변수 포함 ([id, category]): 특정 propsstate 값이 변경될 때마다 데이터를 다시 가져옵니다. 예를 들어, 사용자 ID나 검색 카테고리가 변경될 때 유용합니다.

주의사항

  • 함수나 객체 참조: 의존성 배열에 함수나 객체를 직접 넣으면, 해당 함수나 객체가 매 렌더링마다 새로 생성될 때마다 useEffect가 불필요하게 재실행될 수 있습니다. 이를 방지하려면 useCallback (함수)이나 useMemo (객체) 훅을 사용하여 의존성을 안정화해야 합니다.
    // 잘못된 예시 (fetchData가 매 렌더링마다 새로 생성되어 무한 루프 가능성)
    useEffect(() => {
      const fetchData = async () => { /* ... */ };
      fetchData();
    }, [fetchData]); // 🚨 fetchData가 의존성에 포함되면 안 됨
    // 올바른 예시 1: useEffect 내부에 함수 정의 (일반적)
    useEffect(() => {
      const fetchData = async () => { /* ... */ };
      fetchData();
    }, []); // 빈 배열: 함수는 내부에서 정의되므로 외부 의존성 아님
    // 올바른 예시 2: useCallback으로 함수 안정화
    const fetchData = useCallback(async () => { /* ... */ }, []);
    useEffect(() => {
      fetchData();
    }, [fetchData]); // fetchData가 안정적이므로 안전
    데이터 페칭 함수의 경우, 대부분 useEffect 내부에 정의하는 것이 일반적이고 간결합니다.

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

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

useFetch 커스텀 훅 예시

src/hooks/useFetch.js
import { useState, useEffect } from 'react';

const useFetch = (url, options) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      setLoading(true);
      setError(null);
      try {
        const response = await fetch(url, { ...options, signal });
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name === 'AbortError') {
          console.log('Fetch aborted');
        } else {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    if (url) { // URL이 유효할 때만 fetch 실행 (옵션)
      fetchData();
    } else {
      setLoading(false);
    }

    return () => {
      controller.abort();
    };
  }, [url, options]); // URL이나 옵션이 변경될 때마다 재실행

  return { data, loading, error };
};

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 { data: post, loading, error } = useFetch(
    `https://jsonplaceholder.typicode.com/posts/${postId}`
  ); // 훅 사용!

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

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

  if (!post) {
    return <div style={{ textAlign: 'center', padding: '20px' }}>게시글을 찾을 수 없습니다.</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' }}>{post.title}</h2>
      <p>{post.body}</p>
    </div>
  );
}

export default PostDetailWithHook;

이처럼 커스텀 훅을 사용하면 각 컴포넌트에서 반복되는 로딩/에러 처리 로직을 깔끔하게 분리할 수 있습니다.


"로딩 상태와 에러 처리"는 여기까지입니다. 이 장에서는 비동기 데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 관리하고 사용자에게 피드백하는 중요성에 대해 배웠습니다. useStatetry...catch를 이용한 기본적인 구현 방법부터, useEffect의 의존성 배열 사용 시 주의사항, 그리고 커스텀 훅을 통한 로직 재사용까지 심화된 내용을 다루었습니다.

이제 여러분은 리액트 애플리케이션에서 견고하고 사용자 친화적인 데이터 페칭 로직을 구현할 수 있는 기초를 마련했습니다. 다음 장에서는 axios와 같은 인기 있는 HTTP 클라이언트 라이브러리를 사용하여 데이터 페칭을 더욱 편리하게 만드는 방법을 알아보겠습니다.