본문 바로가기

데이터 로딩과 에러 처리

React Router 6.4 버전의 가장 큰 특징 중 하나는 라우트 레벨에서 데이터를 로딩하고 에러를 처리할 수 있다는 것입니다. 이를 통해 컴포넌트 로직과 데이터 로딩 로직을 깔끔하게 분리할 수 있습니다.

1. loader를 사용한 데이터 로딩

loader는 라우트에 연결된 컴포넌트가 렌더링되기 전에 실행되어 필요한 데이터를 미리 불러올 수 있게 해줍니다. 즉, 사용자가 해당 경로로 이동하면 React Router가 먼저 loader를 실행하여 데이터를 준비하고, 데이터가 준비된 후에 컴포넌트를 렌더링합니다. 기존에 컴포넌트 내부에서 useEffect로 데이터를 불러오는 것과 달리, loader를 사용하면 화면이 표시될 때 이미 데이터가 준비되어 있어 로딩 상태를 별도로 관리할 필요가 줄어듭니다.

(useEffect는 React 컴포넌트에서 부수 효과를 처리하기 위한 Hook입니다. 추후에 더 자세하게 알아볼 예정입니다.)

1.1 기본 사용법

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  Link,
} from 'react-router-dom';

// loader 함수 정의
async function productsLoader() {
  const response = await fetch('https://dummyjson.com/products?limit=10');

  if (!response.ok) {
    throw new Error('상품 정보를 불러오는데 실패했습니다.');
  }

  const data = await response.json();
  return data.products;
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/products',
    element: <Products />,
    loader: productsLoader, // loader 함수 연결
  },
]);

function Home() {
  return (
    <div>
      <h1>홈</h1>
      <Link to="/products">상품 목록 보기</Link>
    </div>
  );
}

function Products() {
  // useLoaderData로 loader가 반환한 데이터 사용
  const products = useLoaderData();

  return (
    <div>
      <h1>상품 목록</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.title} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

위 코드는 /products 경로로 이동할 때 productsLoader 함수가 먼저 실행되어 외부 API에서 상품 데이터를 가져옵니다. 데이터 로딩이 완료되면 Products 컴포넌트가 렌더링되고, useLoaderData() 훅을 통해 loader가 반환한 상품 목록을 바로 사용할 수 있습니다. 만약 API 요청이 실패하면 throw new Error()로 에러를 발생시켜 에러 처리를 할 수 있습니다.

1.2 URL 파라미터를 사용하는 loader

loader 함수는 params 객체를 통해 URL 파라미터에 접근할 수 있습니다.

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  Link,
} from 'react-router-dom';

// 상품 목록 loader
async function productsLoader() {
  const response = await fetch('https://dummyjson.com/products?limit=10');
  const data = await response.json();
  return data.products;
}

// 상품 상세 loader - params로 URL 파라미터 접근
async function productDetailLoader({ params }) {
  const response = await fetch(
    `https://dummyjson.com/products/${params.productId}`,
  );

  if (!response.ok) {
    throw new Response('상품을 찾을 수 없습니다.', { status: 404 });
  }

  return response.json();
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/products',
    element: <Products />,
    loader: productsLoader,
  },
  {
    path: '/products/:productId',
    element: <ProductDetail />,
    loader: productDetailLoader,
  },
]);

function Home() {
  return (
    <div>
      <h1>홈</h1>
      <Link to="/products">상품 목록</Link>
    </div>
  );
}

function Products() {
  const products = useLoaderData();

  return (
    <div>
      <h1>상품 목록</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            <Link to={`/products/${product.id}`}>{product.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

function ProductDetail() {
  const product = useLoaderData();

  return (
    <div>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <p>가격: ${product.price}</p>
      <Link to="/products">목록으로</Link>
    </div>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

위 코드에서 /products/:productId 경로의 productDetailLoader는 params.productId를 통해 URL에서 상품 ID를 추출합니다. 예를 들어 /products/5로 이동하면 params.productId는 "5"가 되고, 이 값을 사용해 해당 상품의 상세 정보를 API에서 가져옵니다. 상품이 존재하지 않으면 throw new Response()로 404 에러를 발생시켜 에러 페이지를 표시할 수 있습니다.

1.3 쿼리스트링을 사용하는 loader

loader 함수는 request 객체를 통해 URL의 쿼리스트링에도 접근할 수 있습니다.

async function productsLoader({ request }) {
  const url = new URL(request.url);
  const category = url.searchParams.get('category');
  const sort = url.searchParams.get('sort');

  let apiUrl = 'https://dummyjson.com/products';

  if (category) {
    apiUrl = `https://dummyjson.com/products/category/${category}`;
  }

  const response = await fetch(apiUrl);
  const data = await response.json();

  // 정렬 처리
  if (sort === 'price') {
    data.products.sort((a, b) => a.price - b.price);
  }

  return data.products;
}

위 코드에서 request.url은 현재 요청된 전체 URL을 담고 있습니다. 예를 들어 /products?category=laptops&sort=price로 접속하면, url.searchParams.get('category')는 "laptops"를, url.searchParams.get('sort')는 "price"를 반환합니다. 이를 활용해 카테고리별 필터링과 가격순 정렬을 적용한 상품 목록을 반환합니다.

loader 사용의 장점

  1. 관심사 분리: 데이터 로딩 로직이 컴포넌트에서 분리되어 코드가 깔끔해집니다.
  2. 병렬 로딩: 중첩된 라우트의 loader들이 병렬로 실행되어 성능이 향상됩니다.
  3. 자동 에러 처리: errorElement와 함께 사용하면 에러 처리가 간편해집니다.
  4. 로딩 상태 관리: useNavigation을 통해 전역적으로 로딩 상태를 관리할 수 있습니다.

2. useNavigation으로 로딩 상태 처리

useNavigation 훅을 사용하면 현재 네비게이션 상태를 확인할 수 있습니다. 이를 통해 데이터 로딩 중 로딩 인디케이터를 표시할 수 있습니다.

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  useNavigation,
  Link,
  Outlet,
} from 'react-router-dom';

async function productsLoader() {
  // 로딩 상태를 확인하기 위해 인위적인 지연 추가
  await new Promise((resolve) => setTimeout(resolve, 1000));

  const response = await fetch('https://dummyjson.com/products?limit=10');
  const data = await response.json();
  return data.products;
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'products',
        element: <Products />,
        loader: productsLoader,
      },
    ],
  },
]);

function RootLayout() {
  const navigation = useNavigation();
  const isLoading = navigation.state === 'loading';

  return (
    <div>
      <nav>
        <Link to="/">홈</Link>
        <Link to="/products">상품</Link>
      </nav>

      {/* 전역 로딩 인디케이터 */}
      {isLoading && <div className="loading-bar">로딩 중...</div>}

      <main>
        <Outlet />
      </main>
    </div>
  );
}

function Home() {
  return <h1>홈</h1>;
}

function Products() {
  const products = useLoaderData();

  return (
    <div>
      <h1>상품 목록</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.title}</li>
        ))}
      </ul>
    </div>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

navigation.state는 다음 세 가지 값 중 하나를 가집니다.

  • idle: 대기 상태 (로딩 완료)
  • loading: 데이터 로딩 중 (loader 실행 중)
  • submitting: 폼 제출 중 (action 실행 중)

위 코드에서 RootLayout 컴포넌트는 useNavigation 훅을 사용해 현재 라우팅 상태를 감지합니다. 사용자가 "상품" 링크를 클릭하면 productsLoader가 실행되는 동안 navigation.state가 "loading"이 되어 "로딩 중..." 메시지가 표시됩니다. 데이터 로딩이 완료되면 상태가 "idle"로 바뀌고 로딩 인디케이터가 사라집니다. 이처럼 레이아웃 컴포넌트에서 useNavigation을 사용하면 전역 로딩 상태를 한 곳에서 관리할 수 있습니다.

3. errorElement를 사용한 에러 처리

라우트에서 에러가 발생하면 errorElement에 지정된 컴포넌트가 렌더링됩니다. loader에서 에러를 throw하거나, 컴포넌트 렌더링 중 에러가 발생했을 때 이 컴포넌트가 표시됩니다.

3.1 기본 에러 처리

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  useRouteError,
  Link,
} from 'react-router-dom';

async function productDetailLoader({ params }) {
  const response = await fetch(
    `https://dummyjson.com/products/${params.productId}`,
  );

  if (!response.ok) {
    throw new Response('상품을 찾을 수 없습니다.', { status: 404 });
  }

  return response.json();
}

const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
    errorElement: <RootError />, // 전역 에러 처리
  },
  {
    path: '/products/:productId',
    element: <ProductDetail />,
    loader: productDetailLoader,
    errorElement: <ProductError />, // 특정 라우트 에러 처리
  },
]);

function Home() {
  return (
    <div>
      <h1>홈</h1>
      <Link to="/products/1">상품 1</Link>
      <Link to="/products/9999">존재하지 않는 상품</Link>
    </div>
  );
}

function ProductDetail() {
  const product = useLoaderData();
  return (
    <div>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
    </div>
  );
}

// 전역 에러 페이지
function RootError() {
  const error = useRouteError();

  return (
    <div>
      <h1>오류가 발생했습니다</h1>
      <p>{error.message || '알 수 없는 오류가 발생했습니다.'}</p>
      <Link to="/">홈으로 돌아가기</Link>
    </div>
  );
}

// 상품 에러 페이지
function ProductError() {
  const error = useRouteError();

  return (
    <div>
      <h1>상품을 불러올 수 없습니다</h1>
      <p>
        {error.status === 404
          ? '요청하신 상품을 찾을 수 없습니다.'
          : '상품 정보를 불러오는 중 오류가 발생했습니다.'}
      </p>
      <Link to="/">홈으로 돌아가기</Link>
    </div>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;

위 코드에서 /products/9999처럼 존재하지 않는 상품에 접근하면 productDetailLoader에서 throw new Response()로 404 에러를 발생시킵니다. 이때 해당 라우트에 지정된 ProductError 컴포넌트가 렌더링되어 사용자에게 에러 메시지를 보여줍니다. useRouteError훅을 사용하면 발생한 에러 객체에 접근할 수 있어, error.status 값에 따라 다른 메시지를 표시할 수 있습니다. 전역 에러 처리(RootError)와 특정 라우트 에러 처리(ProductError)를 분리하면 상황에 맞는 에러 화면을 제공할 수 있습니다.

3.2 useRouteError 훅

useRouteError 훅은 errorElement 내에서 발생한 에러 객체를 반환합니다. 에러의 세부 정보를 확인하여 적절한 에러 메시지를 표시할 수 있습니다.

import { useRouteError, isRouteErrorResponse } from 'react-router-dom';

function ErrorPage() {
  const error = useRouteError();

  // Response 객체인지 확인 (throw new Response() 사용 시)
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} 에러</h1>
        <p>{error.statusText}</p>
        {error.status === 404 && <p>요청하신 페이지를 찾을 수 없습니다.</p>}
        {error.status === 500 && <p>서버에 문제가 발생했습니다.</p>}
      </div>
    );
  }

  // 일반 Error 객체인 경우
  return (
    <div>
      <h1>오류가 발생했습니다</h1>
      <p>{error.message}</p>
    </div>
  );
}

위 코드에서 isRouteErrorResponse함수는 에러가 throw new Response()로 발생한 HTTP 응답 에러인지 확인합니다. HTTP 응답 에러인 경우 error.status와 error.statusText로 상태 코드와 메시지에 접근할 수 있고, 일반 throw new Error()로 발생한 에러인 경우 error.message로 에러 메시지에 접근합니다. 이렇게 에러 유형을 구분하면 상황에 맞는 에러 화면을 제공할 수 있습니다.

4. 404 Not Found 페이지

존재하지 않는 경로로 접근했을 때 표시할 페이지를 만들어 봅시다. path: '*'를 사용하면 다른 라우트와 매칭되지 않는 모든 경로를 처리할 수 있습니다.

import {
  createBrowserRouter,
  RouterProvider,
  Link,
  useLocation,
} from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/about',
    element: <About />,
  },
  // 모든 미매칭 경로 처리
  {
    path: '*',
    element: <NotFound />,
  },
]);

function Home() {
  return (
    <div>
      <h1>홈</h1>
      <Link to="/about">소개</Link>
      <Link to="/nonexistent">존재하지 않는 페이지</Link>
    </div>
  );
}

function About() {
  return <h1>소개 페이지</h1>;
}

function NotFound() {
  const location = useLocation();

  return (
    <div className="not-found">
      <h1>404 - 페이지를 찾을 수 없습니다</h1>
      <p>요청하신 페이지 ({location.pathname})를 찾을 수 없습니다.</p>

      <div className="not-found-actions">
        <Link to="/">홈으로 돌아가기</Link>
        <button onClick={() => window.history.back()}>이전 페이지로</button>
      </div>
    </div>
  );
}

function App() {
  return <RouterProvider router={router} />;
}

export default App;
/* CSS */
.not-found {
  text-align: center;
  padding: 50px 20px;
}

.not-found-actions {
  margin-top: 30px;
  display: flex;
  gap: 20px;
  justify-content: center;
}

.not-found-actions a,
.not-found-actions button {
  padding: 10px 20px;
  border-radius: 5px;
  text-decoration: none;
  cursor: pointer;
}

위 코드에서 path: '*'는 와일드카드로, 위에서 정의한 /와 /about 외의 모든 경로와 매칭됩니다. 사용자가 /nonexistent처럼 정의되지 않은 경로로 접근하면 NotFound 컴포넌트가 렌더링됩니다. useLocation 훅으로 현재 경로를 가져와 사용자가 어떤 잘못된 경로로 접근했는지 표시해줄 수 있습니다.

데이터 로딩과 에러 처리 - React 에센셜 | 위니버시티