로딩 상태와 에러 처리
데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 처리하고 사용자에게 적절히 피드백하는 방법을 더 깊이 있게 다루겠습니다.
데이터 페칭은 비동기 작업이므로, 네트워크 지연, 서버 응답 없음, 데이터 형식 오류 등 다양한 문제가 발생할 수 있습니다.
이 상황들을 사용자에게 명확히 전달하는 것은 좋은 사용자 경험(UX)을 제공하는 데 매우 중요합니다.
이 절의 요청 URL은 실습 재현성을 위해 로컬 Mock API(json-server, http://localhost:4000) 기준으로 작성합니다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/소켓/GraphQL로 분리해 두면 트랙 병행 실습 시 충돌을 줄일 수 있습니다.
로딩 상태 (Loading State) 관리
사용자가 데이터를 기다리는 동안 애플리케이션이 멈춰있는 것처럼 보인다면 사용자 경험이 저해됩니다. 데이터가 로딩 중임을 명확히 표시하여 사용자가 기다리고 있음을 인지하게 해야 합니다.
구현 방법useState훅을 사용하여loading상태(true/false)를 관리합니다.- 데이터 요청 시작 시
loading을true로 설정합니다. - 데이터 요청 완료 시 (성공 또는 실패와 무관하게)
loading을false로 설정합니다. loading상태가true일 때 스피너, 스켈레톤 UI, 또는 데이터 로딩 중...과 같은 메시지를 렌더링합니다.
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블록을 사용하여 비동기 함수 내에서 발생할 수 있는 에러를 포착합니다.fetchAPI의 경우,response.ok(HTTP 상태 코드가 200-299 범위인지 여부)를 확인하여 서버 응답이 성공적인지 확인해야 합니다.response.ok가false이면 직접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]): 특정props나state값이 변경될 때마다 데이터를 다시 가져옵니다. 예를 들어, 사용자 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 커스텀 훅 예시
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 커스텀 훅 사용 예시
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로 격리할 수 있습니다.
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;
}
}import AppErrorBoundary from './components/AppErrorBoundary';
import PostDetailWithHook from './components/PostDetailWithHook';
export default function App() {
return (
<AppErrorBoundary fallback={<p>게시글 화면을 불러오지 못했습니다.</p>}>
<PostDetailWithHook />
</AppErrorBoundary>
);
}- 페이지 단위 경계: 라우트별 주요 화면을 감싸 전체 앱 장애를 방지합니다.
- 위젯 단위 경계: 외부 데이터 의존 위젯(차트, 에디터 등)을 개별 격리합니다.
- 재시도 정책: 버튼 한 번으로 복구 불가능한 에러는 새로고침/홈 이동 CTA를 함께 제공합니다.
로딩 상태와 에러 처리는 여기까지입니다. 이 장에서는 비동기 데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 관리하고 사용자에게 피드백하는 중요성에 대해 배웠습니다. useState와 try...catch를 이용한 기본적인 구현 방법부터, useEffect의 의존성 배열 사용 시 주의사항, 그리고 커스텀 훅을 통한 로직 재사용까지 심화된 내용을 다루었습니다.
이제 여러분은 리액트 애플리케이션에서 견고하고 사용자 친화적인 데이터 페칭 로직을 구현할 수 있는 기초를 마련했습니다. 다음 절에서는 axios와 같은 인기 있는 HTTP 클라이언트 라이브러리를 사용하여 데이터 페칭을 더욱 편리하게 만드는 방법을 알아보겠습니다.