Skip to Content
WebReactReact 중급03. 라우터 설치와 목록 페이지 분리

이번 편의 결과물: bookshelf가 라우터로 감싸이고 책 목록이 / 라우트에서 동작합니다. · 다루는 개념: react-router 설치, createBrowserRouter, RouterProvider, Link

이 편에서 만드는 파일

bookshelf/ ├── package.json (~) └── src/ ├── main.jsx (~) ├── router.jsx (+) ├── pages/ │ └── BookListPage.jsx (+) └── components/ └── BookCard.jsx (~)

src/App.jsx는 이번 편부터 더 이상 쓰지 않습니다. 그 안의 로직은 pages/BookListPage.jsx로 옮깁니다.

개념 정리

데이터 모드 라우터의 세 조각

React Router 데이터 모드는 라우트를 배열로 선언하고, 그 배열로 라우터 객체를 만들고, 그 객체를 트리 최상단에 공급하는 세 단계로 구성됩니다.

조각역할
라우트 객체 배열path(URL 패턴)와 element(렌더할 컴포넌트)를 짝짓는다
createBrowserRouter(routes)라우트 배열을 받아 History API 기반 라우터 인스턴스를 만든다
<RouterProvider router={router} />만든 라우터를 React 트리에 연결한다. 앱의 최상단에 한 번만 둔다
방식클릭 시 동작
<a href="/books/3">브라우저가 서버에 새 페이지를 요청(전체 새로고침)
<Link to="/books/3">라우터가 History API로 URL만 바꾸고 React가 다시 렌더(새로고침 없음)

SPA 안에서 페이지를 이동할 때는 항상 Link(또는 useNavigate)를 씁니다. a 태그는 외부 사이트로 나갈 때만 남겨둡니다.

errorElement

라우트에 매칭되는 화면이 없거나 렌더 중 오류가 나면 React Router는 errorElement로 지정한 컴포넌트를 대신 보여줍니다. errorElement를 두지 않으면 처리되지 않은 기본 오류 화면이 뜹니다.

실습

1. react-router 설치

React Router v7부터 react-router-domreact-router가 한 패키지(react-router)로 통합되었습니다. 이 패키지 하나만 설치합니다.

npm install react-router

2. src/router.jsx 만들기

// src/router.jsx import { createBrowserRouter, Link, useRouteError } from 'react-router'; import BookListPage from './pages/BookListPage.jsx'; function RouteErrorPage() { const error = useRouteError(); const isNotFound = error?.status === 404; return ( <div className="route-error"> <h1>{isNotFound ? '페이지를 찾을 수 없습니다' : '문제가 발생했습니다'}</h1> <p>{error?.statusText ?? error?.message ?? '잠시 후 다시 시도해 주세요.'}</p> <Link to="/">목록으로 돌아가기</Link> </div> ); } export const router = createBrowserRouter([ { path: '/', element: <BookListPage />, errorElement: <RouteErrorPage />, }, ]);

RouteErrorPage는 아직 없는 경로(예: /books/3)로 이동했을 때 대신 보여줄 화면입니다. 04편에서 그 경로에 실제 라우트를 채웁니다.

3. src/pages/BookListPage.jsx 만들기

App.jsx에 있던 책 목록·추가 폼·필터·테마 전환 로직을 그대로 옮깁니다.

// src/pages/BookListPage.jsx import { useReducer, useState, useEffect } from 'react'; import { useTheme } from '../context/ThemeContext.jsx'; import { booksReducer } from '../reducers/booksReducer.js'; import BookForm from '../components/BookForm.jsx'; import BookList from '../components/BookList.jsx'; import ThemeToggle from '../components/ThemeToggle.jsx'; const STORAGE_KEY = 'bookshelf-books'; function loadInitialBooks() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } export default function BookListPage() { const [books, dispatch] = useReducer(booksReducer, undefined, loadInitialBooks); const [filter, setFilter] = useState('all'); const { theme } = useTheme(); useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(books)); }, [books]); const visibleBooks = filter === 'all' ? books : books.filter((book) => book.status === filter); return ( <div className={`page page--${theme}`}> <header className="page-header"> <h1>내 책장</h1> <ThemeToggle /> </header> <BookForm onAdd={(book) => dispatch({ type: 'added', payload: book })} /> <div className="filter-bar"> <label htmlFor="status-filter">상태 필터</label> <select id="status-filter" value={filter} onChange={(event) => setFilter(event.target.value)} > <option value="all">전체</option> <option value="wish">읽고 싶음</option> <option value="reading">읽는 중</option> <option value="done">완독</option> </select> </div> <BookList books={visibleBooks} onToggle={(id) => dispatch({ type: 'toggled', payload: { id } })} onDelete={(id) => dispatch({ type: 'deleted', payload: { id } })} /> </div> ); }

localStorage 키(STORAGE_KEY)는 이 페이지 안에 직접 두었습니다. 06편에서 useLocalStorage 훅으로 옮기면 이 중복이 사라집니다.

// src/components/BookCard.jsx import { Link } from 'react-router'; export default function BookCard({ book, onToggle, onDelete }) { return ( <li className="book-card" style={{ borderLeftColor: book.coverColor }}> <Link to={`/books/${book.id}`} className="book-card__title"> {book.title} </Link> <span className="book-card__author">{book.author}</span> <span className="book-card__status">{book.status}</span> <button type="button" onClick={() => onToggle(book.id)}> 상태 변경 </button> <button type="button" onClick={() => onDelete(book.id)}> 삭제 </button> </li> ); }

5. src/main.jsx 수정 — App 대신 RouterProvider

// src/main.jsx import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router'; import { ThemeProvider } from './context/ThemeContext.jsx'; import { router } from './router.jsx'; import './App.css'; createRoot(document.getElementById('root')).render( <StrictMode> <ThemeProvider> <RouterProvider router={router} /> </ThemeProvider> </StrictMode>, );

6. 실행

npm run dev

확인

  • 브라우저 주소창이 http://localhost:5173/이고 책 목록·추가 폼·필터가 그대로 보입니다.
  • 책 제목을 클릭하면 주소창이 /books/1처럼 바뀌고, 04편 이전이라 아직 라우트가 없으므로 RouteErrorPage의 “페이지를 찾을 수 없습니다” 화면이 뜹니다. 새로고침 없이 화면만 바뀌었다면 정상입니다.
  • 브라우저의 뒤로 가기 버튼을 누르면 다시 목록으로 돌아갑니다.

직접 해보기

  1. RouteErrorPage에 “홈으로” 대신 다른 문구의 Link를 하나 더 추가해봅니다.
  2. router.jsxpath/에서 /home으로 바꾸고 http://localhost:5173/로 접속했을 때 어떤 화면이 뜨는지 확인해봅니다.

정답 보기

path/home으로 바꾸면 /에는 매칭되는 라우트가 없어 RouteErrorPage가 뜹니다. 라우트 경로와 실제로 이동할 URL이 일치해야 합니다.

자주 하는 실수

증상원인고치는 법
Link is not defined 오류react-router에서 import하지 않음import { Link } from 'react-router'; 추가
클릭하면 페이지가 새로고침됨Link 대신 a 태그를 그대로 씀to prop을 쓰는 Link로 바꾼다
RouterProvider를 두 번 렌더링함main.jsx와 다른 파일에 중복으로 둠트리 최상단 한 곳에만 둔다
화면이 하얗게 뜸errorElement가 없어 오류가 그대로 노출됨라우트에 errorElement를 지정한다

확인 문제

문제 14지선다
createBrowserRouter에 넘기는 값의 형태는?
문제 24지선다
a 태그 대신 Link 컴포넌트를 쓰는 이유는?
문제 34지선다
RouterProvider를 두는 위치로 옳은 것은?
문제 44지선다
라우트에 errorElement를 지정하지 않았을 때 매칭되는 라우트가 없으면 일어나는 일은?
문제 54지선다
이번 편에서 책 제목을 클릭했을 때 RouteErrorPage가 뜨는 이유는?

참고 자료

Last updated on