댓글·작성·fallback 라우트
댓글과 작성 및 client fallback 화면을 연결하고, route 입력 오류와 host HTTP 404 경계를 검증합니다.
PostComments.js (중첩 라우팅 자식)
PostDetailPage의 <Outlet /> 내부에 렌더링될 댓글 목록 컴포넌트입니다. 자식 route는 부모의 동적 파라미터를 상속하므로 comments 경로를 선언할 때 :postId를 다시 적지 않아도 useParams로 읽을 수 있습니다. 부모가 형식과 게시글 존재 여부를 먼저 검증하므로 이 컴포넌트는 정상 게시글의 댓글 상태만 담당합니다.
import React from 'react';
import { useParams } from 'react-router';
// (가상 데이터) 댓글
const mockComments = {
'1': [
{ id: 1, author: '방문자1', text: '좋은 글 잘 읽었습니다!' },
{ id: 2, author: '리액트팬', text: 'React Router가 정말 편리하네요.' }
],
'2': [
{ id: 3, author: '후크초보', text: 'useState 너무 헷갈렸는데 덕분에 이해했습니다.' }
]
};
function PostComments() {
const { postId } = useParams(); // 부모 라우트의 파라미터도 자식에서 접근 가능
const comments = postId ? (mockComments[postId] ?? []) : [];
return (
<div style={{ marginTop: '30px', borderTop: '1px solid #eee', paddingTop: '20px' }}>
<h3 style={{ color: '#555', marginBottom: '15px' }}>댓글 목록</h3>
{comments.length > 0 ? (
comments.map(comment => (
<div key={comment.id} style={{ border: '1px solid #eee', borderRadius: '5px', padding: '10px', marginBottom: '10px', backgroundColor: '#fdfdfd' }}>
<p style={{ margin: '0 0 5px 0', fontWeight: 'bold' }}>{comment.author}</p>
<p style={{ margin: 0, fontSize: '0.95em', color: '#666' }}>{comment.text}</p>
</div>
))
) : (
<p style={{ color: '#888' }}>아직 댓글이 없습니다.</p>
)}
<p style={{ fontSize: '0.9em', color: '#999', marginTop: '20px' }}>
(이 댓글들은 postId: {postId} 에 대한 가상 데이터입니다.)
</p>
</div>
);
}
export default PostComments;NewPostPage.js (useNavigate)
새 게시글을 작성하고 저장 성공 후 목록 페이지로 이동합니다. useNavigate는 폼 검증과 서버 저장을 대체하지 않으므로 실제 앱에서는 POST 요청이 성공한 시점에만 호출해야 합니다.
import React, { useState } from 'react';
import { useNavigate } from 'react-router';
function NewPostPage() {
const navigate = useNavigate();
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [category, setCategory] = useState('React');
const handleSubmit = (e) => {
e.preventDefault();
// 실제 앱에서는 여기에서 서버에 게시글을 POST 요청하고, 성공 시 목록으로 이동합니다.
console.log('새 게시글 제출:', { title, content, category });
alert('게시글이 성공적으로 작성되었습니다!');
navigate('/posts'); // 게시글 목록 페이지로 이동
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '30px', border: '1px solid #eee', borderRadius: '8px', backgroundColor: '#fdfdfd' }}>
<h2 className="text-center" style={{ marginBottom: '30px' }}>새 게시글 작성</h2>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
<div>
<label htmlFor="title" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>제목:</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '5px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label htmlFor="content" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>내용:</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows="10"
style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '5px', boxSizing: 'border-box', resize: 'vertical' }}
></textarea>
</div>
<div>
<label htmlFor="category" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>카테고리:</label>
<select
id="category"
value={category}
onChange={(e) => setCategory(e.target.value)}
style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '5px', boxSizing: 'border-box' }}
>
<option value="React">React</option>
<option value="JavaScript">JavaScript</option>
<option value="CSS">CSS</option>
<option value="Optimization">Optimization</option>
</select>
</div>
<button type="submit" className="button" style={{ marginTop: '20px' }}>게시글 제출</button>
</form>
</div>
);
}
export default NewPostPage;NotFoundPage.js (client fallback)
선언한 구체 route가 하나도 매칭되지 않을 때 path="*"가 표시하는 client fallback 화면입니다. 이미 앱 문서가 로드된 뒤 React가 렌더하는 UI이므로 이 컴포넌트만으로 document의 HTTP 상태가 404로 바뀌지는 않습니다.
import React from 'react';
import { Link } from 'react-router';
function NotFoundPage() {
return (
<div className="text-center" style={{ padding: '50px 20px', backgroundColor: '#fefefe', borderRadius: '8px' }}>
<h2 style={{ color: '#e74c3c', fontSize: '2.5em', marginBottom: '15px' }}>페이지를 찾을 수 없습니다</h2>
<p style={{ fontSize: '1.2em', color: '#555', marginBottom: '30px' }}>
요청한 client route가 이 앱에 없습니다.
</p>
<Link to="/" className="button secondary">홈으로 돌아가기</Link>
</div>
);
}
export default NotFoundPage;App.js (최종 라우터 설정)
React Router 8.3 · nested route ownership
URL 계층과 렌더 계층은 부모의 Outlet에서 만납니다. 상세 route가 postId를 검증한 뒤에만 상대 child comments를 열고, 자식은 부모 파라미터를 그대로 상속합니다.
/posts· 목록category검색값을 허용 목록으로 검증합니다./posts/new· 정적 작성정적 세그먼트라서 동적
:postId보다 구체적인 branch입니다./posts/:postId· 동적 상세형식 오류와 올바른 형식의 조회 실패를 부모가 서로 다르게 렌더합니다.
comments· 상대 child부모의 정상 상세와 함께
Outlet에 렌더되고postId를 상속합니다.*· client fallback구체 route가 없는 URL만 처리하며 document의 HTTP 상태를 설정하지 않습니다.
branch ranking
/posts/new와 /posts/:postId가 모두 형태상 맞아도 정적 branch가 더 구체적입니다. 이 충돌은 선언 순서에 기대지 않습니다.
parent guard
/posts/abc는 형식 오류, /posts/999는 조회 실패입니다. 둘 다 동적 상세 route가 소유합니다.
Outlet contract
정상 게시글에서만 부모가 Outlet을 렌더합니다. child path는 comments이고 최종 URL은 부모 경로와 결합됩니다.
모든 라우트를 설정하고 BrowserRouter로 감쌉니다. React Router는 route branch의 구체성을 계산하므로 정적 /posts/new가 동적 /posts/:postId보다 선택 우선순위가 높습니다. 따라서 이 정적·동적 충돌은 선언 순서와 무관하게 해소됩니다. 상대 child path인 comments는 부모 경로에 이어지고, 매칭된 자식 element는 PostDetailPage의 <Outlet />에 렌더됩니다.
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import Navbar from './components/Navbar';
import HomePage from './pages/HomePage';
import PostListPage from './pages/PostListPage';
import PostDetailPage from './pages/PostDetailPage';
import PostComments from './pages/PostComments';
import NewPostPage from './pages/NewPostPage';
import NotFoundPage from './pages/NotFoundPage';
function App() {
return (
<BrowserRouter>
<Navbar /> {/* 내비게이션 바는 항상 상단에 표시 */}
<div className="main-content"> {/* 모든 페이지 내용이 들어갈 컨테이너 */}
<Routes>
{/* 기본 라우트 */}
<Route path="/" element={<HomePage />} />
<Route path="/posts" element={<PostListPage />} />
<Route path="/posts/new" element={<NewPostPage />} />
{/* 동적 라우트 및 중첩 라우트 */}
<Route path="/posts/:postId" element={<PostDetailPage />}>
{/* 자식 라우트: /posts/:postId/comments */}
<Route path="comments" element={<PostComments />} />
</Route>
{/* 구체 route가 없을 때 client fallback UI */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</BrowserRouter>
);
}
export default App;URL, 오류, host 경계 검증
실습 검증은 주소창 직접 입력 -> Link 또는 useNavigate 이동 -> 뒤로/앞으로 이동 순서로 수행합니다. 첫 단계는 host가 문서를 반환하는지, 두 번째 단계는 이미 로드된 앱 안에서 어떤 branch가 선택되는지, 마지막 단계는 history가 URL과 화면을 함께 복원하는지를 각각 확인합니다.
BrowserRouter는 브라우저의 pathname과 history를 사용하지만 정적 host의 rewrite 규칙을 만들지는 않습니다. 배포 환경은 SPA가 맡는 document 탐색 요청을 index.html로 보내야 합니다. 이 범위에는 client wildcard가 표시할 미등록 app path도 포함하되, 정적 asset 경로와 API 요청은 fallback에서 제외해야 합니다.
BrowserRouter · document boundary
주소창 요청과 앱 내부 이동은 시작점이 다릅니다. 직접 진입은 host가 앱 문서를 반환해야 React Router에 도달하고, client wildcard는 앱이 로드된 뒤의 화면 선택일 뿐 HTTP 상태를 자동으로 바꾸지 않습니다.
직접 진입은 host부터
주소창과 새로고침은 먼저 document 요청을 보냅니다.
앱 URL은
index.html로 fallbackfallback이 없으면 host HTTP 404에서 끝나고 React는 실행되지 않습니다.
앱 문서가 로드되면 branch 선택
React Router가 pathname을 읽어 구체 route를 고릅니다.
동적 route는 입력을 다시 검증
/posts/abc와/posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다.구체 route가 없으면 client wildcard
path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동 설정하지 않습니다.앱 내부 이동은 Router부터
Link와useNavigate는 새 document 요청 없이 history와 화면을 갱신합니다.
동적 branch의 실패
:postId는 문자열 세그먼트를 먼저 매칭합니다. 형식 검증과 게시물 조회 실패는 상세 컴포넌트가 구분합니다.
client NotFound
wildcard 화면은 앱의 렌더 결과입니다. 정적 SPA에서 fallback 문서가 200으로 반환됐다면 UI만으로 404 응답이 되지 않습니다.
host fallback 범위
SPA의 document 탐색 경로를 fallback합니다. client wildcard가 표시할 미등록 app path도 포함하되, asset 경로와 API 요청은 index.html로 바꾸지 않습니다.
패키지 확인: 이 프로젝트의 예제 기준은 react-router@8.3.0입니다. 별도 프로젝트라면 같은 패키지를 설치하고 모든 DOM 컴포넌트와 훅을 react-router에서 가져옵니다.
정상 route와 중첩 렌더: /, /posts, /posts/new, /posts/1을 열고 /posts/1/comments에서 상세 화면 아래 댓글이 함께 렌더되는지 확인합니다. 댓글에서 부모로 돌아오면 URL이 /posts/1이 되고 <Outlet />의 자식만 사라져야 합니다.
동적 입력 오류 분리: /posts/abc는 :postId branch가 매칭된 뒤 형식 오류를 표시하고, /posts/999는 올바른 형식이지만 조회 결과가 없다는 상태를 표시해야 합니다. 두 URL 모두 top-level path="*"의 NotFound 화면으로 넘기지 않습니다.
검색값 검증: /posts?category=React는 React 게시글만 표시하고, /posts와 /posts?category=Unknown은 이 예제의 정책에 따라 전체 목록을 표시하는지 확인합니다.
작성 성공 뒤 이동: /posts/new가 동적 상세가 아니라 정적 작성 branch를 선택하는지 확인합니다. 폼 제출 예제에서는 저장 성공을 모의한 뒤 useNavigate로 /posts에 이동합니다.
client fallback과 HTTP 상태 분리: 앱 안에서 /abcd로 이동하거나, host가 /abcd를 index.html로 rewrite한 뒤 앱이 로드되면 path="*"의 NotFound UI가 렌더됩니다. 정적 SPA의 document 응답은 보통 200이며 이 UI가 HTTP 404를 자동 설정하지 않습니다. rewrite가 없으면 host가 실제 HTTP 404를 반환하고 React는 실행되지 않습니다.
history 복원: 링크 이동과 작성 후 이동을 수행한 다음 뒤로·앞으로 이동하며 pathname, 검색 문자열, 상세·댓글 조합이 함께 복원되는지 확인합니다.
이 실습의 핵심은 URL을 화면 이름에만 연결하지 않고 branch 선택, URL 입력 검증, 부모·자식 렌더 소유권, host의 첫 문서 응답까지 하나의 계약으로 다루는 것입니다. useParams, useSearchParams, useNavigate, Link, Routes, Route, Outlet은 이 계약에서 서로 다른 책임을 맡습니다.
이로써 6장 React 라우팅 기초가 모두 끝났습니다.