댓글·작성·fallback 라우트
댓글과 작성 및 client fallback 화면을 연결하고, route 입력 오류와 host HTTP 404 경계를 검증합니다.
앞 글 목록·상세 라우트에서 홈과 목록, 상세 화면을 만들었습니다. 이 글에서는 남은 세 화면인 댓글과 게시글 작성, 찾을 수 없음 화면을 만들고, App.jsx에서 전체 라우트를 연결한 뒤 URL과 오류, 호스트의 응답을 차례로 확인합니다.
PostComments.jsx (중첩 라우팅 자식)
PostDetailPage의 <Outlet /> 안에 렌더링될 댓글 목록 컴포넌트입니다. 자식 라우트는 부모의 동적 매개변수를 이어받으므로, 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;가상 데이터에는 게시글 1의 댓글 두 개와 게시글 2의 댓글 한 개만 있으므로, 다른 게시글에서는 ?? []로 빈 배열을 써서 “아직 댓글이 없습니다.”를 보여 줍니다. 댓글이 없는 상태와 게시글이 없는 상태는 서로 다른 상태입니다.
NewPostPage.jsx (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;이 NewPostPage는 Console 출력과 성공 알림, 목록으로의 이동을 시험하는 코드일 뿐, mockPosts에 글을 더하거나 서버에 저장하지는 않습니다. 그래서 제목과 내용을 입력해 제출한 뒤 목록으로 이동해도 기존 게시글은 다섯 개 그대로이고 새 글은 나타나지 않습니다. 알림의 문구는 흉내 낸 흐름의 일부일 뿐, 저장되었다는 확인으로 쓰지 않습니다.
폼의 required는 일반적인 브라우저 제출에서 빈 값의 제출을 막는 보조 기능입니다. 공백만 있는 문자열을 어떻게 다룰지, 서버의 입력 검증과 인가, 저장 실패와 중복 제출은 구현되어 있지 않습니다. 실제 저장 기능을 만든다면 그 규칙을 정하고, 성공 응답을 받은 뒤 목록 데이터의 갱신과 이동을 이어야 합니다.
NotFoundPage.jsx (client fallback)
선언한 구체적인 라우트가 하나도 맞지 않을 때 path="*"가 보여 주는 클라이언트 대체 화면입니다. 이미 앱 문서를 불러온 뒤 React가 렌더링하는 UI이므로, 이 컴포넌트만으로 문서의 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.jsx (최종 라우터 설정)
REACT ROUTING · URL & RESPONSIBILITY
정상 상세의 Outlet에 comments 자식을 렌더합니다
정적 new와 동적 postId는 형제 branch이고 comments는 상세의 상대 child입니다. 부모는 ID 형식과 게시글 존재를 검사하고 정상 상세에서만 Outlet을 렌더합니다.
실제 /posts/1/comments · 부모 파라미터 상속
postId = "1" → 형식 정상 → 게시글 존재
PostDetailPage + Outlet의 PostComments
mockComments["1"] → 댓글 2개부모의 comments 링크는 /posts/1/comments를 만들고 “게시글로 돌아가기”의 to="."은 부모 /posts/1로 돌아갑니다. /posts/abc/comments와 /posts/999/comments에는 정상 상세·댓글 Outlet이 렌더되지 않습니다.
/· 홈형제 정적 route의
HomePage입니다./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은 부모 경로와 결합됩니다.
위 그림은 블로그 SPA의 라우트 가지와 Outlet이 무엇을 맡는지 보여 줍니다. <Routes> 아래에 홈 /, 쿼리를 쓰는 목록 /posts, 정적 작성 /posts/new, 형식 검사와 조회를 거쳐 상세를 보여 주는 /posts/:postId, 클라이언트의 찾을 수 없음 *가 형제로 놓이고, comments는 동적 상세의 상대 자식 경로로 부모의 <Outlet />에 렌더링됩니다. 정적 new와 동적 postId는 형제 가지이며, Routes가 구체성을 따져 가지를 고릅니다.
그림 위쪽의 예는 /posts/1/comments입니다. postId가 "1"이라 형식을 통과하고 게시글이 있으므로, PostDetailPage와 그 Outlet의 PostComments가 함께 렌더링되고 mockComments["1"]의 댓글 두 개가 보입니다. 부모의 댓글 링크가 /posts/1/comments를 만들고, “게시글로 돌아가기”의 to="."은 부모인 /posts/1로 돌아갑니다. 그림의 설명처럼 /posts/abc/comments와 /posts/999/comments에서는 정상적인 상세와 댓글의 Outlet이 렌더링되지 않습니다.
이제 모든 라우트를 설정하고 BrowserRouter로 감쌉니다. React Router는 라우트 가지의 구체성을 계산하므로 정적 /posts/new가 동적 /posts/:postId보다 먼저 선택되고, 이 충돌은 선언 순서와 상관없이 풀립니다. 상대 자식 경로인 comments는 부모 경로에 이어 붙고, 맞은 자식 element는 PostDetailPage의 <Outlet />에 렌더링됩니다. 이 App은 직접 BrowserRouter를 제공하므로, main.jsx는 <App />만 렌더링하고 라우터를 다시 감싸지 않습니다.
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, 오류, 호스트 응답 검증
실습의 검증은 주소창에 직접 입력하기, Link나 useNavigate로 이동하기, 뒤로·앞으로 이동하기의 순서로 합니다. 첫 단계에서는 호스트가 문서를 돌려주는지, 두 번째 단계에서는 이미 불러온 앱 안에서 어떤 가지가 선택되는지, 마지막 단계에서는 history가 URL과 화면을 함께 되살리는지를 각각 확인합니다.
BrowserRouter는 브라우저의 경로와 history를 쓰지만, 정적 호스트의 재작성 규칙을 만들어 주지는 않습니다. 배포 환경은 SPA가 맡을 문서 탐색 요청을 index.html로 보내야 하며, 그 범위에는 클라이언트 와일드카드가 보여 줄 등록되지 않은 앱 경로도 들어갑니다. 다만 정적 자산의 경로와 API 요청은 그 대체 응답에서 빼야 합니다.
host document fallback과 client route 경계 — 상태 추적
실제 /posts/1 직접 진입은 rewrite 없으면 host 404로 종료하고, HTML 뒤에만 router가 구체/splat view를 고른다. Link는 host document 단계를 생략하며 같은 화면과 HTTP 상태를 분리한다.
REACT ROUTING · URL & RESPONSIBILITY
client NotFound UI와 host HTTP 404는 다른 경계입니다
정적 SPA의 직접 요청은 host가 index.html을 반환해야 React Router에 도달합니다. 실행 중인 앱의 적합한 내부 Link·navigate는 Router 단계로 들어갑니다.
실제 /abcd · 두 호스트 설정의 결과
document fallback 있음
→ entry HTML 보통 200
→ React 실행 → NotFound UI
fallback 없음 → host HTTP 404
→ 앱 실행 못 함이 흐름은 본문의 정적 client-only SPA 조건입니다. asset·API 요청은 document fallback에서 제외하고 실제 상태 코드가 필요하면 host·서버 정책으로 결정합니다.
직접 진입은 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로 바꾸지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 직접 /posts/1 요청의 독립 host 설정 A(fallback 없음)와 B(fallback 있음)를 비교합니다. A의 404 요청은 종료되며 B는 별도 요청입니다. 세 번째 장면은 B의 HTML 이후 client route 선택입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | 직접 진입은 host부터: 주소창과 새로고침은 먼저 document 요청을 보냅니다. |
| 원도표 설명 | 앱 URL은 index.html로 fallback |
| 원도표 조건·주석 | 앱 URL은 index.html로 fallback: fallback이 없으면 host HTTP 404에서 끝나고 React는 실행되지 않습니다. |
| 원도표 조건·주석 | 앱 문서가 로드되면 branch 선택: React Router가 pathname을 읽어 구체 route를 고릅니다. |
| 원도표 설명 | /posts/abc와 /posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다. |
| 원도표 조건·주석 | 동적 route는 입력을 다시 검증: /posts/abc와 /posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다. |
| 원도표 설명 | path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동으로 설정하지 않습니다. |
| 원도표 조건·주석 | 구체 route가 없으면 client wildcard: path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동으로 설정하지 않습니다. |
| 원도표 설명 | Link와 useNavigate는 새 document 요청 없이 history와 화면을 갱신합니다. |
| 원도표 조건·주석 | 앱 내부 이동은 Router부터: Link와 useNavigate는 새 document 요청 없이 history와 화면을 갱신합니다. |
| 원도표 설명 | :postId는 문자열 세그먼트를 먼저 매칭합니다. 형식 검증과 게시물 조회 실패는 상세 컴포넌트가 구분합니다. |
| 원도표 설명 | SPA의 document 탐색 경로를 fallback합니다. client wildcard가 표시할 미등록 app path도 포함하되, asset 경로와 API 요청은 index.html로 바꾸지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 설정 A · fallback 없음 | HTTP: 404 / React: 실행 안 됨 | 직접 /posts/1을 요청하나 rewrite가 없으면 HTTP 404로 끝난다 | client의 wildcard보다 앞에 있는 경계다 |
| 설정 B · fallback HTML | HTTP: 보통 200 / router: 실행 가능 | 설정된 host는 index.html을 보내고 현재 pathname을 유지한다 | asset과 API는 앱 문서의 fallback에서 제외해야 한다 |
| 설정 B · client 선택 | UI: 구체 route/NotFound / status: 자동 변경 없음 | 구체적인 post route 또는 wildcard UI를 고르고 Link는 이 단계로 바로 온다 | client의 실패 표시와 host의 status를 분리한다 |
host document fallback과 client route 경계 — 상태 추적
실제 /posts/1 직접 진입은 rewrite 없으면 host 404로 종료하고, HTML 뒤에만 router가 구체/splat view를 고른다. Link는 host document 단계를 생략하며 같은 화면과 HTTP 상태를 분리한다.
REACT ROUTING · URL & RESPONSIBILITY
client NotFound UI와 host HTTP 404는 다른 경계입니다
정적 SPA의 직접 요청은 host가 index.html을 반환해야 React Router에 도달합니다. 실행 중인 앱의 적합한 내부 Link·navigate는 Router 단계로 들어갑니다.
실제 /abcd · 두 호스트 설정의 결과
document fallback 있음
→ entry HTML 보통 200
→ React 실행 → NotFound UI
fallback 없음 → host HTTP 404
→ 앱 실행 못 함이 흐름은 본문의 정적 client-only SPA 조건입니다. asset·API 요청은 document fallback에서 제외하고 실제 상태 코드가 필요하면 host·서버 정책으로 결정합니다.
직접 진입은 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로 바꾸지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 직접 /posts/1 요청의 독립 host 설정 A(fallback 없음)와 B(fallback 있음)를 비교합니다. A의 404 요청은 종료되며 B는 별도 요청입니다. 세 번째 장면은 B의 HTML 이후 client route 선택입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 조건·주석 | 직접 진입은 host부터: 주소창과 새로고침은 먼저 document 요청을 보냅니다. |
| 원도표 설명 | 앱 URL은 index.html로 fallback |
| 원도표 조건·주석 | 앱 URL은 index.html로 fallback: fallback이 없으면 host HTTP 404에서 끝나고 React는 실행되지 않습니다. |
| 원도표 조건·주석 | 앱 문서가 로드되면 branch 선택: React Router가 pathname을 읽어 구체 route를 고릅니다. |
| 원도표 설명 | /posts/abc와 /posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다. |
| 원도표 조건·주석 | 동적 route는 입력을 다시 검증: /posts/abc와 /posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다. |
| 원도표 설명 | path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동으로 설정하지 않습니다. |
| 원도표 조건·주석 | 구체 route가 없으면 client wildcard: path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동으로 설정하지 않습니다. |
| 원도표 설명 | Link와 useNavigate는 새 document 요청 없이 history와 화면을 갱신합니다. |
| 원도표 조건·주석 | 앱 내부 이동은 Router부터: Link와 useNavigate는 새 document 요청 없이 history와 화면을 갱신합니다. |
| 원도표 설명 | :postId는 문자열 세그먼트를 먼저 매칭합니다. 형식 검증과 게시물 조회 실패는 상세 컴포넌트가 구분합니다. |
| 원도표 설명 | SPA의 document 탐색 경로를 fallback합니다. client wildcard가 표시할 미등록 app path도 포함하되, asset 경로와 API 요청은 index.html로 바꾸지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 설정 A · fallback 없음 | HTTP: 404 / React: 실행 안 됨 | 직접 /posts/1을 요청하나 rewrite가 없으면 HTTP 404로 끝난다 | client의 wildcard보다 앞에 있는 경계다 |
| 설정 B · fallback HTML | HTTP: 보통 200 / router: 실행 가능 | 설정된 host는 index.html을 보내고 현재 pathname을 유지한다 | asset과 API는 앱 문서의 fallback에서 제외해야 한다 |
| 설정 B · client 선택 | UI: 구체 route/NotFound / status: 자동 변경 없음 | 구체적인 post route 또는 wildcard UI를 고르고 Link는 이 단계로 바로 온다 | client의 실패 표시와 host의 status를 분리한다 |
위 그림은 클라이언트의 찾을 수 없음 UI와 호스트의 HTTP 404가 서로 다른 단계라는 점을 보여 줍니다. 주소창에 입력하거나 새로고침하면 브라우저가 호스트에 GET /posts/1 같은 문서를 요청하고, 호스트에 대체 응답 설정이 없으면 HTTP 404를 돌려줘 React가 실행되지 않습니다. 설정이 있으면 index.html을 보통 200으로 돌려주고, 그 뒤에 React Router가 현재 경로로 가지를 고릅니다. 반면 Link나 useNavigate는 새 문서를 요청하지 않고 바로 React Router 단계로 들어갑니다. React Router는 구체적인 가지가 맞으면 그 라우트 화면을 렌더링하고(게시글 라우트는 그 안에서 입력을 검증합니다), 맞는 가지가 없으면 path="*"의 화면을 고릅니다.
그림 위쪽의 예는 /abcd에 대한 두 호스트 설정의 결과입니다. 문서 대체 응답이 있으면 진입 HTML이 보통 200으로 오고 React가 실행되어 찾을 수 없음 UI가 보이며, 대체 응답이 없으면 호스트가 HTTP 404를 돌려줘 앱이 실행되지 못합니다. 그림의 설명처럼 이 흐름은 이 글의 정적 클라이언트 전용 SPA라는 조건에서의 이야기이며, 자산과 API 요청은 문서 대체 응답에서 빼고, 실제 상태 코드가 필요하다면 호스트나 서버의 규칙으로 정합니다.
이 흐름에 따라 다음을 확인합니다. 먼저 패키지입니다. 이 교재의 예제는 [email protected]을 기준으로 하므로, 별도의 프로젝트라면 같은 패키지를 설치하고 모든 DOM 컴포넌트와 훅을 react-router에서 가져옵니다. 다음으로 정상 라우트와 중첩 렌더링입니다. /, /posts, /posts/new, /posts/1을 열어 보고, /posts/1/comments에서 상세 화면 아래에 댓글이 함께 렌더링되는지 확인합니다. 댓글에서 부모로 돌아오면 URL이 /posts/1이 되고, <Outlet />의 자식만 사라져야 합니다.
동적 입력의 오류도 나눠 봅니다. /posts/abc는 :postId 가지가 맞은 뒤 형식 오류를 보여 주고, /posts/999는 형식은 맞지만 조회 결과가 없다는 상태를 보여 줘야 하며, 두 URL 모두 최상위 path="*"의 찾을 수 없음 화면으로 넘어가지 않습니다. 쿼리의 검증으로는 /posts?category=React가 React 게시글만 보여 주고, /posts와 /posts?category=Unknown은 이 예제의 규칙에 따라 전체 목록을 보여 주는지 확인합니다. /posts/new가 동적 상세가 아니라 정적 작성 가지를 고르는지도 확인하고, 폼 제출에서는 저장 성공을 흉내 낸 뒤 useNavigate로 /posts에 이동하는지 봅니다.
클라이언트의 대체 화면과 HTTP 상태도 따로 봅니다. 앱 안에서 /abcd로 이동하거나, 호스트가 /abcd를 index.html로 재작성해 앱을 불러오면 path="*"의 찾을 수 없음 UI가 렌더링됩니다. 정적 SPA의 문서 응답은 보통 200이며, 이 UI가 HTTP 404를 저절로 설정하지 않습니다. 재작성이 없으면 호스트가 실제 HTTP 404를 돌려주고 React는 실행되지 않습니다. 마지막으로 링크 이동과 작성 뒤의 이동을 한 다음 뒤로·앞으로 이동하며, 경로와 쿼리, 상세와 댓글의 조합이 함께 되살아나는지 확인합니다.
이 실습의 핵심은 URL을 화면의 이름에만 잇지 않고, 가지의 선택과 URL 입력의 검증, 부모와 자식의 렌더링, 호스트의 첫 문서 응답까지 하나의 규칙으로 다루는 데 있습니다. useParams, useSearchParams, useNavigate, Link, Routes, Route, Outlet은 그 안에서 각자 다른 역할을 맡습니다.
정리
- 자식 라우트는 부모의 동적 매개변수를 이어받으므로, 댓글 화면은
:postId를 다시 선언하지 않고useParams로 읽습니다. useNavigate는 저장에 성공한 뒤에 호출해야 하며, 이 예제는 저장을 흉내 낼 뿐 실제로 글을 더하지 않습니다.- 정적
/posts/new는 동적/posts/:postId보다 구체적이어서 선언 순서와 상관없이 먼저 선택됩니다. path="*"의 찾을 수 없음 UI는 앱이 실행된 뒤의 화면이며, 문서의 HTTP 상태는 호스트가 정합니다.- 정적 SPA를 배포할 때는 앱 경로의 문서 요청을
index.html로 보내되, 자산과 API 요청은 그 대체 응답에서 뺍니다.
연습 문제
1. 댓글이 없는 게시글
/posts/3/comments에서는 상세와 어떤 댓글 상태가 보일까요? 부모의 to="." 링크를 누르면 무엇만 사라질까요?
해설 보기
ID 3은 존재하는 게시글이므로 상세가 보이지만, 댓글의 가상 데이터가 없어 “아직 댓글이 없습니다.”가 보입니다. 부모 라우트를 기준으로 한 . 링크는 /posts/3으로 돌아가므로 상세는 남고 Outlet의 댓글 화면만 사라집니다. 댓글이 없다는 상태와 게시글이 없다는 상태는 다릅니다.
2. 새 글이 보이지 않는 목록
새 글을 제출해 목록으로 돌아간 다음 새로고침하면, 새 글과 기존 다섯 글은 각각 어떻게 될까요? 목록으로의 이동이 성공했는데도 새 글이 없는 현상을 호스트의 대체 응답 오류라고 할 수 있을까요?
해설 보기
원래의 제출 코드는 글을 저장하지 않으므로, 새 글은 이동한 뒤에도 새로고침한 뒤에도 목록에 없습니다. 기존 가상 배열의 다섯 글은 다시 렌더링됩니다.
이 현상은 호스트의 대체 응답 오류가 아니라 저장 기능이 없기 때문입니다. 저장 기능이 없는 것과, 새 문서로 들어올 수 있게 해 주는 호스트의 대체 응답은 구별해야 합니다.
3. 200으로 오는 찾을 수 없음 화면
호스트가 /abcd에 진입 HTML을 200 OK로 응답한다고 가정합니다. 앱의 찾을 수 없음 화면과 문서의 응답 상태는 각각 어떻게 될까요?
해설 보기
앱은 찾을 수 없음 UI를 보여 주고, 문서의 응답 상태는 HTTP 200으로 남아 두 가지가 함께 성립합니다. 자바스크립트가 그 UI를 렌더링했다고 해서 이미 받은 HTTP 응답의 상태가 바뀌지는 않습니다.
이것으로 6장을 마칩니다. 6장에서는 MPA와 SPA, CSR·SSR·SSG의 차이에서 시작해, React Router 8.5의 Declarative mode로 라우트 트리를 구성하고, 경로 매개변수와 쿼리 문자열로 화면의 상태를 URL에 담으며, 중첩 라우트와 Outlet으로 공통 레이아웃을 나눈 뒤, 이를 모아 블로그 SPA를 만들었습니다. 다음 장의 컴포넌트 간 상태 공유의 어려움부터는 여러 컴포넌트가 상태를 나눠 쓸 때 생기는 문제와, Context와 useReducer, Redux로 이를 다루는 상태 관리를 익힙니다.