지난 시간에는 성능 최적화 도구들을 다뤘습니다. 이번 시간은 이 시리즈의 마지막 글입니다. 지금까지 우리는 하나의 화면 안에서 일어나는 일들을 다뤘는데, 실제 앱은 보통 여러 화면을 갖습니다. 메뉴 클릭에 따라 화면이 바뀌고, URL이 바뀌고, 뒤로 가기 버튼도 동작해야 하죠. 이런 화면 전환을 다루는 도구가 라우팅입니다.
전통적인 웹 vs SPA
전통적인 웹 페이지는 사용자가 링크를 클릭할 때마다 브라우저가 서버에 새 페이지를 요청하고, 서버가 만든 새 HTML을 받아 화면 전체를 다시 그렸습니다. 페이지 전환마다 흰 깜빡임이 생기는 그 방식이죠.
**SPA(Single Page Application)**는 처음 한 번 HTML을 받은 뒤, 이후의 화면 전환은 자바스크립트로 화면을 다시 그리는 방식입니다. 서버에 새 HTML을 요청하지 않고 클라이언트가 알아서 화면을 갈아 끼우니, 전환이 빠르고 부드럽습니다.
리액트로 만든 앱은 기본적으로 SPA입니다. 그런데 SPA는 처음 받은 그 HTML 안에서 모든 일이 일어나므로, "URL이 바뀌면 어떤 화면을 보여줄지"를 우리가 직접 정해줘야 합니다. 이걸 클라이언트 사이드 라우팅이라고 부르며, 리액트 생태계의 사실상 표준 라이브러리가 React Router입니다.
Next.js 같은 프레임워크는 라우팅을 파일 시스템 기반으로 자체 제공해서 React Router를 따로 쓰지 않습니다. 이 시리즈의 후속편에서 다룰 예정입니다. 이번 글은 순수 리액트(Vite로 만든 앱)에서 화면 전환을 어떻게 다루는지에 집중합니다.
React Router 설치
지금까지 사용하던 Vite 프로젝트에 React Router를 추가합니다.
npm install react-router-dom(react-router가 아니라 **react-router-dom**입니다 — 웹용 React Router 패키지)
가장 단순한 예시
라우팅의 기본 구조를 먼저 보여드리겠습니다.
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function Home() {
return <h1>홈 페이지</h1>;
}
function About() {
return <h1>소개 페이지</h1>;
}
function App() {
return (
<BrowserRouter>
<nav style={{ padding: '8px', borderBottom: '1px solid #ccc' }}>
<Link to="/">홈</Link>
{' | '}
<Link to="/about">소개</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
export default App;핵심 요소들:
<BrowserRouter>— 라우팅을 활성화하는 최상위 래퍼. 앱 전체를 감싼다<Routes>— 여러<Route>중 현재 URL과 일치하는 하나를 골라 렌더링하는 컨테이너<Route path="..." element={<...>} />— 어떤 경로에 어떤 컴포넌트를 보여줄지 정의<Link to="...">— 화면 깜빡임 없이 라우트를 전환하는 링크
<a href="/about"> 같은 일반 anchor 태그를 쓰면 브라우저가 페이지를 새로 로드해서 SPA의 장점을 잃습니다. 반드시 <Link>를 써야 클라이언트 사이드 전환이 일어납니다.
URL 파라미터 — 동적 경로
상품 상세 페이지나 사용자 프로필처럼, URL의 일부가 동적으로 바뀌는 경로가 있습니다. 콜론(:)을 붙여 동적 부분을 표시합니다.
<Route path="/users/:userId" element={<UserProfile />} />/users/123, /users/cheolsu 같은 URL이 모두 이 라우트에 매칭됩니다. 컴포넌트 안에서는 useParams 훅으로 동적 부분의 값을 꺼냅니다.
import { useParams } from 'react-router-dom';
function UserProfile() {
const { userId } = useParams();
return <h1>사용자 ID: {userId}</h1>;
}
export default UserProfile;useParams는 path에 명시된 파라미터를 모두 객체로 돌려줍니다. path="/users/:userId/posts/:postId"라면 { userId, postId }를 꺼낼 수 있죠.
프로그래매틱 네비게이션 — useNavigate
링크 외에도 코드로 직접 이동시켜야 할 때가 있습니다. 폼 제출 후 결과 페이지로 이동하거나, 로그아웃 버튼이 홈으로 보내는 식이죠. useNavigate 훅을 사용합니다.
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const [email, setEmail] = useState('');
const navigate = useNavigate();
function handleSubmit(e) {
e.preventDefault();
// ... 로그인 처리 ...
navigate('/dashboard');
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button type="submit">로그인</button>
</form>
);
}navigate('/path')로 이동하고, navigate(-1)이면 뒤로 가기, navigate(1)이면 앞으로 가기입니다.
쿼리 파라미터 — useSearchParams
URL의 ?key=value&key2=value2 부분(쿼리 스트링)을 다룰 때는 useSearchParams를 사용합니다.
import { useSearchParams } from 'react-router-dom';
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
return (
<div>
<input
value={query}
onChange={(e) => setSearchParams({ q: e.target.value })}
placeholder="검색어"
/>
<p>현재 검색어: {query}</p>
</div>
);
}useSearchParams는 useState와 비슷한 인터페이스로 동작합니다. 입력에 따라 URL 자체가 /search?q=리액트처럼 갱신되고, 새로고침하거나 URL을 공유해도 같은 상태가 복원됩니다. 검색 결과 페이지처럼 "URL에 상태가 반영되어야 하는" 경우에 유용합니다.
중첩 라우트와 Outlet
여러 페이지가 같은 레이아웃(헤더, 사이드바 등)을 공유할 때는 중첩 라우트가 깔끔합니다. 부모 라우트가 공통 레이아웃을 그리고, 자식 라우트가 그 안의 콘텐츠 자리를 채우는 구조죠.
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users/:userId" element={<UserProfile />} />
</Route>
</Routes>import { Link, Outlet } from 'react-router-dom';
function Layout() {
return (
<div>
<header style={{ padding: '8px', background: '#f4f4f4' }}>
<Link to="/">홈</Link>
{' | '}
<Link to="/about">소개</Link>
</header>
<main style={{ padding: '16px' }}>
<Outlet />
</main>
</div>
);
}<Outlet />이 있는 자리에 자식 라우트의 컴포넌트가 렌더링됩니다. <Route index>는 부모 경로(/)와 정확히 매치될 때 보여줄 자식을 의미합니다.
이 패턴 덕에 헤더/푸터 코드를 한 곳에 두고도, URL에 따라 가운데 콘텐츠만 바꿀 수 있습니다.
404 페이지
매칭되는 라우트가 없을 때의 페이지를 만들려면 path="*"로 와일드카드 라우트를 마지막에 둡니다.
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>위에서부터 매칭을 시도하다가 어디에도 안 맞으면 *가 잡아냅니다.
활성 링크 표시 — NavLink
네비게이션 바에서 현재 페이지 링크를 강조하고 싶을 때 <Link> 대신 <NavLink>를 사용합니다.
import { NavLink } from 'react-router-dom';
function Nav() {
return (
<nav>
<NavLink
to="/"
end
style={({ isActive }) => ({
fontWeight: isActive ? 'bold' : 'normal',
color: isActive ? 'tomato' : 'inherit',
})}
>
홈
</NavLink>
{' | '}
<NavLink
to="/about"
style={({ isActive }) => ({
fontWeight: isActive ? 'bold' : 'normal',
color: isActive ? 'tomato' : 'inherit',
})}
>
소개
</NavLink>
</nav>
);
}style(또는 className)에 함수를 전달하면 isActive 정보를 받아 분기할 수 있습니다. end prop은 "정확히 이 경로일 때만 활성"이라는 뜻으로, / 같은 루트 경로에서 자주 사용합니다 (안 붙이면 모든 하위 경로에서도 활성으로 잡힘).
직접 해보기
지금까지 배운 것들을 종합해 작은 미니 사이트를 만들어봅시다. 홈, 소개, 사용자 목록, 사용자 상세 4개 페이지가 있습니다.
src/Layout.jsx:
import { NavLink, Outlet } from 'react-router-dom';
function Layout() {
const linkStyle = ({ isActive }) => ({
fontWeight: isActive ? 'bold' : 'normal',
color: isActive ? 'tomato' : 'inherit',
marginRight: '12px',
});
return (
<div>
<header style={{ padding: '12px', background: '#f4f4f4', borderBottom: '1px solid #ccc' }}>
<NavLink to="/" end style={linkStyle}>홈</NavLink>
<NavLink to="/about" style={linkStyle}>소개</NavLink>
<NavLink to="/users" style={linkStyle}>사용자</NavLink>
</header>
<main style={{ padding: '16px' }}>
<Outlet />
</main>
</div>
);
}
export default Layout;src/pages/Home.jsx:
function Home() {
return (
<div>
<h1>홈</h1>
<p>리액트 라우터 미니 사이트입니다.</p>
</div>
);
}
export default Home;src/pages/About.jsx:
function About() {
return (
<div>
<h1>소개</h1>
<p>이 시리즈의 마지막 글에서 만든 예제입니다.</p>
</div>
);
}
export default About;src/pages/UserList.jsx:
import { Link } from 'react-router-dom';
const USERS = [
{ id: 1, name: '철수' },
{ id: 2, name: '영희' },
{ id: 3, name: '민수' },
];
function UserList() {
return (
<div>
<h1>사용자 목록</h1>
<ul>
{USERS.map(user => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.name}</Link>
</li>
))}
</ul>
</div>
);
}
export default UserList;src/pages/UserDetail.jsx:
import { useParams, useNavigate } from 'react-router-dom';
const USERS = {
1: { name: '철수', email: 'cheolsu@example.com' },
2: { name: '영희', email: 'younghee@example.com' },
3: { name: '민수', email: 'minsu@example.com' },
};
function UserDetail() {
const { userId } = useParams();
const navigate = useNavigate();
const user = USERS[userId];
if (!user) {
return (
<div>
<h1>사용자를 찾을 수 없습니다.</h1>
<button onClick={() => navigate('/users')}>목록으로</button>
</div>
);
}
return (
<div>
<h1>{user.name}</h1>
<p>이메일: {user.email}</p>
<button onClick={() => navigate(-1)}>뒤로</button>
</div>
);
}
export default UserDetail;src/pages/NotFound.jsx:
import { Link } from 'react-router-dom';
function NotFound() {
return (
<div>
<h1>404 — 페이지를 찾을 수 없습니다</h1>
<Link to="/">홈으로 돌아가기</Link>
</div>
);
}
export default NotFound;src/App.jsx:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Layout from './Layout';
import Home from './pages/Home';
import About from './pages/About';
import UserList from './pages/UserList';
import UserDetail from './pages/UserDetail';
import NotFound from './pages/NotFound';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<UserList />} />
<Route path="users/:userId" element={<UserDetail />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;저장하고 브라우저에서 확인해보세요.
- 헤더 메뉴를 클릭하면 새로고침 없이 화면이 바뀝니다
- 현재 페이지의 링크는 굵은 글씨에 토마토색
- 사용자 목록에서 이름 클릭 → 동적 URL(
/users/1)로 이동 → 상세 페이지 - "뒤로" 버튼을 누르면 브라우저 뒤로 가기
- 주소창에
/존재하지않는경로를 직접 입력 → 404 페이지
지금까지 시리즈 내내 배운 거의 모든 것이 한 사이트에 들어 있습니다. 컴포넌트 분리, props, useState, 이벤트 처리, 조건부/리스트 렌더링까지요.
마무리 — 그리고 시리즈를 마치며
이번 글에서는 라우팅을 다뤘습니다. 정리하면:
- SPA는 화면 전환을 클라이언트에서 처리한다 → 라우팅 라이브러리가 필요
- React Router 핵심:
BrowserRouter,Routes,Route,Link/NavLink - 동적 경로(
:param)와useParams - 프로그래매틱 이동:
useNavigate - 쿼리 파라미터:
useSearchParams - 공통 레이아웃: 중첩 라우트 +
<Outlet /> path="*"로 404 처리
이로써 **리액트 기초 강좌 시리즈(#1~#15)**가 모두 끝났습니다. 처음에 "리액트가 뭐예요?"에서 시작해서 이제는 라우팅이 있는 작은 SPA를 직접 만들 수 있는 곳까지 왔습니다. 시리즈에서 다룬 것들을 한 번 돌아보면:
- #1~3 기초 단단히 — 리액트의 정체, 환경 설정, JSX
- #4~8 핵심 빌딩 블록 — 컴포넌트/props, state, 이벤트, 조건부/리스트 렌더링
- #9~12 실전 패턴 — 폼, useEffect, 상태 끌어올리기, Context
- #13~15 마무리 — 커스텀 훅, 성능 최적화, 라우팅
여기서 배운 내용은 어떤 React 기반 프레임워크(Next.js, Remix 등)를 쓰든 똑같이 통하는 펀더멘털입니다. 프레임워크는 그 위에 라우팅, 데이터 페칭, SSR 같은 부가 기능을 얹은 것일 뿐이에요.
다음 단계로 가실 분들에게 추천드리는 길:
- 모던 리액트 19 + Next.js 시리즈 (예정) — Server Components,
use(), Actions, Suspense 같은 최신 모델 - 실전 빌드 시리즈 (예정) — Todo 앱, 블로그, 쇼핑몰 같은 작은 프로젝트로 종합 연습
- 직접 만들고 싶은 작은 프로젝트(개인 메모장, 운동 기록기 등)를 시작해서 막힐 때마다 공식 문서를 찾아 읽기
여기까지 읽어주셔서 감사합니다. 작은 컴포넌트 하나하나가 모여 큰 앱이 되는 것처럼, 한 글 한 글 차근차근 따라오신 분이라면 이미 본인의 첫 리액트 앱을 충분히 만들 수 있는 분일 것입니다. 즐거운 리액트 여정 되세요!