icon

안동민 개발노트

6장 : React 라우팅 기초

간단한 다중 페이지 앱 만들기


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

이번 실습에서는 이 지식을 통합해 간단한 블로그 애플리케이션(Simple Blog Application)을 직접 구축해봅니다. 이 실습을 통해 각 라우팅 개념이 실제 시나리오에서 어떻게 적용되는지 체감하고, 여러 라우팅 기법을 혼합해 완성도 높은 사용자 경험을 제공하는 방법을 경험할 수 있습니다.


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

이번 실습은 라우팅 기능이 실제 사용자 동선에서 어떻게 실패하는지 먼저 점검하는 방식으로 진행합니다. 동적 파라미터 누락, 쿼리 문자열 누락, 404 전환을 핵심 점검 대상으로 둡니다.

기본 라우팅: 홈, 블로그 목록, 게시글 작성 페이지를 위한 기본 경로 설정.

동적 라우팅 (useParams): 개별 게시글의 상세 페이지 (/posts/:postId) 구현.

쿼리 문자열 (useSearchParams): 블로그 목록에서 카테고리 필터링 기능 구현.

중첩 라우팅 (Outlet): 게시글 상세 페이지 내에서 댓글 영역 등의 하위 콘텐츠를 위한 중첩 라우트 구조 설계. (간단하게 구현)

useNavigate: 게시글 작성 후 목록 페이지로 이동하는 기능 구현.

Link & NavLink: 내비게이션 및 게시글 목록에서 링크 생성.

준비 단계: 경로 우선순위 정리

준비 단계에서는 입력 분포 기준으로 우선순위를 잡습니다. 조회 요청이 가장 빈번하므로 목록/상세 라우트를 먼저 안정화하고, 작성/삭제 동작은 그다음에 연결합니다.

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-dom';

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" style={navLinkStyle}>게시글 목록</NavLink>
      <NavLink to="/posts/new" style={navLinkStyle}>새 게시글 작성</NavLink>
    </nav>
  );
}

export default Navbar;

라우트 페이지 구성

각 라우트에 해당하는 페이지 컴포넌트들을 만듭니다.

HomePage.js (기본 라우팅)

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

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를 사용합니다.

src/pages/PostListPage.js
import React, { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';

// (가상 데이터) 블로그 게시글 목록
const mockPosts = [
  { id: '1', title: 'React Router v6 핵심 기능', content: 'React Router v6의 새로운 기능들을 알아봅시다.', 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 [filteredPosts, setFilteredPosts] = useState([]);

  const currentCategory = searchParams.get('category') || 'All';

  useEffect(() => {
    let postsToShow = mockPosts;
    if (currentCategory !== 'All') {
      postsToShow = mockPosts.filter(post => post.category === currentCategory);
    }
    setFilteredPosts(postsToShow);
  }, [currentCategory]);

  const categories = ['All', ...new Set(mockPosts.map(post => post.category))];

  const handleCategoryChange = (category) => {
    if (category === 'All') {
      searchParams.delete('category');
    } else {
      searchParams.set('category', category);
    }
    setSearchParams(searchParams);
  };

  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 (중첩 라우팅 부모)

게시글 상세 내용을 표시하고, 하단에 댓글 목록을 위한 중첩 라우트를 설정합니다.

src/pages/PostDetailPage.js
import React, { useEffect, useState } from 'react';
import { useParams, Outlet, Link, useNavigate } from 'react-router-dom';

// mockPosts (PostListPage에서 가져와도 되지만, 여기서는 독립적으로 정의)
const mockPosts = [
  { id: '1', title: 'React Router v6 핵심 기능', content: 'React Router v6의 새로운 기능들을 알아봅시다. 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 PostDetailPage() {
  const { postId } = useParams(); // 라우트 파라미터에서 postId 추출
  const navigate = useNavigate();
  const [post, setPost] = useState(null);

  useEffect(() => {
    // 실제 앱에서는 postId로 서버에서 게시글 데이터를 가져올 것입니다.
    const foundPost = mockPosts.find(p => p.id === postId);
    if (foundPost) {
      setPost(foundPost);
    } else {
      // 게시글을 찾을 수 없으면 404 페이지 또는 목록으로 리다이렉트
      navigate('/404');
    }
  }, [postId, navigate]);

  if (!post) {
    return <div className="text-center">게시글을 불러오는 중이거나 찾을 수 없습니다...</div>;
  }

  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={`/posts/${postId}/comments`} className="button secondary">댓글 보기</Link>
        <Link to={`/posts/${postId}`} className="button secondary" style={{ marginLeft: '10px' }}>게시글로 돌아가기</Link>
      </div>

      {/* Outlet: 중첩 라우트의 콘텐츠가 여기에 렌더링됩니다. */}
      <Outlet />
    </div>
  );
}

export default PostDetailPage;

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 직접 입력 -> 링크 이동 -> 뒤로/앞으로 이동 순서로 수행합니다. 이 순서를 고정하면 라우팅 상태 불일치와 히스토리 동작 문제를 빠르게 분리할 수 있습니다.

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

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

의존성 설치 확인: 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 라우팅 기초가 모두 끝났습니다.

목차