다크 모드
Chapter 04.5. 라우터 맛보기: React Router로 화면을 바꾸고 URL에 상태 담기
"jQuery 시절에는 화면마다 HTML 파일이 따로 있었고, 검색 조건은
location.search를 직접 잘라서 읽었다. React에서는 URL 쿼리스트링도 상태처럼 읽고 바꿀 수 있으며, 새로고침과 링크 공유에도 검색 조건이 그대로 살아남는다."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| SPA 라우팅과 History API | HTML 파일 하나로, URL에 따라 자바스크립트가 컴포넌트를 바꿔 끼우기 | 4.5.1 |
<BrowserRouter>, <Routes>, <Route> | # 없는 깔끔한 URL 라우터와 "이 주소면 이 컴포넌트" 매핑 표 | 4.5.2 |
<Link>, <NavLink> | 페이지 새로고침 없이 URL만 바꾸는 링크와 활성 메뉴 스타일링 | 4.5.3 |
useLocation(), useSearchParams() | 현재 URL 경로(pathname)와 쿼리스트링(?keyword=React) 읽기/수정하기 | 4.5.4 (5장 usePostFilter의 핵심) |
useNavigate()와 replace: true | 히스토리를 쌓으며 이동(push) vs 현재 기록을 덮어쓰기(replace) | 4.5.5 |
인증 보호 라우트 <ProtectedRoute> | 로그인하지 않은 사용자를 /login으로 돌려보내는 검문소 | 11장에서 자세히 |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 4장의 코드 4-1과 같은 상태에서 시작합니다(막혔다면 4장의 코드 4-1을 다시 복사해 오십시오). 이 장에서 손대는 파일: frontend/src/BoardApp.tsx(4.5.6 임시 실습 후 장 끝에서 되돌림). 읽기만 하는 파일: frontend/src/App.tsx, frontend/src/main.tsx
4.5.1 왜 검색 조건과 페이지 번호를 URL에 담아야 하는가? (SPA 라우팅)
jQuery/JSP 시절의 사내 시스템은 보통 화면마다 파일이 따로 있었습니다. list.jsp에서 write.jsp로 가면 브라우저가 페이지 전체를 새로 받아 그리고, 그 사이 자바스크립트 변수는 모두 사라졌습니다.
**SPA(Single Page Application)**는 index.html을 한 번만 받습니다. 그 뒤로는 링크를 눌러도 서버에서 새 HTML을 받지 않고, 자바스크립트가 브라우저의 History API로 주소창의 URL만 바꾼 뒤 그 URL에 맞는 컴포넌트를 화면에 바꿔 끼웁니다. React 생태계에서 이 "URL ↔ 컴포넌트" 연결을 맡는 표준 라이브러리가 **React Router (react-router-dom)**입니다.
그런데 많은 초보자가 검색어와 카테고리를 컴포넌트 내부 useState에만 보관합니다:
tsx
const [selectedCategory, setSelectedCategory] = useState('ALL');
const [searchKeyword, setSearchKeyword] = useState('');이렇게 하면 두 가지 치명적인 실무 문제가 발생합니다:
- F5(새로고침)를 누르면 초기화: 열심히
'DEV'카테고리에'Spring'을 검색해 결과를 보고 있었는데, 새로고침을 누르는 순간 검색 조건이 모두 날아가 전체 목록으로 돌아갑니다. - 동료에게 링크 공유 불가: 사내 메신저로
http://localhost:3000주소를 복사해 주면, 동료 브라우저에는 내가 보던 검색 결과가 아니라 초기 전체 목록이 뜹니다.
이 장에서 라우터를 배우는 핵심 이유는 화면 전환뿐 아니라 검색 조건을 /?keyword=React&category=DEV처럼 URL에 담아 새로고침·링크 공유·[뒤로 가기]에도 살아남게 만드는 것입니다. 5장의 usePostFilter가 바로 이 일을 합니다.
4.5.2 이미 들어 있는 라우터 구조 읽기
실습 환경에는 React Router가 처음부터 설치되어 있습니다. 새로 설치할 것은 없고, 어디에 무엇이 있는지만 확인합니다.
① src/main.tsx 또는 src/App.tsx: <BrowserRouter>로 앱 감싸기
tsx
// frontend/src/App.tsx (핵심 구조 요약)
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
import BoardApp from './BoardApp';
import LoginView from './views/LoginView';
export default function App() {
return (
<BrowserRouter>
<header className="app-header">
<nav className="app-nav">
<NavLink to="/" end>목록</NavLink> |{' '}
<NavLink to="/write">글쓰기</NavLink>
</nav>
</header>
<ErrorBoundary>
{/* 현재 URL과 매칭된 <Route>의 element가 이 자리에 그려진다 */}
<Routes>
<Route path="/" element={<BoardApp />} />
<Route path="/write" element={<ProtectedRoute><BoardApp /></ProtectedRoute>} />
<Route path="/login" element={<LoginView />} />
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}② 라우트 매핑 표 (<Routes> 내부)
path | 렌더링되는 element | 비고 |
|---|---|---|
/ | <BoardApp /> | 지금까지 우리가 만든 게시판 홈 |
/write | <ProtectedRoute><BoardApp /></ProtectedRoute> | 로그인 필요 (미로그인 시 /login으로 이동, 11장) |
/login | <LoginView /> | 로그인 화면 (11장) |
WARNING
<BrowserRouter>와 운영 서버 새로고침 404 주의<BrowserRouter>는 # 기호 없는 깔끔한 주소(/write, /login)를 만듭니다. 그런데 사용자가 /write 주소에서 브라우저 새로고침(F5)을 누르면, 브라우저는 서버에 /write라는 실제 파일을 달라고 요청합니다. 개발 중에는 Vite 개발 서버가 모든 경로에 index.html을 돌려주므로 문제가 없지만, 운영 서버(Nginx 등)는 따로 설정하지 않으면 404 Not Found를 냅니다. 모든 경로에 index.html을 내려주는 설정은 부록 A의 Nginx try_files $uri $uri/ /index.html;에서 다룹니다.
4.5.3 <Routes>와 <Link> / <NavLink>
<Routes>와<Route>: URL이/이면<BoardApp />,/login이면<LoginView />가 그 자리에 그려집니다. jQuery로 치면$('#content').load(...)로 영역만 갈아 끼우던 자리입니다. (ErrorBoundary는 12장에서 설명합니다. 지우지 마십시오.)<Link to="/">: 화면에는<a href="/">로 그려지지만, 클릭하면 서버에서 페이지를 새로 받지 않고 URL만 바꿉니다. 일반<a href="/">를 쓰면 브라우저 전체가 새로고침되어 React 메모리 상태가 모두 증발합니다!<NavLink to="/" end>:<Link>와 같지만 현재 URL과 일치할 때 자동으로active클래스를 붙여 줍니다. 그래서 지금 보고 있는 메뉴가 굵은 글씨로 표시됩니다. 일치 여부는 경로(pathname)만 보고 쿼리스트링(?keyword=...)은 무시하므로,/?keyword=React에서도 [목록] 메뉴는 굵게 유지됩니다.
💡 [확인하기]: 상단 헤더의 [글쓰기]를 눌러 보십시오. 글쓰기 화면이 아니라 로그인 화면이 나오고 주소도
/login?redirect=%2Fwrite로 바뀝니다.<ProtectedRoute>검문소가 로그인하지 않은 사용자를 돌려보낸 것입니다. 가드와 로그인은 11장에서 다루므로 지금은 [목록]을 눌러/로 돌아오십시오.
4.5.4 useLocation()과 useSearchParams(): 지금 URL 읽고 쿼리 다루기
컴포넌트 안에서 현재 URL 정보를 읽을 때는 두 가지 훅을 씁니다.
tsx
import { useLocation, useSearchParams } from 'react-router-dom';
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();| 훅 / 메서드 | /?keyword=React&page=2일 때 결과 | 설명 |
|---|---|---|
location.pathname | '/' | 쿼리를 뺀 현재 경로 |
location.search | '?keyword=React&page=2' | ?를 포함한 원본 쿼리스트링 문자열 |
searchParams.get('keyword') | 'React' | 해당 쿼리 파라미터 값 (없으면 null) |
searchParams.get('category') | null | 없는 키를 조회하면 null 반환 (?? 'ALL'로 기본값 처리) |
Object.fromEntries(searchParams) | { keyword: 'React', page: '2' } | 모든 쿼리를 한눈에 일반 객체로 변환 |
IMPORTANT
쿼리에서 읽은 값은 항상 문자열(string | null)입니다!
주소창의 page=2를 searchParams.get('page')로 읽으면 숫자 2가 아니라 문자열 '2'입니다(파라미터가 아예 없으면 null). 그래서 변환 없이 searchParams.get('page') + 1을 하면 3이 아니라 '21'이 됩니다! 숫자로 쓰려면 반드시 Number(searchParams.get('page')) || 1처럼 숫자로 변환하십시오.
4.5.5 URL 바꾸기: push(기본) vs replace: true
URL을 코드로 바꿀 때는 ① 쿼리만 바꿀 때 쓰는 setSearchParams(새쿼리, 옵션)과 ② 다른 경로로 이동할 때 쓰는 const navigate = useNavigate() 두 가지가 있습니다. 둘 다 히스토리를 다루는 방식은 같습니다.
| 방식 | 브라우저 히스토리 동작 | 주로 쓰는 곳 |
|---|---|---|
기본 (push)navigate('/login')setSearchParams(nextParams) | 방문 기록에 새 항목을 차곡차곡 쌓음 → [뒤로 가기]로 직전 URL 복귀 | 메뉴 이동, 상세 화면 진입 (<Link>도 기본이 push) |
교체 (replace: true)navigate('/', { replace: true })setSearchParams(nextParams, { replace: true }) | 현재 방문 기록 항목을 덮어써서 바꿔치기 → 히스토리가 늘어나지 않음 | 검색 조건·필터 변경, 로그인 완료 후 리다이렉트 |
tsx
// 현재 쿼리를 복사한 뒤 category만 'DEV'로 덮어쓰기 (replace: true)
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('category', 'DEV');
return next;
},
{ replace: true }
);검색할 때마다 push를 쓰면 사용자가 5번 검색했을 때 [뒤로 가기]를 5번 눌러야 게시판을 벗어날 수 있습니다. 그래서 필터 변경에는 보통 { replace: true }를 씁니다.
📞 비유: 일반 통화 기록(
push) vs 007 비밀 요원의 덮어쓰기(replace: true)
push(일반 통화): 전화를 걸 때마다 스마트폰 통화 목록에 차곡차곡 쌓입니다. [뒤로 가기]를 누르면 직전 기록이 나옵니다.replace: true(비밀 통화): 방금 건 통화 기록을 지우고 현재 위치를 그 자리로 바꿔치기합니다.
만약 로그인 완료 화면이나 결제 완료 화면으로 이동할 때replace: true를 안 쓰고 기본push를 쓰면 어떻게 될까요? 사용자가 무심코 브라우저 [뒤로 가기]를 눌렀을 때 **"이미 끝난 결제 주문서"나 "이미 로그인했는데 로그인 창"**으로 되돌아가서 개발팀에 식은땀을 선사합니다!
4.5.6 [실습] 쿼리를 눈으로 확인하기 (임시 실험)
BoardApp.tsx에 현재 URL 경로와 쿼리를 보여 주는 작은 실험 상자를 잠시 붙여 봅니다. 장 끝에서 지웁니다.
[코드 4.5-1] 임시 실습 코드 (frontend/src/BoardApp.tsx에 추가)
tsx
// ① 파일 상단 import 영역에 한 줄 추가
import { Link, useLocation, useSearchParams } from 'react-router-dom';
// ② BoardApp 컴포넌트 안(useEffect 줄 위)에 추가
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
function replaceCategoryToDev() {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
next.set('category', 'DEV');
return next;
},
{ replace: true }
);
}tsx
{/* ③ JSX: 페이지네이션 <div className="pagination">...</div> 바로 아래, 가장 바깥 </div> 앞에 추가 */}
<div
className="router-lab"
style={{ marginTop: '20px', padding: '12px', background: '#f1f5f9', borderRadius: '8px' }}
>
<p>pathname: {location.pathname}</p>
<pre>query: {JSON.stringify(Object.fromEntries(searchParams))}</pre>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Link to="/?keyword=React">keyword=React로 이동(push)</Link>
<button type="button" onClick={replaceCategoryToDev}>
category=DEV로 교체(replace)
</button>
</div>
</div>따라 해 보기
http://localhost:3000/을 열면pathname: /,query: {}가 보입니다. 확인용으로 등록 폼의 [제목] 칸에 아무 글자나 적어 두십시오.- [keyword=React로 이동(push)] 링크를 누릅니다. 주소창이
/?keyword=React로 바뀌고 화면의query에{"keyword":"React"}가 나타납니다. [제목] 칸에 적어 둔 글자가 그대로 남아 있다면 페이지가 새로고침되지 않았다는 증거입니다! - 브라우저 **[뒤로 가기]**를 누르면
/,query: {}로 돌아가고, **[앞으로 가기]**를 누르면 다시/?keyword=React가 됩니다.<Link>의 기본 동작인push가 히스토리를 쌓았기 때문입니다. /?keyword=React상태에서 [category=DEV로 교체(replace)] 버튼을 누르면 주소가/?keyword=React&category=DEV가 됩니다. 이제 브라우저 **[뒤로 가기]**를 누르면/?keyword=React가 아니라 곧바로/로 돌아갑니다!replace: true가/?keyword=React항목을 덮어써서 그 항목이 히스토리에 남아 있지 않기 때문입니다.
NOTE
URL은 바뀌었는데 게시글 목록과 검색 입력창은 그대로입니다!
2번에서 주소는 /?keyword=React가 되었지만 게시글 목록도, 위쪽 검색 입력창도 바뀌지 않았습니다. 같은 / 경로 안에서 쿼리스트링만 바뀌면 React Router는 BoardApp을 언마운트하지 않고 그대로 리렌더링하며(그래서 [제목] 칸 글자도 남아 있었습니다), 코드 4-1에는 아직 searchParams를 읽어 검색 상태와 맞추는 코드가 없기 때문입니다. "URL이 바뀌면 검색 조건을 맞추고, 검색하면 URL을 바꾸는" 양방향 연결은 5장의 usePostFilter 커스텀 훅이 완벽하게 해결합니다.
되돌리기: 확인이 끝나면 ①②③에서 추가한 임시 줄을 모두 지워 BoardApp.tsx를 4장의 코드 4-1 상태로 되돌리십시오. 5장은 코드 4-1에서 시작합니다.
4.5.7 [직접 해보기] 실습 미션 💪
미션: 쿼리 파라미터에서 특정 키를 깔끔하게 삭제(delete)하는 함수 작성하기
5장의 usePostFilter에서는 사용자가 카테고리를 'ALL'(전체)로 바꾸거나 검색어를 빈 문자열('')로 지웠을 때, URL에 ?category=ALL&keyword=처럼 지저분한 파라미터를 남기지 않고 아예 URL에서 해당 키를 제거합니다.URLSearchParams 객체의 .set(key, val)과 .delete(key)를 사용하여, newKeyword가 빈 문자열이면 'keyword' 파라미터를 지우고, 값이 있으면 세팅하는 코드를 작성해 보십시오.
💡 미션 정답 코드 확인하기
tsx
function updateKeywordQuery(newKeyword: string) {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (newKeyword.trim()) {
next.set('keyword', newKeyword.trim());
} else {
next.delete('keyword'); // 빈 문자열이면 URL에서 ?keyword= 자체를 깔끔하게 제거!
}
next.set('page', '1');
return next;
},
{ replace: true }
);
}4.5.8 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄 요약
- SPA는 HTML을 한 번만 받고, React Router가 URL에 맞는 컴포넌트를
<Routes>자리에 바꿔 끼웁니다.<Link>와<NavLink>는 새로고침 없이 URL만 바꿉니다. useSearchParams()로 현재 쿼리스트링(?keyword=...)을 반응형으로 읽고 수정하며, 히스토리를 쌓을 때는 기본(push), 검색 필터처럼 현재 기록을 덮어쓸 때는{ replace: true }를 씁니다.searchParams.get(...)이 돌려주는 값은 항상 문자열(string) 또는null이므로, 숫자로 쓸 때는Number(...)변환과 기본값 처리를 챙깁니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
frontend/src/BoardApp.tsx | 4.5.6 임시 실습 후 되돌림 → 변경 없음(코드 4-1 상태 유지) |
자주 하는 실수 (Troubleshooting)
| 증상 | 원인 | 해결 |
|---|---|---|
[글쓰기]를 누르면 로그인 화면(/login)으로 튕김 | <ProtectedRoute> 가드가 미로그인 사용자를 돌려보냄 | 정상 동작. 11장에서 자세히 다룸 |
<a href="/?keyword=React">를 썼더니 화면 전체가 깜빡이며 입력폼이 초기화됨 | 일반 <a> 태그는 브라우저 전체 새로고침을 유발함 | React Router의 <Link to="/?keyword=React"> 사용 |
searchParams.get('page') + 1이 '21'이 됨 | 쿼리 파라미터 값은 항상 문자열임 | `(Number(searchParams.get('page')) |
운영 서버(Nginx)에서 /write 새로고침 시 404 에러 | 브라우저가 서버에 /write 파일을 요청함 | 부록 A의 Nginx try_files 설정 적용 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
/?keyword=React주소에서location.pathname과searchParams.get('keyword')는 각각 무엇인가요?/→ (push)/?keyword=React→ (replace: true)/?keyword=React&category=DEV순서로 이동한 뒤 브라우저 [뒤로 가기]를 한 번 누르면 주소는 어디인가요?- 4.5.6 실습 2번에서 주소가
/?keyword=React로 바뀌었는데 게시글 목록이 그대로였던 이유는 무엇인가요? - 검색 조건을 바꿀 때 기본
push보다replace: true를 쓰는 이유는 무엇인가요?
셀프 체크 정답
location.pathname은'/',searchParams.get('keyword')는 문자열'React'입니다. 쿼리스트링은pathname에 포함되지 않습니다./입니다.replace: true가 직전 항목인/?keyword=React를/?keyword=React&category=DEV로 덮어썼으므로, 그 앞 기록인/로 바로 돌아갑니다.- 같은
/경로에서 쿼리만 바뀌면BoardApp컴포넌트가 언마운트되지 않고 유지되며, 코드 4-1에는 아직searchParams변화를 감지해loadPosts()를 다시 부르는 코드가 없기 때문입니다. push를 쓰면 검색할 때마다 브라우저 히스토리가 쌓여, 사용자가 이전 화면으로 돌아가려 할 때 검색한 횟수만큼 [뒤로 가기]를 연타해야 하기 때문입니다.
다음 챕터로 이어지는 질문
이제 URL 쿼리를 읽고(useSearchParams) 덮어쓰는(replace: true) 방법을 완벽히 익혔습니다. 남은 일은 4장의 Fat Component 문제로 돌아가는 것입니다. 페이징·검색·폼 검증 로직을 BoardApp.tsx에서 도려내되, 검색 조건은 방금 배운 방식으로 URL과 양방향 동기화하려면 코드를 어떻게 설계해야 할까요?
이어지는 Chapter 05에서는 이 세 가지 비즈니스 로직을 **Custom Hook (usePagination, usePostFilter, useFormValidation)**으로 도려내고, 화면 없이 0.1초 만에 검증하는 첫 번째 Vitest 단위 테스트를 작성합니다!