본문으로 건너뛰기

안동민 개발노트

본문 시작

간단한 다중 경로 SPA 만들기

블로그 SPA의 정적·동적·중첩·fallback 경로 계약을 먼저 세우고, 정상 이동과 실패 경계를 함께 검증합니다.

React Router의 핵심 컴포넌트와 훅을 사용해 정적·동적 라우팅을 설정하고, URL 파라미터와 쿼리 문자열로 데이터를 전달하며, 복잡한 UI를 위한 중첩 라우팅까지 이해하셨습니다.

이번 실습에서는 이 지식을 통합해 간단한 블로그 애플리케이션(Simple Blog Application)을 직접 구축합니다. 예제는 이 프로젝트에 설치된 React Router 8.3을 기준으로 하며, DOM용 컴포넌트와 훅도 react-router에서 가져옵니다.

이 실습을 통해 각 라우팅 개념이 실제 시나리오에서 어떻게 적용되는지 체감하고, 여러 라우팅 기법을 혼합해 완성도 높은 사용자 경험을 제공하는 방법을 경험할 수 있습니다.

블로그 SPA에서 홈, 목록, 작성, 상세, 댓글, fallback URL을 화면과 입력에 연결하고 파라미터, 쿼리, 직접 진입 실패를 분리해 검증하는 경로 계약

React Router 8.3 · route contract first

URL마다 정상 화면과 실패 상태를 함께 정합니다. 정적·동적 route의 구체성, 화면이 읽는 외부 입력, 중첩 렌더, host의 첫 문서 응답을 서로 다른 계약으로 검증합니다.

블로그 실습의 route inventory
URL 패턴 화면·입력 먼저 검증할 실패
/ 홈 · 정적 route 루트 링크의 예외 규칙으로 다른 URL에서 홈이 비활성화되는가
/posts 목록 · ?category=React 검색값 생략·미지원 값에 기본 목록 또는 명시적 오류가 있는가
/posts/new 작성 · 정적 new :postId보다 구체적인 정적 branch가 선택되는가
/posts/:postId 상세 · useParams 잘못된 형식과 존재하지 않는 게시물을 구분하는가
/posts/:postId/comments 상세의 Outlet · 댓글 부모 상세와 자식 댓글이 함께 렌더되는가
* 알 수 없는 client route 앱의 NotFound UI와 host의 HTTP 404가 혼동되지 않는가
  1. / · 홈

    루트 링크는 예외적으로 정확한 루트 URL에서만 활성화됩니다.

  2. /posts · 목록

    선택적인 category 검색값의 생략·오류 정책을 정합니다.

  3. /posts/new · 작성

    정적 new branch는 동적 :postId보다 구체적입니다.

  4. /posts/:postId · 상세

    파라미터 형식과 게시물 존재 여부를 각각 검사합니다.

  5. /posts/:postId/comments · 댓글

    부모 상세의 Outlet에 자식 화면이 렌더되는지 확인합니다.

  6. * · client fallback

    NotFound UI와 host가 반환하는 실제 HTTP 상태를 분리합니다.

branch 선택

정적 세그먼트가 동적 세그먼트보다 높은 구체성으로 매칭됩니다. 선언 순서를 충돌 해결 규칙으로 삼지 않습니다.

외부 입력

postIdcategory는 URL에서 온 문자열입니다. 형식·허용 범위·조회 실패를 화면 계약에 포함합니다.

첫 문서 요청

주소창 직접 진입은 host가 먼저 처리합니다. client path="*"만으로 rewrite나 실제 HTTP 404가 결정되지는 않습니다.


실습 목표: 라우팅 실패 시나리오 대응

이번 실습은 라우팅 기능이 실제 사용자 동선에서 어떻게 실패하는지 먼저 점검하는 방식으로 진행합니다.

잘못됐거나 존재하지 않는 동적 파라미터, 쿼리 문자열의 생략·오류, 클라이언트 fallback과 HTTP 404의 경계를 핵심 점검 대상으로 둡니다. /posts는 목록 경로이므로 postId가 빠진 상세 경로가 아니라 별도의 정상 화면입니다.

경로 계약: 홈, 목록, 작성, 상세, 댓글, fallback URL을 먼저 표로 고정합니다.

동적 라우팅 (useParams): 개별 게시글 상세 경로 (/posts/:postId)를 구현하고 파라미터 형식과 조회 결과를 검사합니다.

쿼리 문자열 (useSearchParams): 블로그 목록의 카테고리 필터를 구현하고, 값이 없을 때의 기본 목록과 허용하지 않는 값의 처리 방법을 정합니다.

중첩 라우팅 (Outlet): 게시글 상세 페이지 안에 댓글 화면을 렌더하는 자식 route를 설계합니다.

useNavigate: 게시글 저장이 성공한 뒤 목록 페이지로 이동합니다.

LinkNavLink: 내비게이션과 게시글 목록의 링크를 만들고 활성 범위를 검증합니다.


준비 단계: URL 계약과 실패 상태 정리

준비 단계에서는 구현 순서보다 먼저 각 URL의 화면, 입력, 실패 상태를 정합니다.

/posts/new/posts/:postId가 모두 /posts 아래에 있어도 React Router는 route branch의 구체성을 계산하므로 정적 new 세그먼트가 동적 :postId보다 우선합니다. 이 충돌을 피하려고 선언 순서에 의존하지 않습니다. 알 수 없는 client route의 path="*" 화면과 주소창 직접 진입에 응답하는 host의 상태 코드·rewrite 정책도 별도로 시험합니다.

Vite로 생성된 프로젝트가 있다고 가정합니다.

src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

App.js
index.css # 전역/기본 스타일
Navbar.js
HomePage.js
PostListPage.js
PostDetailPage.js
PostComments.js # 중첩 라우팅 예시
NewPostPage.js
NotFoundPage.js

라우팅 화면용 전역 스타일 조정 (index.css)

블로그의 기본적인 레이아웃 및 폰트 스타일을 정의합니다.

src/index.css
body {
  font-family: 'Arial', sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f4f7f6;
  color: #333;
  line-height: 1.6;
}

#root {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.main-content {
  flex-grow: 1;
  padding: 20px;
  max-width: 960px;
  margin: 20px auto;
  background-color: #ffffff;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
}

h1, h2, h3 {
  color: #2c3e50;
}

/* 유틸리티 스타일 */
.text-center {
  text-align: center;
}

.button {
  display: inline-block;
  padding: 10px 20px;
  background-color: #3498db;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  text-decoration: none;
  font-size: 1em;
  transition: background-color 0.2s ease;
}

.button:hover {
  background-color: #2980b9;
}

.button.secondary {
  background-color: #7f8c8d;
}
.button.secondary:hover {
  background-color: #616e78;
}

내비게이션 바 (Navbar.js)

NavLink를 사용하여 블로그의 주요 페이지로 이동하는 링크를 만듭니다.

src/components/Navbar.js
import React from 'react';
import { NavLink } from 'react-router';

function Navbar() {
  const navLinkStyle = ({ isActive }) => {
    return {
      padding: '10px 15px',
      textDecoration: 'none',
      color: isActive ? '#fff' : '#c0c0c0',
      backgroundColor: isActive ? '#34495e' : 'transparent',
      borderRadius: '5px',
      transition: 'all 0.3s ease',
      fontWeight: isActive ? 'bold' : 'normal',
    };
  };

  return (
    <nav style={{
      backgroundColor: '#2c3e50',
      padding: '15px 0',
      boxShadow: '0 4px 8px rgba(0,0,0,0.1)',
      display: 'flex',
      justifyContent: 'center',
      gap: '25px',
    }}>
      <NavLink to="/" style={navLinkStyle}>홈</NavLink>
      <NavLink to="/posts" end style={navLinkStyle}>게시글 목록</NavLink>
      <NavLink to="/posts/new" style={navLinkStyle}>새 게시글 작성</NavLink>
    </nav>
  );
}

export default Navbar;

이어서 보기