목록·상세 라우트
홈과 게시물 목록·상세 화면을 URL 파라미터 및 중첩 라우트로 구성합니다.
앞 글 간단한 다중 경로 SPA 만들기에서 블로그의 URL 규칙과 공통 파일을 준비했습니다. 이 글에서는 각 라우트에 해당하는 화면 컴포넌트 가운데 홈과 게시글 목록, 게시글 상세를 만듭니다.
라우트 페이지 구성
HomePage.jsx (기본 라우팅)
홈 화면은 블로그를 소개하고 게시글 목록으로 가는 링크를 보여 줍니다. 상태나 데이터 없이 고정된 소개 문구만 그리므로, 이 페이지에서 볼 곳은 맨 아래의 Link 하나입니다. className="button"은 앞 글의 index.css에 정의한 클래스라서, 링크가 버튼 모양으로 보입니다.
import React from 'react';
import { Link } from 'react-router';
function HomePage() {
return (
<div className="text-center" style={{ padding: '40px 20px', backgroundColor: '#eaf7f5', borderRadius: '8px' }}>
<h2 style={{ color: '#2ecc71', fontSize: '2.5em', marginBottom: '15px' }}>환영합니다!</h2>
<p style={{ fontSize: '1.2em', color: '#555', marginBottom: '30px' }}>
React Router 기초 실습을 위한 간단한 블로그입니다.
</p>
<Link to="/posts" className="button">게시글 보러 가기</Link>
</div>
);
}
export default HomePage;링크를 누르면 문서를 새로 받지 않고 클라이언트 라우팅으로 /posts에 이동하며, 그 경로와 맞는 PostListPage가 화면에 들어옵니다.
PostListPage.jsx (쿼리 문자열)
게시글 목록을 보여 주고, 카테고리 필터에 useSearchParams를 씁니다. 쿼리 문자열의 값은 URL에서 온 문자열이므로 데이터에 실제로 있는 카테고리만 허용하고, 값이 없거나 허용 목록에 없으면 All로 해석합니다. setSearchParams를 호출하면 새 쿼리 문자열로 이동합니다.
import React from 'react';
import { Link, useSearchParams } from 'react-router';
// (가상 데이터) 블로그 게시글 목록
const mockPosts = [
{ id: '1', title: 'React Router 8.5 핵심 기능', content: 'React Router 8.5의 라우팅 기능들을 알아봅시다.', category: 'React', author: '김개발', date: '2024-05-01' },
{ id: '2', title: 'Hooks를 이용한 상태 관리', content: 'useState, useEffect를 활용한 상태 관리 예제.', category: 'React', author: '이코딩', date: '2024-05-05' },
{ id: '3', title: 'CSS-in-JS vs CSS Modules', content: '두 가지 스타일링 방식의 장단점 비교.', category: 'CSS', author: '박디자인', date: '2024-05-10' },
{ id: '4', title: '성능 최적화 기법', content: '메모이제이션과 코드 스플리팅.', category: 'Optimization', author: '최효율', date: '2024-05-12' },
{ id: '5', title: '자바스크립트 비동기 프로그래밍', content: 'Promise와 async/await의 이해.', category: 'JavaScript', author: '정논리', date: '2024-05-15' },
];
function PostListPage() {
const [searchParams, setSearchParams] = useSearchParams();
const allowedCategories = [...new Set(mockPosts.map(post => post.category))];
const requestedCategory = searchParams.get('category');
const currentCategory = allowedCategories.includes(requestedCategory)
? requestedCategory
: 'All';
const categories = ['All', ...allowedCategories];
const filteredPosts = currentCategory === 'All'
? mockPosts
: mockPosts.filter(post => post.category === currentCategory);
const handleCategoryChange = (category) => {
setSearchParams(category === 'All' ? {} : { category });
};
return (
<div>
<h2 className="text-center" style={{ marginBottom: '20px' }}>전체 게시글</h2>
<div className="text-center" style={{ marginBottom: '30px' }}>
{categories.map(category => (
<button
key={category}
onClick={() => handleCategoryChange(category)}
style={{
padding: '8px 15px',
margin: '0 5px',
borderRadius: '5px',
border: `1px solid ${currentCategory === category ? '#3498db' : '#ccc'}`,
backgroundColor: currentCategory === category ? '#e8f6fc' : 'white',
cursor: 'pointer',
fontWeight: currentCategory === category ? 'bold' : 'normal',
transition: 'all 0.2s ease',
}}
>
{category}
</button>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '20px' }}>
{filteredPosts.length > 0 ? (
filteredPosts.map(post => (
<div
key={post.id}
style={{
border: '1px solid #eee',
borderRadius: '8px',
padding: '20px',
backgroundColor: '#fefefe',
boxShadow: '0 2px 5px rgba(0,0,0,0.05)',
transition: 'transform 0.2s ease',
}}
>
<Link to={`/posts/${post.id}`} style={{ textDecoration: 'none', color: '#333' }}>
<h3 style={{ margin: '0 0 10px 0', color: '#3498db', fontSize: '1.3em' }}>{post.title}</h3>
</Link>
<p style={{ fontSize: '0.9em', color: '#777', margin: '0 0 10px 0' }}>작성자: {post.author} | 날짜: {post.date}</p>
<p style={{ fontSize: '1em', color: '#555', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{post.content.substring(0, 80)}...
</p>
<span style={{ fontSize: '0.8em', backgroundColor: '#e0e0e0', padding: '5px 10px', borderRadius: '15px', color: '#555' }}>
{post.category}
</span>
</div>
))
) : (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#888' }}>해당 카테고리의 게시글이 없습니다.</p>
)}
</div>
</div>
);
}
export default PostListPage;allowedCategories는 가상 데이터에서 겹치지 않게 모은 React, CSS, Optimization, JavaScript의 네 카테고리이고, 버튼은 여기에 All을 더해 다섯 개입니다. /posts?category=React는 다섯 게시글 가운데 ID 1과 2를 남깁니다. 이 목록의 setter는 쿼리 문자열 전체를 바꿉니다. 예를 들어 ?category=React&tab=spec에서 CSS 버튼을 누르면 새 쿼리는 ?category=CSS가 되고 tab은 지워지며, All을 누르면 쿼리가 모두 사라집니다. 라우트 파라미터와 쿼리 문자열의 복제를 쓰는 updateParam과 달리, 이 실습은 category 하나만 쓰기로 정했기 때문입니다.
PostDetailPage.jsx (중첩 라우팅 부모)
게시글의 상세 내용을 보여 주고, 아래쪽에 댓글 목록을 위한 중첩 라우트의 자리를 둡니다. :postId는 어떤 경로 조각이든 하나와 맞으므로, 컴포넌트가 양의 정수 형식과 조회 결과를 차례로 검증해야 합니다. 형식 오류와 존재하지 않는 게시글은 path="*"로 보내지 않고 이 동적 라우트가 각각 렌더링합니다. 정상적인 게시글일 때만 <Outlet />에 댓글 자식을 열고, 자식으로 가는 링크는 부모 라우트를 기준으로 한 상대 경로를 씁니다.
게시글 상세 입력의 두 실패 경계 — 상태 추적
postId abc/0/01의 형식 실패와 999 조회 실패, 1 정상 상세/Outlet은 검사 위치와 값이 다르다. 같은 입력/검증/조회/화면 역할로 단락을 보여 줄 가치가 있다.
BLOG ROUTES · INPUT GUARD
ID 형식 오류와 조회 실패는 상세 route가 구분합니다
동적 :postId 매칭은 데이터의 유효성을 보장하지 않습니다. PostDetailPage는 형식 검사 → 게시글 조회 → 정상 상세·Outlet 순서로 판단하며 두 실패에서는 조기에 반환합니다.
실제 세 입력 · 같은 동적 branch, 다른 화면
abc → 형식 오류 / 조회하지 않음
999 → 형식 정상 / 게시글 없음
1 → 게시글 존재 / 상세 + comments 자식세 입력 모두 상세 route가 소유합니다. 전혀 없는 route의 client wildcard나 host의 HTTP 상태와는 별개입니다.
1 · 먼저 ID 문자열의 형식
/^[1-9]\d*$/를 검사합니다. abc·0·01이면 “잘못된 게시글 주소”를 렌더하고 조회·Outlet으로 진행하지 않습니다.
2 · 형식이 맞으면 게시글 조회
mockPosts.find(item => item.id === postId)로 찾습니다. 999는 올바른 형식이지만 데이터가 없어 “게시글을 찾을 수 없습니다”를 렌더합니다.
3 · 정상 게시글에서만 상세와 자식
postId="1"이면 상세를 렌더하고 /posts/1/comments에서는 부모 Outlet에 댓글 2개가 함께 표시됩니다.
목록의 category 정책
React는 게시글 2개, 생략·미지원 값은 All로 전체 5개입니다. 목록 예제의 setter는 search 전체를 교체하므로 다른 key를 보존하는 ch6-3의 updateParam과 다릅니다.
클라이언트 검사와 서버 경계
이 실습의 mockPosts는 로컬 가상 데이터입니다. 실제 서버 조회에서는 입력 검증·접근 권한·오류 응답을 서버에서 다시 강제하며, 이 UI의 문구만으로 HTTP 상태를 설정하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 서로 독립적인 요청 A(abc·0·01의 형식 오류), B(999의 조회 실패), C(1의 정상 게시글)를 비교합니다. 두 실패는 조기 반환하고 ID가 자동으로 다음 값으로 바뀌지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | /^[1-9]\d*$/를 검사합니다. abc·0·01이면 “잘못된 게시글 주소”를 렌더하고 조회·Outlet으로 진행하지 않습니다. |
| 원도표 설명 | mockPosts.find(item => item.id === postId)로 찾습니다. 999는 올바른 형식이지만 데이터가 없어 “게시글을 찾을 수 없습니다”를 렌더합니다. |
| 원도표 설명 | postId="1"이면 상세를 렌더하고 /posts/1/comments에서는 부모 Outlet에 댓글 2개가 함께 표시됩니다. |
| 원도표 설명 | React는 게시글 2개, 생략·미지원 값은 All로 전체 5개입니다. 목록 예제의 setter는 search 전체를 교체하므로 다른 key를 보존하는 ch6-3의 updateParam과 다릅니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 A · 형식 오류 | 형식: /^[1-9]\d*$/ / 결과: 잘못된 주소 | postId가 abc·0·01이면 양의 정수 형식 검사에서 거절된다 | 동적 route 매칭과 ID의 형식 검사는 다르다 |
| 요청 B · 조회 실패 | ID: 999 / 결과: 게시글 없음 | 999는 형식을 통과하지만 mockPosts에 없다 | 조회 실패를 형식 오류와 구별한다 |
| 요청 C · 정상 상세 | ID: 1 / Outlet: 정상 분기만 | 1은 정상 게시글과 Outlet을 열어 comments 자식을 표시할 수 있다 | 실패 분기에서 댓글이 따로 표시되지는 않는다 |
게시글 상세 입력의 두 실패 경계 — 상태 추적
postId abc/0/01의 형식 실패와 999 조회 실패, 1 정상 상세/Outlet은 검사 위치와 값이 다르다. 같은 입력/검증/조회/화면 역할로 단락을 보여 줄 가치가 있다.
BLOG ROUTES · INPUT GUARD
ID 형식 오류와 조회 실패는 상세 route가 구분합니다
동적 :postId 매칭은 데이터의 유효성을 보장하지 않습니다. PostDetailPage는 형식 검사 → 게시글 조회 → 정상 상세·Outlet 순서로 판단하며 두 실패에서는 조기에 반환합니다.
실제 세 입력 · 같은 동적 branch, 다른 화면
abc → 형식 오류 / 조회하지 않음
999 → 형식 정상 / 게시글 없음
1 → 게시글 존재 / 상세 + comments 자식세 입력 모두 상세 route가 소유합니다. 전혀 없는 route의 client wildcard나 host의 HTTP 상태와는 별개입니다.
1 · 먼저 ID 문자열의 형식
/^[1-9]\d*$/를 검사합니다. abc·0·01이면 “잘못된 게시글 주소”를 렌더하고 조회·Outlet으로 진행하지 않습니다.
2 · 형식이 맞으면 게시글 조회
mockPosts.find(item => item.id === postId)로 찾습니다. 999는 올바른 형식이지만 데이터가 없어 “게시글을 찾을 수 없습니다”를 렌더합니다.
3 · 정상 게시글에서만 상세와 자식
postId="1"이면 상세를 렌더하고 /posts/1/comments에서는 부모 Outlet에 댓글 2개가 함께 표시됩니다.
목록의 category 정책
React는 게시글 2개, 생략·미지원 값은 All로 전체 5개입니다. 목록 예제의 setter는 search 전체를 교체하므로 다른 key를 보존하는 ch6-3의 updateParam과 다릅니다.
클라이언트 검사와 서버 경계
이 실습의 mockPosts는 로컬 가상 데이터입니다. 실제 서버 조회에서는 입력 검증·접근 권한·오류 응답을 서버에서 다시 강제하며, 이 UI의 문구만으로 HTTP 상태를 설정하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 서로 독립적인 요청 A(abc·0·01의 형식 오류), B(999의 조회 실패), C(1의 정상 게시글)를 비교합니다. 두 실패는 조기 반환하고 ID가 자동으로 다음 값으로 바뀌지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | /^[1-9]\d*$/를 검사합니다. abc·0·01이면 “잘못된 게시글 주소”를 렌더하고 조회·Outlet으로 진행하지 않습니다. |
| 원도표 설명 | mockPosts.find(item => item.id === postId)로 찾습니다. 999는 올바른 형식이지만 데이터가 없어 “게시글을 찾을 수 없습니다”를 렌더합니다. |
| 원도표 설명 | postId="1"이면 상세를 렌더하고 /posts/1/comments에서는 부모 Outlet에 댓글 2개가 함께 표시됩니다. |
| 원도표 설명 | React는 게시글 2개, 생략·미지원 값은 All로 전체 5개입니다. 목록 예제의 setter는 search 전체를 교체하므로 다른 key를 보존하는 ch6-3의 updateParam과 다릅니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 A · 형식 오류 | 형식: /^[1-9]\d*$/ / 결과: 잘못된 주소 | postId가 abc·0·01이면 양의 정수 형식 검사에서 거절된다 | 동적 route 매칭과 ID의 형식 검사는 다르다 |
| 요청 B · 조회 실패 | ID: 999 / 결과: 게시글 없음 | 999는 형식을 통과하지만 mockPosts에 없다 | 조회 실패를 형식 오류와 구별한다 |
| 요청 C · 정상 상세 | ID: 1 / Outlet: 정상 분기만 | 1은 정상 게시글과 Outlet을 열어 comments 자식을 표시할 수 있다 | 실패 분기에서 댓글이 따로 표시되지는 않는다 |
위 그림은 PostDetailPage가 ID의 형식 오류와 조회 실패를 어떻게 구분하는지 보여 줍니다. useParams로 읽은 postId는 경로에서 온 문자열이며, 먼저 /^[1-9]\d*$/로 양의 정수 형식인지 검사합니다. 형식이 틀리면 abc나 0, 01처럼 “잘못된 게시글 주소”를 보여 주고 일찍 반환합니다. 형식을 통과하면 mockPosts에서 item.id === postId로 조회하고, 없으면 999처럼 형식은 맞지만 “게시글을 찾을 수 없습니다”를 보여 줍니다. 게시글이 있을 때만 정상적인 상세와 Outlet을 렌더링하므로, 댓글 자식도 이 정상 분기에서만 보입니다.
그림 위쪽의 예처럼 abc는 형식 오류라서 조회하지 않고, 999는 형식은 맞지만 게시글이 없으며, 1은 게시글이 있어 상세와 댓글 자식을 함께 보여 줍니다. 그림의 설명처럼 세 입력 모두 같은 동적 가지에 맞고 상세 라우트가 처리하며, 아예 맞는 라우트가 없는 경우의 클라이언트 와일드카드나 호스트의 HTTP 상태와는 별개입니다. 동적 :postId에 맞았다는 것이 데이터가 올바르다는 보장은 아닙니다.
import React from 'react';
import { useParams, Outlet, Link } from 'react-router';
// mockPosts (PostListPage에서 가져와도 되지만, 여기서는 독립적으로 정의)
const mockPosts = [
{ id: '1', title: 'React Router 8.5 핵심 기능', content: 'React Router 8.5의 Routes, Route, Link, NavLink, useParams, useNavigate, useSearchParams, Outlet을 하나의 앱에서 사용합니다.', category: 'React', author: '김개발', date: '2024-05-01' },
{ id: '2', title: 'Hooks를 이용한 상태 관리', content: 'useState, useEffect, useContext를 활용하여 컴포넌트의 상태를 효율적으로 관리하는 방법을 학습합니다. 클린업 함수와 의존성 배열의 중요성도 강조합니다.', category: 'React', author: '이코딩', date: '2024-05-05' },
{ id: '3', title: 'CSS-in-JS vs CSS Modules', content: 'Styled-components와 Emotion 같은 CSS-in-JS 라이브러리, 그리고 CSS Modules의 장단점을 비교 분석하고, 각각의 활용 시나리오를 제시합니다.', category: 'CSS', author: '박디자인', date: '2024-05-10' },
{ id: '4', title: '성능 최적화 기법', content: '메모이제이션 (React.memo, useCallback, useMemo)과 코드 스플리팅을 통한 React 애플리케이션 성능 향상 전략을 논의합니다.', category: 'Optimization', author: '최효율', date: '2024-05-12' },
{ id: '5', title: '자바스크립트 비동기 프로그래밍', content: 'Promise, async/await를 이용한 비동기 코드 작성법과 오류 처리 방법을 깊이 있게 다룹니다. Fetch API와 Axios를 활용한 데이터 통신도 포함됩니다.', category: 'JavaScript', author: '정논리', date: '2024-05-15' },
];
function PostRouteState({ title, children }) {
return (
<div className="text-center" role="status">
<h2>{title}</h2>
<p>{children}</p>
<Link to="/posts" className="button secondary">게시글 목록으로</Link>
</div>
);
}
function PostDetailPage() {
const { postId } = useParams();
if (!postId || !/^[1-9]\d*$/.test(postId)) {
return (
<PostRouteState title="잘못된 게시글 주소">
postId는 1 이상의 정수여야 합니다.
</PostRouteState>
);
}
// 실제 앱에서는 검증된 postId로 서버에서 데이터를 조회합니다.
const post = mockPosts.find(item => item.id === postId);
if (!post) {
return (
<PostRouteState title="게시글을 찾을 수 없습니다">
주소 형식은 올바르지만 해당 게시글이 존재하지 않습니다.
</PostRouteState>
);
}
return (
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', padding: '30px', backgroundColor: '#fdfdfd' }}>
<h2 style={{ color: '#3498db', fontSize: '2em', marginBottom: '15px' }}>{post.title}</h2>
<p style={{ fontSize: '0.9em', color: '#777', marginBottom: '20px' }}>
작성자: <span style={{ fontWeight: 'bold' }}>{post.author}</span> | 날짜: {post.date} | 카테고리: <span style={{ fontWeight: 'bold', color: '#2ecc71' }}>{post.category}</span>
</p>
<div style={{ fontSize: '1.1em', lineHeight: '1.8', color: '#444', borderTop: '1px solid #eee', paddingTop: '20px', marginBottom: '30px' }}>
{post.content}
</div>
{/* 중첩 라우팅을 위한 내비게이션 */}
<div style={{ borderTop: '1px solid #eee', paddingTop: '20px', marginBottom: '20px' }}>
<Link to="comments" className="button secondary">댓글 보기</Link>
<Link to="." className="button secondary" style={{ marginLeft: '10px' }}>게시글로 돌아가기</Link>
</div>
{/* Outlet: 중첩 라우트의 콘텐츠가 여기에 렌더링됩니다. */}
<Outlet />
</div>
);
}
export default PostDetailPage;이 코드의 가상 데이터에 있는 “React Router 8.5 핵심 기능” 같은 문자열은 화면에 보여 줄 예시일 뿐입니다. 상세 화면의 “댓글 보기” 링크는 to="comments"라서 현재 라우트 아래에 comments를 더하고, “게시글로 돌아가기”의 to="."은 상세 라우트 자기 자신을 가리킵니다.
목록에서 상세와 댓글까지 이어서 읽어 봅니다. /posts?category=React에서 ID 1의 제목 링크를 누르면 /posts/1로 이동하고, 상세 데이터에서 같은 문자열 ID로 조회해 “React Router 8.5 핵심 기능”을 보여 줍니다. 댓글 링크는 그 부모 라우트 아래에 comments를 더해 /posts/1/comments가 됩니다. 목록의 검색 조건은 상세 링크에 넣지 않았으므로 상세 URL에는 category가 남지 않으며, 이전 목록의 조건은 브라우저의 뒤로 가기로 그 history 항목에 돌아갈 때 다시 읽힙니다.
정리
- 목록 화면은 URL의
category를 데이터에 있는 값만 허용하고, 없거나 지원하지 않는 값은All로 해석합니다. - 이 실습의
setSearchParams는 쿼리 전체를 바꾸므로,category말고 다른 키는 남지 않습니다. - 상세 화면은
postId의 형식 검사와 데이터 조회를 차례로 하고, 두 실패를 각각 다른 상태로 보여 주며 일찍 반환합니다. - 정상적인 게시글일 때만
Outlet을 렌더링하므로, 댓글 자식은 형식 오류나 조회 실패에서는 보이지 않습니다. - 상세 화면의
to="comments"와to="."은 현재 라우트를 기준으로 해석되는 상대 링크입니다.
연습 문제
1. 댓글이 보이지 않는 두 주소
최종 라우트 트리와 가상 데이터는 그대로 둡니다. /posts/01/comments와 /posts/999/comments에서는 무엇이 보이고, 왜 댓글이 렌더링되지 않을까요?
해설 보기
첫 주소는 상세와 댓글의 가지가 맞지만, 부모인 상세 화면이 01의 형식을 거절해 “잘못된 게시글 주소”를 일찍 돌려줍니다. 두 번째 주소는 형식을 통과해 조회한 뒤 데이터가 없어 “게시글을 찾을 수 없습니다”를 돌려줍니다.
두 경우 모두 돌려준 화면에 Outlet이 없으므로 댓글 자식이 렌더링되지 않습니다. 클라이언트의 와일드카드 라우트가 이 두 실패를 처리하는 것이 아닙니다.
2. 세 가지 정상·대체 주소
/posts/2/comments, /posts?category=Unknown, /no-such-route에서는 각각 무엇이 보일까요?
해설 보기
/posts/2/comments는 ID 2의 상세와 그 아래의 댓글이 함께 보입니다. /posts?category=Unknown은 지원하지 않는 카테고리를 All로 해석해 전체 다섯 개를 보여 주며, URL을 자동으로 다시 쓰지는 않습니다.
/no-such-route만 선언된 구체적인 가지가 없어 최상위의 찾을 수 없음 화면을 고릅니다. 이때의 HTTP 상태는 별도로 호스트가 정하는 응답입니다.
3. 다른 쿼리가 있는 목록
/posts?category=React&tab=spec에서 CSS 버튼을 누르면 URL은 어떻게 바뀔까요? tab을 남기려면 무엇을 바꿔야 할까요?
해설 보기
setSearchParams({ category: 'CSS' })가 쿼리 전체를 바꾸므로 URL은 /posts?category=CSS가 되고 tab=spec은 사라집니다. 다른 키를 남기려면 라우트 파라미터와 쿼리 문자열의 updateParam처럼 new URLSearchParams(searchParams)로 현재 값을 복제하고 category만 set하거나 delete한 뒤 setter에 넘겨야 합니다.
다음 글 댓글·작성·fallback 라우트에서는 댓글과 게시글 작성, 찾을 수 없음 화면을 만들고 전체 라우트를 연결합니다.