Frameworks/React

에러 처리와 로딩 상태 관리

newclass 2025. 4. 1. 00:57

에러 처리와 로딩 상태 관리

API 통신에서는 항상 에러와 로딩 상태를 적절히 처리해야 합니다.

1. 전역 에러 처리

// contexts/ErrorContext.js
import React, { createContext, useState, useContext } from 'react';

const ErrorContext = createContext(null);

export function ErrorProvider({ children }) {
  const [globalError, setGlobalError] = useState(null);

  // 에러 표시 함수
  const showError = (message) => {
    setGlobalError(message);
    // 일정 시간 후 에러 메시지 제거
    setTimeout(() => {
      setGlobalError(null);
    }, 5000);
  };

  // 에러 제거 함수
  const clearError = () => {
    setGlobalError(null);
  };

  return (
    <ErrorContext.Provider value={{ globalError, showError, clearError }}>
      {children}
      {/* 전역 에러 표시 UI */}
      {globalError && (
        <div className="global-error-container">
          <div className="global-error">
            <p>{globalError}</p>
            <button onClick={clearError}>닫기</button>
          </div>
        </div>
      )}
    </ErrorContext.Provider>
  );
}

export function useError() {
  return useContext(ErrorContext);
}
// App.js
import React from 'react';
import { ErrorProvider } from './contexts/ErrorContext';
import { AuthProvider } from './contexts/AuthContext';
import Router from './Router';

function App() {
  return (
    <ErrorProvider>
      <AuthProvider>
        <Router />
      </AuthProvider>
    </ErrorProvider>
  );
}

export default App;

2. 로딩 상태 UI 컴포넌트

// components/LoadingSpinner.js
import React from 'react';
import './LoadingSpinner.css'; // 스피너 스타일

function LoadingSpinner({ size = 'medium', fullPage = false }) {
  const sizeClass = {
    small: 'spinner-small',
    medium: 'spinner-medium',
    large: 'spinner-large'
  };

  if (fullPage) {
    return (
      <div className="spinner-fullpage">
        <div className={`spinner ${sizeClass[size]}`}></div>
      </div>
    );
  }

  return <div className={`spinner ${sizeClass[size]}`}></div>;
}

export default LoadingSpinner;
/* LoadingSpinner.css */
.spinner {
  border-radius: 50%;
  animation: spin 1s linear infinite;
  border-style: solid;
  border-color: #f3f3f3;
  border-top-color: #3498db;
}

.spinner-small {
  width: 20px;
  height: 20px;
  border-width: 2px;
}

.spinner-medium {
  width: 40px;
  height: 40px;
  border-width: 4px;
}

.spinner-large {
  width: 60px;
  height: 60px;
  border-width: 6px;
}

.spinner-fullpage {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgba(255, 255, 255, 0.7);
  z-index: 1000;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

사용 예:

import React, { useState, useEffect } from 'react';
import api from '../services/api';
import LoadingSpinner from '../components/LoadingSpinner';
import { useError } from '../contexts/ErrorContext';

function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const { showError } = useError();

  useEffect(() => {
    const fetchUserProfile = async () => {
      try {
        setLoading(true);
        const response = await api.get('/user/profile');
        setUser(response.data);
      } catch (error) {
        showError('사용자 프로필을 불러오는데 실패했습니다.');
      } finally {
        setLoading(false);
      }
    };

    fetchUserProfile();
  }, [showError]);

  if (loading) {
    return <LoadingSpinner fullPage size="large" />;
  }

  if (!user) {
    return <div>사용자 정보를 찾을 수 없습니다.</div>;
  }

  return (
    <div className="user-profile">
      <h1>{user.name}의 프로필</h1>
      <div className="profile-details">
        <p>이메일: {user.email}</p>
        <p>가입일: {new Date(user.createdAt).toLocaleDateString()}</p>
        {/* 기타 사용자 정보 */}
      </div>
    </div>
  );
}

export default UserProfile;

실전 API 연동 예제: 블로그 애플리케이션

다음은 블로그 포스트를 가져오고, 생성하고, 수정하고, 삭제하는 간단한 블로그 애플리케이션 예제입니다.

1. API 서비스 설정

// services/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
});

// 인터셉터 설정 (이전 예제 참조)

export default api;

2. 블로그 서비스 함수

// services/blogService.js
import api from './api';

export const blogService = {
  // 모든 포스트 가져오기
  getAllPosts: async () => {
    const response = await api.get('/posts');
    return response.data;
  },
  
  // 포스트 하나 가져오기
  getPost: async (id) => {
    const response = await api.get(`/posts/${id}`);
    return response.data;
  },
  
  // 포스트 생성하기
  createPost: async (postData) => {
    const response = await api.post('/posts', postData);
    return response.data;
  },
  
  // 포스트 수정하기
  updatePost: async (id, postData) => {
    const response = await api.put(`/posts/${id}`, postData);
    return response.data;
  },
  
  // 포스트 삭제하기
  deletePost: async (id) => {
    const response = await api.delete(`/posts/${id}`);
    return response.data;
  },
  
  // 포스트에 댓글 추가하기
  addComment: async (postId, commentData) => {
    const response = await api.post(`/posts/${postId}/comments`, commentData);
    return response.data;
  }
};

3. 블로그 컴포넌트

// pages/Blog.js
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { blogService } from '../services/blogService';
import LoadingSpinner from '../components/LoadingSpinner';
import { useAuth } from '../contexts/AuthContext';
import { useError } from '../contexts/ErrorContext';

function Blog() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const { user } = useAuth();
  const { showError } = useError();

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        setLoading(true);
        const data = await blogService.getAllPosts();
        setPosts(data);
      } catch (error) {
        showError('포스트를 불러오는데 실패했습니다.');
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();
  }, [showError]);

  if (loading) {
    return <LoadingSpinner fullPage />;
  }

  return (
    <div className="blog-container">
      <h1>블로그</h1>
      
      {user && (
        <Link to="/posts/new" className="create-post-btn">
          새 포스트 작성
        </Link>
      )}
      
      <div className="posts-list">
        {posts.length === 0 ? (
          <p>포스트가 없습니다.</p>
        ) : (
          posts.map(post => (
            <div key={post.id} className="post-card">
              <h2>{post.title}</h2>
              <p className="post-meta">
                작성자: {post.author.name} | 
                작성일: {new Date(post.createdAt).toLocaleDateString()}
              </p>
              <p className="post-excerpt">{post.excerpt}</p>
              <Link to={`/posts/${post.id}`}>자세히 보기</Link>
            </div>
          ))
        )}
      </div>
    </div>
  );
}

export default Blog;
// pages/PostDetail.js
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { blogService } from '../services/blogService';
import LoadingSpinner from '../components/LoadingSpinner';
import CommentForm from '../components/CommentForm';
import CommentsList from '../components/CommentsList';
import { useAuth } from '../contexts/AuthContext';
import { useError } from '../contexts/ErrorContext';

function PostDetail() {
  const { id } = useParams();
  const navigate = useNavigate();
  const [post, setPost] = useState(null);
  const [loading, setLoading] = useState(true);
  const { user } = useAuth();
  const { showError } = useError();

  useEffect(() => {
    const fetchPost = async () => {
      try {
        setLoading(true);
        const data = await blogService.getPost(id);
        setPost(data);
      } catch (error) {
        showError('포스트를 불러오는데 실패했습니다.');
        navigate('/blog');
      } finally {
        setLoading(false);
      }
    };

    fetchPost();
  }, [id, navigate, showError]);

  const handleDelete = async () => {
    if (window.confirm('정말 이 포스트를 삭제하시겠습니까?')) {
      try {
        await blogService.deletePost(id);
        navigate('/blog');
      } catch (error) {
        showError('포스트 삭제에 실패했습니다.');
      }
    }
  };

  const handleAddComment = async (commentData) => {
    try {
      const newComment = await blogService.addComment(id, commentData);
      // 포스트 상태 업데이트하여 새 댓글 추가
      setPost({
        ...post,
        comments: [...post.comments, newComment]
      });
    } catch (error) {
      showError('댓글 추가에 실패했습니다.');
    }
  };

  if (loading) {
    return <LoadingSpinner fullPage />;
  }

  if (!post) {
    return <div>포스트를 찾을 수 없습니다.</div>;
  }

  return (
    <div className="post-detail">
      <h1>{post.title}</h1>
      <p className="post-meta">
        작성자: {post.author.name} | 
        작성일: {new Date(post.createdAt).toLocaleDateString()}
      </p>
      
      <div className="post-content">{post.content}</div>
      
      {user && user.id === post.author.id && (
        <div className="post-actions">
          <Link to={`/posts/${id}/edit`} className="edit-btn">수정</Link>
          <button onClick={handleDelete} className="delete-btn">삭제</button>
        </div>
      )}
      
      <div className="comments-section">
        <h2>댓글</h2>
        <CommentsList comments={post.comments} />
        {user ? (
          <CommentForm onSubmit={handleAddComment} />
        ) : (
          <p>댓글을 작성하려면 <Link to="/login">로그인</Link>하세요.</p>
        )}
      </div>
    </div>
  );
}

export default PostDetail;
// pages/PostForm.js
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { blogService } from '../services/blogService';
import LoadingSpinner from '../components/LoadingSpinner';
import { useError } from '../contexts/ErrorContext';

function PostForm() {
  const { id } = useParams();
  const navigate = useNavigate();
  const isEditing = !!id;
  
  const [formData, setFormData] = useState({
    title: '',
    content: ''
  });
  
  const [loading, setLoading] = useState(isEditing);
  const [submitting, setSubmitting] = useState(false);
  const { showError } = useError();

  useEffect(() => {
    if (isEditing) {
      const fetchPost = async () => {
        try {
          const post = await blogService.getPost(id);
          setFormData({
            title: post.title,
            content: post.content
          });
        } catch (error) {
          showError('포스트를 불러오는데 실패했습니다.');
          navigate('/blog');
        } finally {
          setLoading(false);
        }
      };

      fetchPost();
    }
  }, [id, isEditing, navigate, showError]);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    if (!formData.title.trim() || !formData.content.trim()) {
      showError('제목과 내용을 모두 입력하세요.');
      return;
    }
    
    try {
      setSubmitting(true);
      
      if (isEditing) {
        await blogService.updatePost(id, formData);
      } else {
        await blogService.createPost(formData);
      }
      
      navigate(isEditing ? `/posts/${id}` : '/blog');
    } catch (error) {
      showError(`포스트 ${isEditing ? '수정' : '생성'}에 실패했습니다.`);
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) {
    return <LoadingSpinner fullPage />;
  }

  return (
    <div className="post-form-container">
      <h1>{isEditing ? '포스트 수정' : '새 포스트 작성'}</h1>
      
      <form onSubmit={handleSubmit} className="post-form">
        <div className="form-group">
          <label htmlFor="title">제목</label>
          <input
            type="text"
            id="title"
            name="title"
            value={formData.title}
            onChange={handleChange}
            required
          />
        </div>
        
        <div className="form-group">
          <label htmlFor="content">내용</label>
          <textarea
            id="content"
            name="content"
            value={formData.content}
            onChange={handleChange}
            rows="10"
            required
          />
        </div>
        
        <div className="form-actions">
          <button type="button" onClick={() => navigate(-1)} disabled={submitting}>
            취소
          </button>
          <button type="submit" disabled={submitting}>
            {submitting ? '저장 중...' : isEditing ? '수정' : '작성'}
          </button>
        </div>
      </form>
    </div>
  );
}

export default PostForm;

결론

React 애플리케이션에서 API 연동은 매우 중요한 부분입니다. 기본적인 Fetch API나 Axios를 사용하는 방법부터, 커스텀 훅으로 로직을 추상화하거나 React Query나 SWR과 같은 고급 라이브러리를 활용하는 방법까지 다양한 접근 방식이 있습니다.

효과적인 API 연동을 위한 핵심 원칙:

  1. 관심사 분리: API 호출 로직을 컴포넌트에서 분리하여 재사용성과 유지보수성 향상
  2. 적절한 상태 관리: 로딩 상태, 에러 상태, 데이터 상태를 명확히 관리
  3. 에러 처리: 네트워크 오류, 서버 오류 등을 적절히 처리하고 사용자에게 피드백 제공
  4. 인증 처리: 토큰 기반 인증, 리프레시 토큰 등의 인증 메커니즘 구현
  5. 캐싱과 최적화: 불필요한 API 요청 줄이기, 데이터 캐싱으로 성능 최적화

이러한 원칙과 패턴을 따르면 안정적이고 유지보수가 용이한 React 애플리케이션을 구축할 수 있습니다.