본문으로 건너뛰기

안동민 개발노트

본문 시작

댓글·작성·fallback 라우트

댓글과 작성 및 client fallback 화면을 연결하고, route 입력 오류와 host HTTP 404 경계를 검증합니다.

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

PostDetailPage<Outlet /> 내부에 렌더링될 댓글 목록 컴포넌트입니다. 자식 route는 부모의 동적 파라미터를 상속하므로 comments 경로를 선언할 때 :postId를 다시 적지 않아도 useParams로 읽을 수 있습니다. 부모가 형식과 게시글 존재 여부를 먼저 검증하므로 이 컴포넌트는 정상 게시글의 댓글 상태만 담당합니다.

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

NewPostPage.js (useNavigate)

새 게시글을 작성하고 저장 성공 후 목록 페이지로 이동합니다. useNavigate는 폼 검증과 서버 저장을 대체하지 않으므로 실제 앱에서는 POST 요청이 성공한 시점에만 호출해야 합니다.

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

NotFoundPage.js (client fallback)

선언한 구체 route가 하나도 매칭되지 않을 때 path="*"가 표시하는 client fallback 화면입니다. 이미 앱 문서가 로드된 뒤 React가 렌더하는 UI이므로 이 컴포넌트만으로 document의 HTTP 상태가 404로 바뀌지는 않습니다.

src/pages/NotFoundPage.js
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.js (최종 라우터 설정)

블로그 SPA의 route branch를 정적, 동적, wildcard로 나누고 게시글 상세 부모가 입력 검증과 댓글 Outlet을 소유하는 중첩 route 트리

React Router 8.3 · nested route ownership

URL 계층과 렌더 계층은 부모의 Outlet에서 만납니다. 상세 route가 postId를 검증한 뒤에만 상대 child comments를 열고, 자식은 부모 파라미터를 그대로 상속합니다.

블로그 SPA의 route branch와 중첩 댓글 route 트리 Routes 아래에 홈, 목록, 작성, 동적 상세, wildcard가 형제로 있고, comments는 동적 상세의 자식으로 연결되어 부모 Outlet에 렌더됩니다. <Routes> branch를 점수로 선택 / HomePage /posts 목록 · search /posts/new 정적 · 작성 /posts/:postId 형식 → 조회 → 상세 * client NotFound comments 상대 child path 부모 <Outlet />
  1. /posts · 목록

    category 검색값을 허용 목록으로 검증합니다.

  2. /posts/new · 정적 작성

    정적 세그먼트라서 동적 :postId보다 구체적인 branch입니다.

  3. /posts/:postId · 동적 상세

    형식 오류와 올바른 형식의 조회 실패를 부모가 서로 다르게 렌더합니다.

  4. comments · 상대 child

    부모의 정상 상세와 함께 Outlet에 렌더되고 postId를 상속합니다.

  5. * · 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은 부모 경로와 결합됩니다.

모든 라우트를 설정하고 BrowserRouter로 감쌉니다. React Router는 route branch의 구체성을 계산하므로 정적 /posts/new가 동적 /posts/:postId보다 선택 우선순위가 높습니다. 따라서 이 정적·동적 충돌은 선언 순서와 무관하게 해소됩니다. 상대 child path인 comments는 부모 경로에 이어지고, 매칭된 자식 element는 PostDetailPage<Outlet />에 렌더됩니다.

src/App.js
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, 오류, host 경계 검증

실습 검증은 주소창 직접 입력 -> Link 또는 useNavigate 이동 -> 뒤로/앞으로 이동 순서로 수행합니다. 첫 단계는 host가 문서를 반환하는지, 두 번째 단계는 이미 로드된 앱 안에서 어떤 branch가 선택되는지, 마지막 단계는 history가 URL과 화면을 함께 복원하는지를 각각 확인합니다.

BrowserRouter는 브라우저의 pathname과 history를 사용하지만 정적 host의 rewrite 규칙을 만들지는 않습니다. 배포 환경은 SPA가 맡는 document 탐색 요청을 index.html로 보내야 합니다. 이 범위에는 client wildcard가 표시할 미등록 app path도 포함하되, 정적 asset 경로와 API 요청은 fallback에서 제외해야 합니다.

주소창 직접 요청은 host의 index.html fallback을 먼저 통과하지만 앱 내부 Link와 useNavigate는 이미 로드된 React Router에서 branch를 선택하며 client NotFound UI와 HTTP 404가 서로 다른 경계에 있는 흐름

BrowserRouter · document boundary

주소창 요청과 앱 내부 이동은 시작점이 다릅니다. 직접 진입은 host가 앱 문서를 반환해야 React Router에 도달하고, client wildcard는 앱이 로드된 뒤의 화면 선택일 뿐 HTTP 상태를 자동으로 바꾸지 않습니다.

host fallback과 React Router branch 선택 흐름 주소창이나 새로고침은 host rewrite 여부에 따라 index.html 또는 HTTP 404로 갈리고, index.html이 반환된 경우에만 React Router가 구체 branch나 client wildcard 화면을 선택합니다. Link와 useNavigate는 host 문서 요청 없이 React Router 단계로 들어갑니다. 아니요 아니요 주소창 · 새로고침 → host document GET /posts/1 SPA가 맡을 document 요청을 index.html로 fallback? host HTTP 404 React는 실행되지 않음 index.html 반환 정적 SPA document는 보통 200 Link · useNavigate 새 document 요청 없음 React Router 현재 pathname으로 branch 선택 구체 branch가 매칭되는가? route 화면 렌더 post route는 로컬 입력 검증 path="*" NotFound UI document status 자동 변경 없음
  1. 직접 진입은 host부터

    주소창과 새로고침은 먼저 document 요청을 보냅니다.

  2. 앱 URL은 index.html로 fallback

    fallback이 없으면 host HTTP 404에서 끝나고 React는 실행되지 않습니다.

  3. 앱 문서가 로드되면 branch 선택

    React Router가 pathname을 읽어 구체 route를 고릅니다.

  4. 동적 route는 입력을 다시 검증

    /posts/abc/posts/999는 둘 다 상세 branch 안의 서로 다른 상태입니다.

  5. 구체 route가 없으면 client wildcard

    path="*"는 NotFound UI를 렌더하지만 HTTP 상태를 자동 설정하지 않습니다.

  6. 앱 내부 이동은 Router부터

    LinkuseNavigate는 새 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로 바꾸지 않습니다.

패키지 확인: 이 프로젝트의 예제 기준은 react-router@8.3.0입니다. 별도 프로젝트라면 같은 패키지를 설치하고 모든 DOM 컴포넌트와 훅을 react-router에서 가져옵니다.

정상 route와 중첩 렌더: /, /posts, /posts/new, /posts/1을 열고 /posts/1/comments에서 상세 화면 아래 댓글이 함께 렌더되는지 확인합니다. 댓글에서 부모로 돌아오면 URL이 /posts/1이 되고 <Outlet />의 자식만 사라져야 합니다.

동적 입력 오류 분리: /posts/abc:postId branch가 매칭된 뒤 형식 오류를 표시하고, /posts/999는 올바른 형식이지만 조회 결과가 없다는 상태를 표시해야 합니다. 두 URL 모두 top-level path="*"의 NotFound 화면으로 넘기지 않습니다.

검색값 검증: /posts?category=React는 React 게시글만 표시하고, /posts/posts?category=Unknown은 이 예제의 정책에 따라 전체 목록을 표시하는지 확인합니다.

작성 성공 뒤 이동: /posts/new가 동적 상세가 아니라 정적 작성 branch를 선택하는지 확인합니다. 폼 제출 예제에서는 저장 성공을 모의한 뒤 useNavigate/posts에 이동합니다.

client fallback과 HTTP 상태 분리: 앱 안에서 /abcd로 이동하거나, host가 /abcdindex.html로 rewrite한 뒤 앱이 로드되면 path="*"의 NotFound UI가 렌더됩니다. 정적 SPA의 document 응답은 보통 200이며 이 UI가 HTTP 404를 자동 설정하지 않습니다. rewrite가 없으면 host가 실제 HTTP 404를 반환하고 React는 실행되지 않습니다.

history 복원: 링크 이동과 작성 후 이동을 수행한 다음 뒤로·앞으로 이동하며 pathname, 검색 문자열, 상세·댓글 조합이 함께 복원되는지 확인합니다.


이 실습의 핵심은 URL을 화면 이름에만 연결하지 않고 branch 선택, URL 입력 검증, 부모·자식 렌더 소유권, host의 첫 문서 응답까지 하나의 계약으로 다루는 것입니다. useParams, useSearchParams, useNavigate, Link, Routes, Route, Outlet은 이 계약에서 서로 다른 책임을 맡습니다.

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