본문으로 건너뛰기

안동민 개발노트

본문 시작

목록·상세 라우트

홈과 게시물 목록·상세 화면을 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 기준의 상대 경로를 사용합니다.

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;