본문 바로가기

간단한 다중 경로 SPA 만들기

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

6장에서는 React Router의 핵심 컴포넌트와 훅으로 정적·동적 라우팅을 설정하고, 경로 매개변수와 쿼리 문자열로 데이터를 전하며, 여러 화면이 레이아웃을 나누는 중첩 라우팅까지 익혔습니다. 이 실습에서는 이 지식을 모아 간단한 블로그 애플리케이션(simple blog application)을 직접 만듭니다. 예제는 React Router 8.5를 기준으로 하며, DOM용 컴포넌트와 훅도 모두 react-router에서 가져옵니다.

실습은 이 글에서 URL의 규칙과 공통 파일을 준비한 뒤, 목록·상세 라우트에서 홈과 목록, 상세 화면을, 댓글·작성·fallback 라우트에서 댓글과 작성, 찾을 수 없음 화면과 전체 라우트를 만드는 순서로 이어집니다. 각 라우팅 개념이 실제 상황에서 어떻게 쓰이는지, 여러 기법을 섞어 하나의 사용자 경험을 만드는 방법을 체감해 보세요.

블로그의 정상 URL과 실패 상태를 함께 고정합니다

REACT ROUTING · URL & RESPONSIBILITY

블로그의 정상 URL과 실패 상태를 함께 고정합니다

홈·목록·작성·상세·댓글·client fallback의 여섯 계약을 먼저 정합니다. 동적 ID의 형식·조회 실패와 client wildcard, host의 HTTP 상태는 각각 확인합니다.

실제 실습의 입력 정책 · 정상과 실패를 나누기

/posts?category=React
→ React 게시글 2개
/posts?category=Unknown
→ All · 전체 5개
/posts/abc → 형식 오류
/posts/999 → 조회 실패

/posts/new는 정적 작성 route가 선택됩니다. category 생략·미지원 값은 본문의 목록 컴포넌트에서 All로 해석하며, 동적 입력 오류는 상세 컴포넌트가 처리합니다.

블로그 실습의 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 선택

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

외부 입력

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

첫 문서 요청

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

위 표는 블로그 실습의 여섯 가지 URL과 각 URL에서 먼저 확인할 실패를 정리합니다. /는 정적 라우트인 홈으로, 루트 링크의 예외 규칙 덕분에 다른 URL에서는 홈 링크가 비활성이 되는지 봅니다. /posts는 ?category=React 같은 쿼리를 받는 목록으로, 쿼리가 없거나 지원하지 않는 값일 때 기본 목록이나 분명한 오류를 보여 주는지 확인합니다. /posts/new는 정적 new 조각을 가진 작성 화면으로, 동적인 :postId보다 구체적인 정적 가지가 선택되는지 봅니다.

/posts/:postId는 useParams로 ID를 읽는 상세 화면으로, 형식이 잘못된 ID와 존재하지 않는 게시글을 구별하는지 확인합니다. /posts/:postId/comments는 상세 화면의 Outlet에 들어가는 댓글 화면으로, 부모 상세와 자식 댓글이 함께 렌더링되는지 봅니다. 마지막 *는 알 수 없는 클라이언트 경로로, 앱의 찾을 수 없음 UI와 호스트의 HTTP 404를 혼동하지 않는지 확인합니다.

그림 위쪽의 예는 이 실습의 입력 규칙입니다. /posts?category=React는 React 게시글 두 개를, 지원하지 않는 /posts?category=Unknown은 All로 해석해 전체 다섯 개를 보여 줍니다. /posts/abc는 형식 오류, /posts/999는 조회 실패이며, /posts/new는 정적인 작성 라우트가 선택됩니다. 그림 아래의 설명처럼 정적 조각은 동적 조각보다 구체성이 높아 먼저 맞으므로 선언 순서를 충돌의 해결 규칙으로 삼지 않으며, postId와 category는 URL에서 온 문자열이므로 검증해서 써야 합니다.


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

이 실습은 라우팅 기능이 실제 사용자의 동선에서 어떻게 실패하는지를 먼저 점검하는 방식으로 진행합니다. 잘못되었거나 존재하지 않는 동적 매개변수, 쿼리 문자열이 빠지거나 틀린 경우, 그리고 클라이언트의 대체 화면과 HTTP 404의 차이를 핵심 점검 대상으로 둡니다. /posts는 목록의 경로이므로 postId가 빠진 상세 경로가 아니라 별도의 정상 화면입니다.

이를 위해 먼저 홈과 목록, 작성, 상세, 댓글, 대체 화면의 URL을 위의 표처럼 정해 둡니다. 그다음 개별 게시글의 상세 경로 /posts/:postId를 useParams로 구현하며 매개변수의 형식과 조회 결과를 검사하고, 블로그 목록의 카테고리 필터를 useSearchParams로 구현하며 값이 없을 때의 기본 목록과 허용하지 않는 값의 처리를 정합니다. 게시글 상세 화면 안에 댓글 화면을 렌더링하는 자식 라우트를 Outlet으로 설계하고, 게시글 저장에 성공하면 useNavigate로 목록 화면으로 이동하며, 내비게이션과 게시글 목록의 링크는 Link와 NavLink로 만들어 활성 범위를 확인합니다.


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

준비 단계에서는 구현 순서보다 먼저 각 URL의 화면과 입력, 실패 상태를 정합니다. /posts/new와 /posts/:postId가 모두 /posts 아래에 있어도 React Router는 가지의 구체성을 계산하므로, 정적 new 조각이 동적 :postId보다 먼저 맞습니다. 이 충돌을 피하려고 선언 순서에 기대지 않습니다. 알 수 없는 클라이언트 경로의 path="*" 화면과, 주소창으로 직접 들어올 때 호스트가 돌려주는 상태 코드와 재작성 규칙도 따로 시험합니다.

Vite로 만든 프로젝트가 있다고 가정하고, src 폴더에 다음 구조로 파일을 만듭니다. JSX가 들어 있는 파일은 모두 .jsx로 저장하며, 다음 두 글의 코드 제목도 이 이름을 따릅니다.

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

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

블로그의 기본 레이아웃과 글꼴 스타일을 정의합니다. 이 파일은 Vite 템플릿의 src/index.css를 바꾸는 전역 스타일이라서, main.jsx에서 불러온 뒤로는 모든 화면에 적용됩니다. 아래의 #root는 index.html에서 React 앱이 그려지는 div의 id입니다.

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;
}

#root를 세로 방향의 Flexbox로 두고 .main-content에 flex-grow: 1을 주어, 내용이 짧아도 본문 영역이 남은 높이를 채우게 했습니다. .button 클래스는 뒤의 화면들에서 링크와 버튼에 같은 모양을 입히는 데 씁니다.


내비게이션 바 (Navbar.jsx)

NavLink로 블로그의 주요 화면으로 이동하는 링크를 만듭니다. 이번 Navbar는 별도의 CSS 클래스 대신 style에 함수를 넘기는 방식을 씁니다. 세 링크의 to는 뒤에서 선언할 홈, 게시글 목록, 새 게시글 작성 라우트의 경로이며, 그중 /posts와 /posts/new는 앞부분이 같아 활성 표시가 겹칠 수 있다는 점을 코드 뒤에서 다룹니다.

src/components/Navbar.jsx
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;

style에 넘긴 함수는 isActive에 따라 활성 링크의 글자색과 배경색을 바꿉니다. 홈 링크는 end 없이도 루트에서만 활성이 됩니다. 모든 URL이 /로 시작하므로 React Router가 to="/"인 NavLink를 예외로 다루기 때문입니다(React Router의 NavLink). 반면 게시글 목록 링크에는 end를 붙였으므로 /posts에서만 활성이 되고, /posts/new나 /posts/1에서는 비활성이 됩니다. 활성 링크에는 aria-current="page"도 저절로 붙습니다.


정리

  • 블로그 실습은 홈, 목록, 작성, 상세, 댓글, 대체 화면의 여섯 URL과 각각의 실패 상태를 먼저 정한 뒤 구현합니다.
  • /posts/new처럼 정적 조각을 가진 가지는 동적 :postId보다 구체적이어서 선언 순서와 상관없이 먼저 맞습니다.
  • 형식이 잘못된 ID, 존재하지 않는 게시글, 알 수 없는 경로, 호스트의 HTTP 404는 서로 다른 곳에서 처리하는 서로 다른 실패입니다.
  • NavLink의 end는 하위 경로에서 활성으로 보이지 않게 하며, to="/"인 링크는 예외로 루트에서만 활성이 됩니다.

연습 문제

1. 내비게이션의 활성 링크

Navbar에서 /posts/new에 있을 때 활성으로 보이는 링크는 무엇일까요? 게시글 목록 링크의 end를 지우면 어떻게 달라질까요?

해설 보기

/posts/new에서는 “새 게시글 작성” 링크만 활성입니다. 홈 링크는 루트에서만 활성이 되고, 게시글 목록 링크는 end 때문에 정확히 /posts일 때만 활성이 됩니다.

end를 지우면 /posts/new도 /posts로 시작하므로 게시글 목록 링크까지 활성이 되어, 두 링크가 동시에 강조되고 둘 다 aria-current="page"를 가지게 됩니다.

2. 실패를 나눠 보기

/posts/abc, /posts/999, /unknown에 앱 안의 링크로 이동했을 때, 각 실패는 어떤 라우트와 컴포넌트가 처리해야 할까요?

해설 보기

/posts/abc와 /posts/999는 둘 다 /posts/:postId 상세 라우트에 맞습니다. abc는 상세 컴포넌트의 형식 검사에서 걸리는 형식 오류이고, 999는 형식은 맞지만 데이터에 없는 조회 실패입니다. /unknown은 어떤 라우트에도 맞지 않으므로 path="*"의 찾을 수 없음 화면이 처리합니다.

이 세 가지는 모두 앱이 실행된 뒤 클라이언트에서 고르는 화면이며, 주소창으로 직접 들어왔을 때 호스트가 돌려주는 HTTP 상태 코드와는 따로 확인해야 합니다.

다음 글 목록·상세 라우트에서는 홈과 게시글 목록, 상세 화면을 경로 매개변수와 쿼리로 구성합니다.