#10. React-Router: SPA에서 화면만 갈아끼우기
목차13
TL;DR
- React Router는 SPA에서 새로고침 없이, URL에 따라 화면만 갈아끼우는 라이브러리다.
- 골격:
<BrowserRouter>로 앱을 감싸고,<Routes>안에서<Route path element>로 URL ↔ 컴포넌트 매핑 - 이동:
<Link>(유저 클릭) /useNavigate()(코드가 이동시킴). - 중첩 라우트:
<Outlet/>으로 자식 렌더 위치 표시, - 동적 경로:
:id는useParams()
개요
React는 기본적으로 “Single Page Application (SPA)“를 지원한다고 하였다.
근데 SPA에서 “페이지”란 게 좀 이상하다. 새로고침도 없고, HTML 문서도 하나뿐인데 무슨 페이지 이동이란 말인가?
게다가 문제가 하나 생기는데, 사용자가 보는 화면은 바뀌는데 페이지는 그대로라면 URL도 그대로인 것일까?
뒤로가기도, 북마크도, 링크 공유도 의미가 없어지지 않나?
그래서 필요한 것이 URL과 보여지는 화면의 동기화이고, 이를 위해 React Router가 있다.
화면만 바꾸는 게 아니라, “지금 어떤 화면인지”를 URL와 매핑하는 것
이번에도 Compose의 Navigation을 이미 알고 있어서 대부분 그대로 매핑됐다.
설치와 기본 골격
설치
npm install react-router-dom@6
1. BrowserRouter로 감싸기 (main.jsx)
앱 전체가 라우팅의 지배를 받아야 하니, 최상단을 <BrowserRouter>로 감싼다.
import { BrowserRouter } from 'react-router-dom';
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
지배라는 표현이 잘 와닿지 않는다면, <BorwserRouter>로 <App>을 감쌌으니, 이제 App은 이 웹사이트의 가장 큰 컴포넌트가 되었다고 봐도 될 것 같다.
이제 App이라는, 전체 화면을 관장하는 단일 페이지 안에서, URL에 따라서 개별 화면들(컴포넌트들)이 배치될 것이다.
2. Routes / Route (App.jsx)
<Routes>는 경로와 띄울 컴포넌트(화면)들의 쌍을 정의하는 공간이며,
<Route>로 개별 경로(path)와 띄울 요소(element)를 정의한다.
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/detail" element={<DetailPage />} />
</Routes>
여기서 element가 받는 건 JSX element다.
즉 예시의 element={<HomePage />}처럼 실제로 그려질 컴포넌트를 넣는다.
Compose로 치면 아래와 같다:
NavHost(navController, startDestination = "home") {
composable("home") { HomePage() }
composable("detail") { DetailPage() }
}
<Routes> = NavHost, <Route> = composable.
화면 이동: Link vs useNavigate
화면을 이동시키는 방법은 두 가지인데, “누가 이동을 트리거하느냐” 로 구분해볼 수 있다.
1. <Link> : 유저의 클릭에 반응
유저가 눌러서 이동하는 경우로, 아래와 같이 사용한다:
<Link to="/detail">상세 보기</Link>
그런데 생각해보면, 이미 우리는 HTML에 <a> 태그가 존재한다는 사실을 알고 있다.
Q. 그냥
<a href="/detail">쓰면 되는거 아님?
결정적으로, 둘은 동작 방식이 다르다.
화면상에서, <Link>는 겉으론 <a>처럼 보이지만 새로고침 없이 History API로 URL만 바꾸고 약속된 컴포넌트로 갈아끼울 뿐이다.
조금 더 구체적인 설명을 위해서는 “History API”와 여기서 제공하는 pushState를 알아야한다.
History API는 브라우저에서 제공하는 Web API이다.
사실, React-router의 대부분의 기능이 이 History API의 기능을 끌어와서 사용한다.
그리고 <Link>에서 사용하는 대표적인 함수가 바로 pushState이다.
pushState가 하는 역할은 단순하다:
- 현재 URL을 히스토리 스택(=방문 기록)에 쌓는다.
- 그리고 주어진 경로(여기서는 “/detail”)로 주소창을 바꾼다.
그리고 여기서 <a>태그와 결정적인 차이가 발생한다.
<a>태그는, 브라우저에게 href의 해당 문서 전체를 새로 받아와서 로드하도록 지시한다.
예를들어 <a href="/detail">는, “브라우저가 서버로부터 /detail 경로의 문서를 통째로 새로 받아오는” 방식으로 동작한다.
이는 멀쩡히 구동중인 현재 React 앱을 통째로 버린다는 것을 의미하므로,
우리가 애써 기억하려던 모든 State가 날아가게 된다.
그러나 React Router에서 제공하는 <Link>는 History API의 pushState만 호출한다.
history.pushState(null, "", "/detail");
앞서 언급했듯이, pushState는 단순히 히스토리에 쌓고 주소창의 URL을 바꿀 뿐, 서버로부터 뭔가를 받아와서 새로 로드하라는 명령을 내리지 않는다.
따라서 현재 URL은 방문 기록에 쌓이고, 주소창도 “/detail”로 바뀌지만, 서버에게 “/detail” 문서를 요청하진 않는다.
대신, 우리가 작성한 JS(=React Router)가 /detail 페이지에서 보여줘야 할 컴포넌트로 ‘전환’시킬 뿐이다.
즉, React Router는 History API를 이용하여 브라우저가 관리할 항목(어느 페이지(url)를 방문했는가?)과,
실제 페이지의 전환(React-Router 및 JSX)의 책임 주체를 명확히 분리해둔 것이다.
// 물론 사용자가 F5를 누르거나, 주소창에 손수 입력할 경우에는 서버로 요청이 전송된다.
// 때문에 SPA에서, 모든 경로들은 다시 index.html에서부터 타고 내려가도록 되돌려주는 과정이 필요하다.
2. useNavigate
버튼을 누르거나, 폼 제출이 끝나거나, 로그인이 성공한 “어떤 일이 벌어진 뒤” 코드로 이동시킬 때 쓴다.
사용자의 의지와는 상관 없이, 코드의 실행에 의해 화면을 바꿀 때 사용한다.
let navigate = useNavigate();
return (
<button onClick={() => navigate("/detail")}>주문하고 이동</button>
);
숫자를 넘기면 브라우저 히스토리를 타고 움직인다.:
navigate(-1): 뒤로가기 (브라우저의 “뒤로가기”)navigate(1): 앞으로가기
역시 내부적으로는 History API에서 제공하는 back, forward 함수를 사용한다.
여기서 유용한 옵션으로, { replace: true }가 있다:
navigate("/home", { replace: true });
히스토리에 새 항목을 쌓는(push) 대신, 현재 항목을 갈아치운다(replace).
예를들어, 회원가입이나 로그인 성공 후에는 이전 내역들을 기억할 필요 없이 사용자를 홈으로 바로 보내버리면 된다.
이때 항목 쌓기 없이 바로 현재 화면을 바꿔버리면, 유저가 뒤로가기를 눌러도 로그인 페이지로 안 돌아간다.
Compose의 navController.navigate(...) / popBackStack()과 같은 결이다.
특수한 경로들
1. 404 페이지 (path="*")
어떤 규칙에도 안 맞는 URL을 받는 fallback이다. path="*"(와일드카드)를 사용한다.
<Route path="*" element={<NotFound />} />
2. 중첩 라우트 (Nested Routes) + <Outlet/>
라우트도 계층으로 겹칠 수 있다. 예를들어, /about 안에 /about/members를 두는 식이다.
<Route path="/about" element={<About />}>
<Route path="members" element={<Members />} /> {/* → /about/members */}
</Route>
여기서, 자식의 path는 /로 시작하지 않는 상대경로(members)임을 확인.
여기서 중요한 건, /about/members로 갔을 때 <Members/>가 어디에 그려지느냐일 것이다.
부모인 <About/> 안에 <Outlet/> 을 심어두면, 그 자리에 자식이 끼워진다.
function About() {
return (
<div>
<h2>소개 페이지 공통 UI</h2>
<Outlet /> ← 여기에 <Members/>가 들어옴
</div>
);
}
공통 레이아웃(네비바·사이드바)을 부모에 두고, 바뀌는 부분만 <Outlet/>으로 교체하는 패턴에 쓴다.
동적 파라미터: :id와 useParams
상품마다 URL이 다른데(/detail/0, /detail/1 …) 라우트를 하나하나 만들 순 없다.
따라서 URL의 일부를 변수(:id)로 비워둔다 — 이를 동적 파라미터 방식이라 한다.
<Route path="/detail/:id" element={<DetailPage />} />
자식 컴포넌트에서 사용해야할 때, useParams()로 꺼내 읽을 수 있다.
function DetailPage() {
const { id } = useParams();
// ...
}
별도의 props를 통해 받지 않고, 바로 useParams()로 꺼내썼음에 유의.
이때 useParams로 받아온 값은 그 값과 상관 없이 항상 문자열이므로,
상품 id와 같이 숫자로 취급해야 할 때는 적절한 형변환이 필요하다.
Compose의 composable("detail/{id}") + backStackEntry.arguments와 대응하는데,
Compose는 NavType.IntType등으로 받아올 타입을 명시할 수 있었던 반면,
React Router는 일단 문자열로 받고 캐스팅이 필요하다는 점이 다르다.
