본문 바로가기

목록·상세 라우트

홈과 게시물 목록·상세 화면을 URL 파라미터 및 중첩 라우트로 구성합니다.

앞 글 간단한 다중 경로 SPA 만들기에서 블로그의 URL 규칙과 공통 파일을 준비했습니다. 이 글에서는 각 라우트에 해당하는 화면 컴포넌트 가운데 홈과 게시글 목록, 게시글 상세를 만듭니다.


라우트 페이지 구성

HomePage.jsx (기본 라우팅)

홈 화면은 블로그를 소개하고 게시글 목록으로 가는 링크를 보여 줍니다. 상태나 데이터 없이 고정된 소개 문구만 그리므로, 이 페이지에서 볼 곳은 맨 아래의 Link 하나입니다. className="button"은 앞 글의 index.css에 정의한 클래스라서, 링크가 버튼 모양으로 보입니다.

src/pages/HomePage.jsx
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를 호출하면 새 쿼리 문자열로 이동합니다.

src/pages/PostListPage.jsx
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은 검사 위치와 값이 다르다. 같은 입력/검증/조회/화면 역할로 단락을 보여 줄 가치가 있다.

ID 형식 오류와 조회 실패는 상세 route가 구분합니다

BLOG ROUTES · INPUT GUARD

ID 형식 오류와 조회 실패는 상세 route가 구분합니다

동적 :postId 매칭은 데이터의 유효성을 보장하지 않습니다. PostDetailPage는 형식 검사 → 게시글 조회 → 정상 상세·Outlet 순서로 판단하며 두 실패에서는 조기에 반환합니다.

실제 세 입력 · 같은 동적 branch, 다른 화면

abc → 형식 오류 / 조회하지 않음
999 → 형식 정상 / 게시글 없음
1   → 게시글 존재 / 상세 + comments 자식

세 입력 모두 상세 route가 소유합니다. 전혀 없는 route의 client wildcard나 host의 HTTP 상태와는 별개입니다.

PostDetailPage의 실제 입력 검증과 Outlet 분기ID는 먼저 양의 정수 문자열 형식을 검사합니다. 실패하면 잘못된 주소 상태를 렌더합니다. 통과한 ID를 mockPosts에서 조회하고 없으면 게시글 없음 상태를 렌더합니다. 정상 게시글만 상세와 Outlet을 렌더하므로 comments 자식도 이 정상 분기에서만 보입니다.useParams · postIdpathname의 문자열 입력양의 정수 형식?/^[1-9]\d*$/잘못된 게시글 주소abc · 0 · 01mockPosts에서 ID 조회item.id === postId게시글이 존재?게시글을 찾을 수없습니다999 · 형식은 정상 /조회 실패정상 상세 + Outlet1 · 댓글 child 렌더아니요예아니요예
PostDetailPage의 실제 입력 검증과 Outlet 분기ID는 먼저 양의 정수 문자열 형식을 검사합니다. 실패하면 잘못된 주소 상태를 렌더합니다. 통과한 ID를 mockPosts에서 조회하고 없으면 게시글 없음 상태를 렌더합니다. 정상 게시글만 상세와 Outlet을 렌더하므로 comments 자식도 이 정상 분기에서만 보입니다.useParams · postIdpathname 문자열 입력양의 정수 형식?/^[1-9]\d*$/잘못된 게시글 주소abc · 0 · 01mockPosts에서ID 조회item.id === postId게시글이 존재?게시글을 찾을 수없습니다999 · 형식은 정상 /조회 실패정상 상세 + Outlet1 · 댓글 child 렌더아니요예아니요예

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은 검사 위치와 값이 다르다. 같은 입력/검증/조회/화면 역할로 단락을 보여 줄 가치가 있다.

ID 형식 오류와 조회 실패는 상세 route가 구분합니다

BLOG ROUTES · INPUT GUARD

ID 형식 오류와 조회 실패는 상세 route가 구분합니다

동적 :postId 매칭은 데이터의 유효성을 보장하지 않습니다. PostDetailPage는 형식 검사 → 게시글 조회 → 정상 상세·Outlet 순서로 판단하며 두 실패에서는 조기에 반환합니다.

실제 세 입력 · 같은 동적 branch, 다른 화면

abc → 형식 오류 / 조회하지 않음
999 → 형식 정상 / 게시글 없음
1   → 게시글 존재 / 상세 + comments 자식

세 입력 모두 상세 route가 소유합니다. 전혀 없는 route의 client wildcard나 host의 HTTP 상태와는 별개입니다.

PostDetailPage의 실제 입력 검증과 Outlet 분기ID는 먼저 양의 정수 문자열 형식을 검사합니다. 실패하면 잘못된 주소 상태를 렌더합니다. 통과한 ID를 mockPosts에서 조회하고 없으면 게시글 없음 상태를 렌더합니다. 정상 게시글만 상세와 Outlet을 렌더하므로 comments 자식도 이 정상 분기에서만 보입니다.useParams · postIdpathname의 문자열 입력양의 정수 형식?/^[1-9]\d*$/잘못된 게시글 주소abc · 0 · 01mockPosts에서 ID 조회item.id === postId게시글이 존재?게시글을 찾을 수없습니다999 · 형식은 정상 /조회 실패정상 상세 + Outlet1 · 댓글 child 렌더아니요예아니요예
PostDetailPage의 실제 입력 검증과 Outlet 분기ID는 먼저 양의 정수 문자열 형식을 검사합니다. 실패하면 잘못된 주소 상태를 렌더합니다. 통과한 ID를 mockPosts에서 조회하고 없으면 게시글 없음 상태를 렌더합니다. 정상 게시글만 상세와 Outlet을 렌더하므로 comments 자식도 이 정상 분기에서만 보입니다.useParams · postIdpathname 문자열 입력양의 정수 형식?/^[1-9]\d*$/잘못된 게시글 주소abc · 0 · 01mockPosts에서ID 조회item.id === postId게시글이 존재?게시글을 찾을 수없습니다999 · 형식은 정상 /조회 실패정상 상세 + Outlet1 · 댓글 child 렌더아니요예아니요예

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에 맞았다는 것이 데이터가 올바르다는 보장은 아닙니다.

src/pages/PostDetailPage.jsx
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 라우트에서는 댓글과 게시글 작성, 찾을 수 없음 화면을 만들고 전체 라우트를 연결합니다.