본문으로 건너뛰기

안동민 개발노트

본문 시작
6장 : React 라우팅 기초간단한 다중 페이지 앱 만들기

댓글·작성·404 라우트

댓글과 작성 및 404 화면을 연결하고 전체 라우터와 히스토리 동작을 검증합니다.

PostComments.js (중첩 라우팅 자식)

PostDetailPage 내부에 렌더링될 댓글 목록 컴포넌트입니다.

src/pages/PostComments.js
import React from 'react';
import { useParams } from 'react-router-dom';

// (가상 데이터) 댓글
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 = 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)

새 게시글을 작성하고 제출 후 목록 페이지로 리다이렉트합니다.

src/pages/NewPostPage.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';

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 (404 처리)

일치하는 라우트가 없을 때 표시될 404 페이지입니다.

src/pages/NotFoundPage.js
import React from 'react';
import { Link } from 'react-router-dom';

function NotFoundPage() {
  return (
    <div className="text-center" style={{ padding: '50px 20px', backgroundColor: '#fefefe', borderRadius: '8px' }}>
      <h2 style={{ color: '#e74c3c', fontSize: '3em', marginBottom: '15px' }}>404</h2>
      <p style={{ fontSize: '1.5em', color: '#555', marginBottom: '30px' }}>
        페이지를 찾을 수 없습니다.
      </p>
      <Link to="/" className="button secondary">홈으로 돌아가기</Link>
    </div>
  );
}

export default NotFoundPage;

App.js (최종 라우터 설정)

모든 라우트를 설정하고 BrowserRouter로 감쌉니다.

src/App.js
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
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>

          {/* 일치하는 라우트가 없을 때 (404 Not Found) */}
          <Route path="*" element={<NotFoundPage />} />
        </Routes>
      </div>
    </BrowserRouter>
  );
}

export default App;

검증 순서 및 확인 사항: URL/히스토리 점검

실습 검증은 URL 직접 입력 -> 링크 이동 -> 뒤로/앞으로 이동 순서로 수행합니다.

이 순서를 고정하면 라우팅 상태 불일치와 히스토리 동작 문제를 빠르게 분리할 수 있습니다.

검증할 때는 기능 이름보다 URL이 어떤 상태를 만들고 어떤 화면을 렌더링해야 하는지 기준을 세우는 편이 좋습니다.

다음 검증 기준표로 통합 실습의 정상 동작과 실패 신호를 함께 확인해 보세요.

프로젝트 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.

코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다.

의존성 설치 확인: react-router-dom이 설치되어 있는지 확인합니다. (npm install react-router-dom 또는 yarn add react-router-dom)

애플리케이션 실행: npm run dev (또는 yarn dev) 명령어를 실행하여 개발 서버를 시작합니다.

라우팅 기능 테스트
  • 홈 (/): 환영합니다! 메시지가 표시되는지 확인.
  • 게시글 목록 (/posts)
    • 게시글들이 표시되는지 확인.
    • 상단 카테고리 버튼을 클릭하여 URL 쿼리 문자열 (?category=React 등)이 변경되고, 목록이 필터링되는지 확인.
    • 검색 버튼 클릭 시 URL이 변경되고 필터링된 결과가 나오는지 확인.
    • 각 게시글 제목을 클릭하면 상세 페이지로 이동하는지 확인.
  • 게시글 상세 (/posts/:postId)
    • http://localhost:5173/posts/1 등으로 직접 접근하거나, 목록에서 클릭하여 상세 페이지가 표시되는지 확인.
    • URL 파라미터 (postId)가 올바르게 인식되어 해당 게시글 정보가 로드되는지 확인.
    • 댓글 보기 버튼을 클릭하여 URL이 /posts/1/comments 등으로 변하고, 게시글 아래에 댓글 목록이 표시되는지 확인. (Outlet을 통한 중첩 라우팅)
    • 게시글로 돌아가기 버튼 클릭 시 /posts/1 (부모 라우트)로 다시 돌아와 댓글 부분이 사라지는지 확인.
  • 새 게시글 작성 (/posts/new)
    • 폼이 표시되는지 확인.
    • 폼을 작성하고 게시글 제출 버튼을 클릭하면 alert 메시지 후 게시글 목록 페이지로 리다이렉트되는지 확인. (useNavigate 활용)
  • 404 페이지 (/*)
    • 존재하지 않는 URL (예: http://localhost:5173/abcd)로 접근했을 때 404 Not Found 페이지가 표시되는지 확인.

이제 React Router의 여러 기능을 조합해 간단한 웹 애플리케이션을 구성했습니다.

이번 실습을 통해,

  • 기본 라우팅, 동적 라우팅, 쿼리 문자열 처리, 중첩 라우팅의 실용적인 적용법을 익혔습니다.
  • useParams, useSearchParams, useNavigate 훅의 역할을 확인했습니다.
  • Link, NavLink, Routes, Route, Outlet 컴포넌트의 역할을 명확히 이해했습니다.

이 실습에서는 기본 라우팅, 동적 라우팅, 쿼리 문자열, 중첩 라우팅을 하나의 예제로 연결했습니다.

이로써 6장 React 라우팅 기초가 모두 끝났습니다.

블로그 실습은 파일을 많이 만드는 과제처럼 보이지만, 실제로는 URL별 정상 화면과 실패 화면을 빠짐없이 묶는 연습입니다.


다중 페이지 앱은 링크 클릭뿐 아니라 직접 URL 접근, 새로고침, 404 처리까지 같이 검증해야 합니다.

실습 목표: 라우팅 실패 시나리오 대응과 경로 우선순위 정리를 중심으로 정리한 보조 다이어그램입니다.