안동민 개발노트 아이콘

안동민 개발노트

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

로딩 상태와 에러 처리

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

데이터 페칭은 비동기 작업이므로, 네트워크 지연, 서버 응답 없음, 데이터 형식 오류 등 다양한 문제가 발생할 수 있습니다. 이 상황들을 사용자에게 명확히 전달하는 것은 좋은 사용자 경험(UX)을 제공하는 데 매우 중요합니다. 이 절의 요청 URL은 실습 재현성을 위해 로컬 Mock API(json-server, http://localhost:4000) 기준으로 작성합니다. 포트 정책은 4000=Mock API, 4100=별도 백엔드/소켓/GraphQL로 분리해 두면 트랙 병행 실습 시 충돌을 줄일 수 있습니다.


로딩 상태 (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('http://localhost:4000/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('http://localhost:4000/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(
    `http://localhost:4000/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;

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



Error Boundary로 실패 범위 격리하기

데이터 페칭 에러는 try...catch로 처리할 수 있지만, 렌더링 중 예외가 발생하면 컴포넌트 트리 전체가 깨질 수 있습니다. 이때 Error Boundary를 두면 전체 페이지 다운 대신 문제 구역만 폴백 UI로 격리할 수 있습니다.

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

  handleRetry = () => {
    this.setState({ hasError: false });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: 16, border: '1px solid #f2b8b5', borderRadius: 8 }}>
          {this.props.fallback ?? <p>문제가 발생했습니다.</p>}
          <button onClick={this.handleRetry}>다시 시도</button>
        </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>
  );
}
배치 가이드
  • 페이지 단위 경계: 라우트별 주요 화면을 감싸 전체 앱 장애를 방지합니다.
  • 위젯 단위 경계: 외부 데이터 의존 위젯(차트, 에디터 등)을 개별 격리합니다.
  • 재시도 정책: 버튼 한 번으로 복구 불가능한 에러는 새로고침/홈 이동 CTA를 함께 제공합니다.

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

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

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


로딩과 에러 처리는 성공 데이터가 오기 전후의 모든 화면 상태를 구분하는 일입니다.