icon
6장 : React 라우팅 기초

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

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

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


실습 목표

  1. 기본 라우팅: 홈, 블로그 목록, 게시글 작성 페이지를 위한 기본 경로 설정.
  2. 동적 라우팅 (useParams): 개별 게시글의 상세 페이지 (/posts/:postId) 구현.
  3. 쿼리 문자열 (useSearchParams): 블로그 목록에서 카테고리 필터링 기능 구현.
  4. 중첩 라우팅 (Outlet): 게시글 상세 페이지 내에서 댓글 영역 등의 하위 콘텐츠를 위한 중첩 라우트 구조 설계. (간단하게 구현)
  5. useNavigate: 게시글 작성 후 목록 페이지로 이동하는 기능 구현.
  6. Link & NavLink: 내비게이션 및 게시글 목록에서 링크 생성.

프로젝트 준비

create-react-app으로 생성된 프로젝트가 있다고 가정합니다. src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

src/
├── App.js
├── index.css (전역/기본 스타일)
├── components/
│   └── Navbar.js
├── pages/
│   ├── HomePage.js
│   ├── PostListPage.js
│   ├── PostDetailPage.js
│   ├── PostComments.js (중첩 라우팅 예시)
│   ├── NewPostPage.js
│   └── NotFoundPage.js

전역 스타일링 (index.css)

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

src/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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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;

실습 진행 방법 및 확인 사항

  1. 프로젝트 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
  2. 코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다.
  3. 의존성 설치 확인: react-router-dom이 설치되어 있는지 확인합니다. (npm install react-router-dom 또는 yarn add react-router-dom)
  4. 애플리케이션 실행: npm start (또는 yarn start) 명령어를 실행하여 개발 서버를 시작합니다.
  5. 라우팅 기능 테스트
    • 홈 (/): "환영합니다!" 메시지가 표시되는지 확인.
    • 게시글 목록 (/posts)
      • 게시글들이 표시되는지 확인.
      • 상단 카테고리 버튼을 클릭하여 URL 쿼리 문자열 (?category=React 등)이 변경되고, 목록이 필터링되는지 확인.
      • 검색 버튼 클릭 시 URL이 변경되고 필터링된 결과가 나오는지 확인.
      • 각 게시글 제목을 클릭하면 상세 페이지로 이동하는지 확인.
    • 게시글 상세 (/posts/:postId)
      • http://localhost:3000/posts/1 등으로 직접 접근하거나, 목록에서 클릭하여 상세 페이지가 표시되는지 확인.
      • URL 파라미터 (postId)가 올바르게 인식되어 해당 게시글 정보가 로드되는지 확인.
      • "댓글 보기" 버튼을 클릭하여 URL이 /posts/1/comments 등으로 변하고, 게시글 아래에 댓글 목록이 표시되는지 확인. (Outlet을 통한 중첩 라우팅)
      • "게시글로 돌아가기" 버튼 클릭 시 /posts/1 (부모 라우트)로 다시 돌아와 댓글 부분이 사라지는지 확인.
    • 새 게시글 작성 (/posts/new)
      • 폼이 표시되는지 확인.
      • 폼을 작성하고 "게시글 제출" 버튼을 클릭하면 alert 메시지 후 게시글 목록 페이지로 리다이렉트되는지 확인. (useNavigate 활용)
    • 404 페이지 (/*)
      • 존재하지 않는 URL (예: http://localhost:3000/abcd)로 접근했을 때 "404 Not Found" 페이지가 표시되는지 확인.

이제 여러분은 React Router의 다양한 기능을 통합하여 실제 작동하는 간단한 웹 애플리케이션을 성공적으로 구축하셨습니다.

이번 실습을 통해,

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

이 실습은 여러분이 리액트 애플리케이션의 핵심인 라우팅 기능을 효과적으로 구현하고, 사용자에게 매끄러운 내비게이션 경험을 제공하는 데 큰 도움이 될 것입니다.

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