본문 바로가기

목록·상세 라우트

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

라우트 페이지 구성

각 라우트에 해당하는 페이지 컴포넌트들을 만듭니다.

HomePage.js (기본 라우팅)

src/pages/HomePage.js
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;

PostListPage.js (쿼리 문자열)

게시글 목록을 표시하고 카테고리 필터링을 위해 useSearchParams를 사용합니다. 검색 파라미터는 URL에서 온 문자열이므로 데이터에 실제로 존재하는 카테고리만 허용하고, 값이 없거나 허용 목록에 없으면 All로 해석합니다. setSearchParams를 호출하면 새 검색 문자열로 이동합니다.

src/pages/PostListPage.js
import React from 'react';
import { Link, useSearchParams } from 'react-router';

// (가상 데이터) 블로그 게시글 목록
const mockPosts = [
  { id: '1', title: 'React Router 8.3 핵심 기능', content: 'React Router 8.3의 라우팅 기능들을 알아봅시다.', 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;

PostDetailPage.js (중첩 라우팅 부모)

게시글 상세 내용을 표시하고, 하단에 댓글 목록을 위한 중첩 라우트를 설정합니다. :postId는 어떤 한 세그먼트든 매칭하므로 컴포넌트가 양의 정수 형식과 조회 결과를 차례로 검증해야 합니다. 형식 오류와 존재하지 않는 게시글은 path="*"로 보내지 않고 이 동적 route가 각각 렌더합니다. 정상 게시글일 때만 <Outlet />에 댓글 자식을 열며, 자식 링크는 부모 route 기준의 상대 경로를 사용합니다.

게시글 상세 입력의 두 실패 경계 — 상태 추적

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 자식을 표시할 수 있다실패 분기에서 댓글이 따로 표시되지는 않는다
src/pages/PostDetailPage.js
import React from 'react';
import { useParams, Outlet, Link } from 'react-router';

// mockPosts (PostListPage에서 가져와도 되지만, 여기서는 독립적으로 정의)
const mockPosts = [
  { id: '1', title: 'React Router 8.3 핵심 기능', content: 'React Router 8.3의 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;

목록에서 상세와 댓글까지 이어 읽기

/posts?category=React는 다섯 가상 게시글 가운데 ID 1·2를 남깁니다. ID 1의 제목 링크를 누르면 /posts/1로 이동하고 상세 데이터에서 같은 문자열 ID를 조회합니다. 댓글 링크는 그 부모 route 아래 comments를 더해 /posts/1/comments가 됩니다. 목록의 검색 조건은 상세 링크에 포함하지 않았으므로 상세 URL에는 category가 남지 않습니다. 이전 목록 조건은 브라우저 뒤로 가기의 history 항목으로 돌아갈 때 다시 읽습니다.

이 목록의 setter는 검색 문자열 전체를 교체합니다. 예를 들어 ?category=React&tab=spec에서 CSS 버튼을 누르면 새 search는 ?category=CSS이며 tab은 지워집니다. 앞 절의 복제 기반 updateParam과 달리 이 실습은 category 하나만 사용하는 계약입니다.

입력 오류와 데이터 없음 따로 풀기

최종 route tree와 가상 데이터는 변경하지 않습니다. /posts/01/comments, /posts/999/comments, /posts/2/comments, /posts?category=Unknown, /no-such-route의 결과를 구별합니다. 앞의 두 주소에서 댓글이 렌더되지 않는 이유도 적어 보세요.

각 실패를 소유하는 컴포넌트의 해설

첫 주소는 상세·댓글 branch가 매칭되지만 부모가 01의 형식을 거절하여 잘못된 주소 상태를 조기에 반환합니다. 두 번째는 형식을 통과해 조회한 뒤 데이터가 없어 게시글 없음 상태를 반환합니다. 두 경우 모두 그 반환값에 Outlet이 없으므로 댓글 자식을 렌더하지 않습니다. client wildcard가 이 두 실패를 처리하는 것이 아닙니다.

세 번째는 ID 2의 상세와 댓글 한 개가 함께 보입니다. 미지원 category는 All로 해석해 전체 다섯 개를 보여 주고 URL을 자동으로 다시 쓰지는 않습니다. 마지막 주소만 선언된 구체 branch가 없어 top-level NotFound 화면을 선택합니다. HTTP 상태는 별도 호스트 응답입니다.