
웹사이트를 구축할 때 모든 페이지마다 HTML을 만들어서 화면을 구성하는것이 좋을까?아니면 하나의 HTML을 만들고 메뉴마다 필요한 필요한 콘텐츠를 렌더링하여 보여주는것이 좋을까? 정답은 없다.
그렇지만 대표적인 기준점이 하나 있다. “잘 검색되게끔 해야하는가”이다.
검색 포탈에 사이트가 노출정도가 SPA보다 MPA이 우수하기 때문이다.
SPA : Single Page Application ( 하나의 HTML 파일에서 필요 콘텐츠 렌더링 )
MPA : Multi Page Application ( 여러개의 HTML 파일을 사용 )
하지만 대표적인 기준점일뿐 노출정도라는 하나장점만 보고 MPA를 골라야한다고 단정할 수 없다.
모듈화 컴포넌트 개발을 해야하는경우, 프론트와 백엔드 개발의 명확하게 구분해야하는경우,서버가 해야할 역할을 클라이언트에 부담해야하는 경우 등 이런 케이스들은 SPA를 쓰는게 정답일 수 있기 때문이다.
해당 단원에서는 리액트에서 SPA를 구축하는 방법에 대해서 알아볼 예정이다.
물론 React Router라는 라이브러리를 사용하여 SPA를 구축한다.
프로젝트에서 npm install react-router-dom 패키지를 설치한다.
자세한 사항은 reactrouter.com을 방문해서 이 패키지에 대해서 자세히 알아 볼 수 있다.
본격적으로 라우트를 정의해보자
Home의 사이트와 Products의 사이트를 SPA로 구현하는것이 목적이다.
App.js (라우트를 설정하자)
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import HomePage from './pages/Home';
import ProductsPage from './pages/Products';
const router = createBrowserRouter([
// path 프로퍼티는 경로를 설정한다. 기본 경로 일경우 '/'
// element 프로퍼티는 "어떤" "컴포넌트"를 보여줄것인지 설정한다.
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>}
]);
function App() {
return <RouterProvider router={router}/>
}
export default App;
요약하면
createBrowserRouter() 함수를 사용하여 라우팅 환경을 설정하고
RouterProvider를 사용해 ReactRouter를 받아 사용한다.
Home.js (Home의 특징을 가지는 Home.js 컴포넌트)
function HomePage() {
return (
<>
<h1>My Home Page</h1>
</>
);
}
export default HomePage;
Products.js (product의 특징을 가지는 Products.js 컴포넌트)
function ProductsPage() {
return <h1>The Products Pages</h1>
}
export default ProductsPage;

쇼핑몰에서 무슨 상품이 있는지 탐색하려 할때 주소창에 일일이 /products를 치는것을 본적은 없을것이다. Link를 구현하되 SPA의 목적에 반하지 않게 구현해보자.
늘 하던대로 앵커 요소에 href 속성을 받아서 사용하면 안될까?
<a href="/products">상품 페이지로 이동</a>.
아쉽지만 이렇게 사용한다면 SPA의 목적에 반하는 기능으로 구현이된다.
이렇게 사용하면 저 앵커 요소로 링크를 클릭하게될때 서버에 새로운 요청을 전송한다.
싱글 페이지 애플리케이션의 목적 자체가 자바스크립트 코드를 초반에 다 읽어 들여온 뒤 다시는 읽지 않게끔 하는것이다. 서버에 새로운 요청을하여 자바스크립트를 다시 읽어 들여오는것은 이치에 맞지 않다.
따라서 다음과 같은 방식으로 해야한다
react-router-dom 에서 Link 컴포넌트를 가져와서 링크를 만들자.
<Link to="/products">상품 페이지로 이동</Link>.
새로고침현상 즉 서버에 새로요청 하지않는다. SPA의 목적에 부합하는 구현이다.

상단에 내비게이션바를 고정시켜서 보여주고싶다면 어떻게 구현해야 할까?
세가지 컴포넌트에 모두 내비게이션바를 보여줘야 한다면 다음과 같은 그림으로 각각 일일이 코드를 작성하여 구현할 수도 있을것이다. 하지만 컴포넌트의 개수가 많아진다면 유지보수를 하기 힘들뿐더러 효율적이지 못하다.

(비 효율적인 코드작성 방법)
해결 방법으로는 다음과같이 레이아웃 래퍼를 만들고 콘텐츠의 내용은 별도로 path에 맞는 element에 작성된 컴포넌트를 렌더링한다는것이 기본 골자다.

(일일이 MainNavi 컴포넌트를 각컴포넌트에 작성해주지 않아도된다.)
만드는 방법은 간단하다.
App.js ( 레이아웃 래퍼 구성하기)
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import HomePage from './pages/Home';
import ProductsPage from './pages/Products';
import RootLayout from './pages/Root';
const router = createBrowserRouter([
{
// 1. 레이아웃 래퍼(부모)를 기본경로(‘/’)를 두고
// 2. 레이아웃 래퍼 컴포넌트를 기본적으로 렌더링하며
// 3. children 속성을 추가하여 주소에 맞는 컴포넌트를 렌더링하게끔 한다.
path: '/',
element : <RootLayout/>,
children : [
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>}
]
},
]);
function App() {
return <RouterProvider router={router}/>
}
export default App;
Root.js ( 레이아웃 래퍼 컴포넌트 )
// Outlet 패키지를 불러온다
import {Outlet} from 'react-router-dom'
import MainNavigation from '../components/MainNavigation'
import classes from './Root.module.css';
function RootLayout() {
return (
<>
// 최상단에 고정적으로 보여줄 네비게이션 컴포넌트 렌더링
<MainNavigation/>
<main className={classes.content}>
// 경로에 따른 조건부 컴포넌트 출력
<Outlet/>
</main>
</>
)
}
export default RootLayout;

해당 사진을 보고 현재 어느 네비게이션 목록에 해당하는 페이지인지 명시적으로 알수 있는가? 물론 “메인 페이지”라는 컨텐츠 내용을 보고 Home 목록에 해당하는 페이지인지 유추가 가능하다. 하지만 어떠한 홈페이지를 사용할때 사용자가 유추해서 사용하는것은 옳지 못하다. 그냥 사막에서 나침반 없이 횡단하는것과 마찬가지일것이다.
그렇다면 네비게이션 목록에 현재 위치하고있는 페이지에 따라 밑줄이 그어지는것으로 표시해보자.
여기서 사용해 볼 수 있는것은 Link의 대용품 NavLink를 사용해보자.
바꾸기전
MainNavigation.js (Link를 사용한 코드)
import {Link} from 'react-router-dom';
import classes from './MainNavigation.module.css'
function MainNavigation() {
return (
<header className={classes.header}>
<nav>
<ul className={classes.list}>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/products">Products</Link>
</li>
</ul>
</nav>
</header>
);
}
export default MainNavigation;
MainNavigation.js (NavLink를 사용한 코드)
import { NavLink } from "react-router-dom";
import classes from "./MainNavigation.module.css";
function MainNavigation() {
return (
<header className={classes.header}>
<nav>
<ul className={classes.list}>
<li>
<NavLink
to="/"
// 불리언값을 반환하는 프로퍼티를 보유하는 객체는
// react-router-dom에서 제공한다.
className={({ isActive }) =>
isActive ? classes.active : undefined
}
// 해당 end 프로퍼티를 사용하여 Home 컴포넌트만 적용하도록 한다.
// to("/")로 시작하여 end("")까지의 Path 경로를 가지는 컴포넌트만 적용할게요.
end={true}
>
Home
</NavLink>
</li>
<li>
<NavLink
to="/products"
className={({ isActive }) =>
isActive ? classes.active : undefined
}
// 여기서는 end 프로퍼티가 필요없다
// to("/products")로 시작하는 컴포넌트는 하나로 유일하기 때문이다.
>
Products
</NavLink>
</li>
</ul>
</nav>
</header>
);
}
export default MainNavigation;
MainNavigation.module.css ( css 모듈파일 )
.list {
display: flex;
gap: 1rem;
}
.list a {
text-decoration: none;
color: var(--color-primary-400);
}
.list a:hover,
.list a.active {
color: var(--color-primary-800);
text-decoration: underline;
}
react-router-dom 에서 제공하는 Link 컴포넌트나 NavLink 컴포넌트는 앵커태그(<a>)를 리턴하는것이기 때문에 css를 적용하려할때 a태그로 적용해주면 NavLink또는 Link 컴포넌트에 적용이 된다.

만약에 어떠한 폼이 제출되었거나, 타이머가 만료되었을 경우 강제 라우팅을 실시하여 네비게이션 동작을 트리거하고싶은 경우가 있을것이다.
이럴때 사용할 수 있는것은 react-router-dom이 제공하는 useNavigate 훅을 사용하면 된다.
Home.js ( useNavigate 훅을 사용하자 )
import {useNavigate} from 'react-router-dom';
function HomePage() {
const navigate = useNavigate();
function navigateHanlder() {
navigate('/products');
}
return (
<>
<h1>메인 페이지</h1>
<p>
<button onClick={navigateHanlder}>버튼누르면 이동</button>
</p>
</>
);
}
export default HomePage;

잘못된 경로로 들어갈때 react-router-dom 패키지는 다음과 같이 기본 오류 페이지를 출력한다.
이렇게 기본 오류 페이지보다는 커스텀 페이지를 만들어서 사용자 경험에 좀더 와닿게 만들고싶다면 어떻게 해야할까?
일단 오류 페이지 컴포넌트를 만들자.
Error.js( 오류페이지 컴포넌트 )
import MainNavigation from "../components/MainNavigation";
function ErrorPage() {
return (
<>
<MainNavigation/>
<main>
<h1> 오류가 발생했습니다. </h1>
<h3> 잘못된 경로를 입력. </h3>
</main>
</>
);
}
export default ErrorPage;
그런다음에 App.js에서 라우트를 정의한 부분을 통해 errorElement 프로퍼티를 추가해서 에러페이지 컴포넌트를 연결시켜주면 완성된다.
App.js ( errorElement 정의하기 )
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
import HomePage from './pages/Home';
import ProductsPage from './pages/Products';
import RootLayout from './pages/Root';
import ErrorPage from './pages/Error';
const router = createBrowserRouter([
{
path: '/',
element : <RootLayout/>,
// 잘못된 경로를 입력하게 되면, 즉 오류가 발생하게 된다면
// 해당 페이지의 폴백 페이지인 ErrorPage 컴포넌트를 렌더링한다.
errorElement : <ErrorPage/>,
children : [
// 자식 페이지들이 오류를 thorw할수 있는 상황은 추후 학습할 예정이다.
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>}
]
},
]);
function App() {
return <RouterProvider router={router}/>
}
export default App;

가장 먼저 예시를 이미지로 보자.

상품 상세 페이지(/products) 에서 각기다른 경로에 따른 상품 상세 내용을(snack or breed or drink) 렌더링 하고자 한다. 이때 렌더링하는 컴포넌트는 ProductDetail.js 하나로 동일하다.
(데이터까지 동적으로 출력하는것은 추후에 학습)
코드를 하나씩 살펴보자
App.js ( snack,breed,drink 상품의 경로를 추가함)
// (import 부분 생략..)
const router = createBrowserRouter([
{
path: '/',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>},
{ path: '/products/snack', element: <ProductDetailPage/>},
{ path: '/products/breed', element: <ProductDetailPage/>},
{ path: '/products/drink', element: <ProductDetailPage/>}
]
},
]);
function App() {
return <RouterProvider router={router}/>
}
export default App;
Products.js ( 제품들의 목록을 보여주는 제품 상세 페이지)
import { Link } from "react-router-dom";
function ProductsPage() {
return (
<>
<h1>제품 상세 페이지</h1>
<ul>
<li>
<Link to="/products/snack">과자</Link>
</li>
<li>
<Link to="/products/breed">빵</Link>
</li>
<li>
<Link to="/products/drink">음료수</Link>
</li>
</ul>
</>
);
}
export default ProductsPage;
ProductDetail.js (상품의 상세 내용을 렌더링하는 컴포넌트)
function ProductDetailPage() {
return <h1>상품 상세 내용</h1>;
}
export default ProductDetailPage;
Products.js ( 제품 목록들을 보여주는 제품 상세 페이지 )
import { Link } from "react-router-dom";
function ProductsPage() {
return (
<>
<h1>제품 상세 페이지</h1>
<ul>
<li>
<Link to="/products/snack">과자</Link>
</li>
<li>
<Link to="/products/breed">빵</Link>
</li>
<li>
<Link to="/products/drink">음료수</Link>
</li>
</ul>
</>
);
}
export default ProductsPage;
위 세 파일들의 문제점을 찾아보자. 무엇이 문제일까? 어떻게 고칠 수 있을까?
문제는 하드코딩 되어있다는 점이다. 제품들이 고정되어있고 수가 적으면 문제 없을지 몰라도, 수가 많아지면 유지보수에 있어서 어려움이 있을 뿐만 아니라 유연성도 떨어진다.
즉“ 동적 라우트 정의" 라고 하기에는 거리가 멀다.
코드를 개선하여 유연성 있게 바꿔보자.
App.js ( 라우팅 정의에서 역동적 경로 세그먼트를 사용하자 !! )
const router = createBrowserRouter([
{
path: '/',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>},
// react-router-dom에서 지원하는 역동적 경로 세그먼트를 사용!
// 콜론을 넣고 플레이스홀더를 넣으면 된다.
// 플레이스홀더 : 일종의 자리표시자로, 어떤 값을 대체할 수 있는 공간을 의미
{ path: '/products/:productId', element: <ProductDetailPage/>},
]
},
]);
function App() {
return <RouterProvider router={router}/>
}
export default App;
Products.js( 제품들의 목록을 보여주는 제품 상세 페이지)
// 링크도 만들어주자.
import { Link } from "react-router-dom";
// 더미 제품들
const PRODUCTS = [
{id : 'snack', title: '과자'},
{id : 'drink', title: '음료'},
{id : 'breed', title: '빵'},
]
function ProductsPage() {
return (
<>
<h1>제품 상세 페이지</h1>
<ul>
// 하드코딩이 아니라 매핑을하여 데이터에 따른 동적 추가가 구현이 되었다.
{PRODUCTS.map((prod) => (
<li key={prod.id}>
<Link to={`/products/${prod.id}`}>{prod.title}</Link>
</li>
))}
</ul>
</>
);
}
export default ProductsPage;
// 플레이스 홀더에 어떤 값이 입력되었는지 알기 위해서는 useParams 훅을 사용
ProductDetail.js
import { useParams } from "react-router-dom";
function ProductDetailPage() {
const params = useParams();
return(
<>
<h1>상품 상세 내용</h1>
{/* 라우트 정의에서 사용한 식별자를 그대로 사용해야한다. */}
<p>{params.productId}</p>
</>
)
}
export default ProductDetailPage;
이로써 추후에 어떠한 데이터가 추가된다고 하더라도 손쉽게 유지보수를 할 수 있게 되었다.

절대 경로 : “ / ” 를 붙여서 경로를 지정한다.
상대 경로 : “/”를 붙이지않고 현재 경로를 기준으로 경로가 계산이 된다.
예시를 들어보자 부모 래핑 레이아웃에 "/root"를 추가하면 어떤 일이 벌어질까?
App.js ( 부모 래핑 레이아웃에 절대경로(root)를 추가하면? )
path: '/root',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '/', element: <HomePage/> },
{ path: '/products', element: <ProductsPage/>},
{ path: '/products/:productId', element: <ProductDetailPage/>},
문제점이 발생한다! 해당 경로의 문제점은 무엇일까?
부모의 경로는 “http://localhost:3000/root” 로 시작하는데
자식 경로는
“http://localhost:3000/”
“http://localhost:3000/products”
“http://localhost:3000/products/:productId”
의 경로를 가지므로 자식들의 경로가 올바르지 않다.
따라서
App.js ( 부모루트의 절대경로에 맞게 자식들도 따라서 추가해야한다. )
path: '/root',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '/root', element: <HomePage/> },
{ path: '/root/products', element: <ProductsPage/>},
{ path: '/root/products/:productId', element: <ProductDetailPage/>}
이런식으로 부모루트의 절대경로에 맞게 추가 해서 해결해야한다.
(하지만 해결된게아니다.. 다른 컴포넌트에선 root를 추가하지않은 절대경로를 사용하고 있음)
아니면 이런식으로 상대경로를 사용하여 해결해도 된다.
App.js ( 부모 래핑 레이아웃은 절대경로를가지고, 자식은 상대경로를 사용 )
path: '/root',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '', element: <HomePage/> },
{ path: 'products', element: <ProductsPage/>},
{ path: 'products/:productId', element: <ProductDetailPage/>}
상대 경로는 절대 경로와 달리 현재 경로를 기준으로 계산하기 때문에 부모가 어떠한 경로를 추가했더라도 문제없이 동작한다. ( 하지만 이것도 마찬가지로 100% 해결되지 못한다. )
그런데 왜 100% 해결되지 못한다고 하는것일까? 일단 짧게 예시를 들면 라우터의 정의부분에서는 상대경로를 사용하고 있는데 주목하자. 반면 Product.js에서 코드 일부를 발췌해서 가져와보면..
<Link to={`/products/${prod.id}`}>{prod.title}</Link>
/products로 고정되어있다!
“root/products/:productId” 이것과
“products/:productId” 이것은 서로 일치하지 않기 때문에 문제점이 발생한다.
일치 하지 않는다는 문제점을 찾아가서 해결해보자.
Home.js
import {useNavigate} from 'react-router-dom';
function HomePage() {
const navigate = useNavigate();
function navigateHanlder() {
// (X) 이렇게 절대경로를 사용하지 않고,
// navigate('/products');
// (O) 상대경로로 사용하여 이동시킨다.
navigate('products');
}
return (
<>
<h1>메인 페이지</h1>
<p>
<button onClick={navigateHanlder}>버튼누르면 이동</button>
</p>
</>
);
}
export default HomePage;
Product.js
import { Link } from "react-router-dom";
// 더미 제품들
const PRODUCTS = [
{id : 'snack', title: '과자'},
{id : 'drink', title: '음료'},
{id : 'breed', title: '빵'},
]
function ProductsPage() {
return (
<>
<h1>제품 상세 페이지</h1>
<ul>
{PRODUCTS.map((prod) => (
<li key={prod.id}>
// (O) 바로 플레이스홀더만 받아와서 상대경로로 지정해준다.
<Link to={prod.id}>{prod.title}</Link>
</li>
))}
</ul>
</>
);
}
export default ProductsPage;
추가 학습할 내용으로 뒤로 돌아갈때는 경로(to)를 “..”로 사용해야하는데 유의사항이 있다.
import {Link, useParams } from "react-router-dom";
function ProductDetailPage() {
const params = useParams();
return(
<>
<h1>상품 상세 내용</h1>
<p>{params.productId}</p>
{/* realative 프로퍼티의 path를 사용하지 않고 단지 ".."의 경로를 사용한다면
한단계 수준위로간다 ( 두 세그먼트를 넘어가버림) route로 설정할경우도 마찬가지
한 세그먼트만을 뒤로 가고싶다면 relative 프로퍼티를 사용하여 path로 설정하자 */}
<p><Link to=".." relative="path">Back</Link></p>
</>
)
}
export default ProductDetailPage;
코드의 주석에 내용처럼 “한 수준”이 아니라 “한 세그먼트”를 넘어가고싶다면 relative 프로퍼티를 사용하여 path로 설정해야한다.
relative 프로퍼티를 사용하지 않거나 relative 프로퍼티를 사용하여 route로 설정한다면 “한 수준”으로 올라가므로 Home 메뉴가 나오게된다.
이때까지 학습한 내용을 이미지로 확인해보자. (주소창을 유의하며 보자)

부모 라우트가 활성화가 되었을때 로딩 되어야하는 기본 라우터를 정의할때 이렇게 정의했었다.
{
path: '/',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ path: '/', element: <HomePage/> },
//....
]
}
아무것도 서술하지않고 단지 부모 라우트의 경로에 맞춰서 ‘/’를 사용했었다...
하지만 index 프로퍼티를 사용하여 부모 라우터가 활성화될때 로딩되어야하는 기본 라우터를 정의해 줄 수 있다!
{
path: '/root',
element : <RootLayout/>,
errorElement : <ErrorPage/>,
children : [
{ index : true, element: <HomePage/> },
//....
]
}