next.js 동적 라우트(Dynamic Routes)

1 min read

현대 웹 애플리케이션은 정적인 페이지만으로 구성되지 않습니다. 쇼핑몰의 수많은 상품 페이지나 블로그의 개별 포스트처럼, URL의 일부가 동적으로 변하는 페이지를 효율적으로 처리하는 능력이 필수적입니다. Next.js의 ‘동적 라우트(Dynamic Routes)’는 바로 이러한 요구사항을 해결해주는 강력한 기능입니다.

이번 글에서는 동적 라우트를 구성하는 핵심 개념들을 알아보고, 실제 제품 관리 시스템 예제를 통해 어떻게 복잡한 URL 구조를 체계적으로 관리하는지 살펴보겠습니다.

📚 주요 개념 설명

Next.js에서 동적 라우팅을 구현하기 위해 사용되는 주요 파일 시스템 규칙은 다음과 같습니다.

  • 라우트 그룹 (Route Groups): (admin)과 같이 폴더 이름을 괄호로 묶으면, 해당 폴더는 URL 경로에 영향을 주지 않으면서 관련된 라우트들을 그룹으로 묶어주는 역할을 합니다. 파일을 체계적으로 정리하는 데 유용합니다.
  • 동적 세그먼트 (Dynamic Segments): [id]와 같이 폴더 이름을 대괄호로 묶으면, 해당 부분은 URL의 동적 매개변수(parameter)가 됩니다. 예를 들어 [id]1, abc, product-123 등 어떤 값이든 받아들일 수 있습니다.
  • 캐치올 세그먼트 (Catch-all Segments): [...slug]와 같은 형식은 /shop/a/b/c와 같이 여러 URL 세그먼트를 한 번에 잡아낼 때 사용됩니다.
  • 선택적 캐치올 세그먼트 (Optional Catch-all Segments): [[...slug]]와 같이 대괄호로 한 번 더 감싸면, 해당 세그먼트가 아예 없는 경우(-shop)까지도 처리할 수 있는 선택적 라우트가 됩니다.

💡 실제 사용 예제: 제품 관리 시스템

다양한 카테고리와 상품 ID를 가지는 제품 상세 페이지를 구현하는 예제를 통해 동적 라우트가 어떻게 활용되는지 알아보겠습니다.

파일 구조 및 URL 매핑

  • 파일 경로: app/(admin)/products/[category]/[id]/page.tsx
  • URL 구조: -/products/[카테고리명]/[제품ID]
  • URL 예시: -/products/electronics/123

이 구조에서 (admin)은 URL에 영향을 주지 않는 라우트 그룹이며, [category][id]는 각각 카테고리와 제품 ID를 동적으로 받아오는 동적 세그먼트입니다.

제품 상세 페이지 코드 (page.tsx)

// app/(admin)/products/[category]/[id]/page.tsx
// 이 파일은 제품 상세 페이지를 구현합니다.
'use client';

// 필요한 React 훅과 MUI 컴포넌트들을 가져옵니다
import { useEffect, useState } from 'react';
import {
  Box, Paper, Typography, Grid, TextField, Button,
  CircularProgress, Alert, Breadcrumbs, Link
} from '@mui/material';
import { ReactTabulator } from 'react-tabulator'; // 데이터 테이블 컴포넌트
import { useRouter } from 'next/navigation'; // Next.js 라우팅

// 제품 데이터의 타입을 정의합니다
interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  description: string;
  specifications: Record<string, string>;
  relatedProducts: {
    id: string;
    name: string;
    price: number;
    thumbnail: string;
  }[];
}

// 제품 상세 페이지 컴포넌트
export default function ProductPage({
  params // URL에서 추출한 파라미터 ([category]와 [id])
}: {
  params: { category: string; id: string }
}) {
  // 상태 관리를 위한 useState 훅들
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [saving, setSaving] = useState(false);
  const router = useRouter();

  // 컴포넌트가 마운트되거나 params가 변경될 때 제품 데이터를 불러옵니다
  useEffect(() => {
    async function loadProduct() {
      try {
        const response = await fetch(`/api/products/${params.category}/${params.id}`);
        if (!response.ok) {
          throw new Error('제품 정보를 불러올 수 없습니다');
        }
        const data = await response.json();
        setProduct(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : '알 수 없는 오류');
      } finally {
        setLoading(false);
      }
    }
    loadProduct();
  }, [params.category, params.id]);

  // 제품 정보 저장 핸들러
  const handleSave = async () => {
    if (!product) return;
    setSaving(true);
    try {
      const response = await fetch(
        `/api/products/${params.category}/${params.id}`,
        {
          method: 'PUT',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(product),
        }
      );
      if (!response.ok) {
        throw new Error('제품 정보를 저장할 수 없습니다');
      }
      router.refresh(); // 성공 시 페이지 새로고침
    } catch (err) {
      setError(err instanceof Error ? err.message : '알 수 없는 오류');
    } finally {
      setSaving(false);
    }
  };

  // 관련 제품 테이블의 컬럼 설정
  const columns = [
    { title: "제품명", field: "name", width: 200 },
    { title: "가격", field: "price", width: 150, formatter: "money", formatterParams: { symbol: "₩", precision: 0 } },
    { title: "썸네일", field: "thumbnail", width: 120, formatter: "image", formatterParams: { height: "50px" } }
  ];

  if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}><CircularProgress /></Box>;
  if (error) return <Box sx={{ p: 3 }}><Alert severity="error">{error}</Alert></Box>;
  if (!product) return null;

  // 메인 UI 렌더링
  return (
    <Box sx={{ p: 3 }}>
      {/* 현재 페이지 위치를 보여주는 브레드크럼 */}
      <Breadcrumbs sx={{ mb: 3 }}>
        <Link href="/products">제품</Link>
        <Link href={`/products/${params.category}`}>{params.category}</Link>
        <Typography color="text.primary">{product.name}</Typography>
      </Breadcrumbs>

      {/* 제품 정보 입력 폼 */}
      <Paper sx={{ p: 3, mb: 3 }}>
        <Typography variant="h6" gutterBottom>제품 정보</Typography>
        <Grid container spacing={3}>
          {/* 입력 필드들... (생략) */}
        </Grid>
        <Box sx={{ mt: 3, display: 'flex', justifyContent: 'flex-end' }}>
          <Button variant="contained" onClick={handleSave} disabled={saving}>
            {saving ? '저장 중...' : '저장'}
          </Button>
        </Box>
      </Paper>

      {/* 관련 제품 목록 */}
      <Paper sx={{ p: 3 }}>
        <Typography variant="h6" gutterBottom>관련 제품</Typography>
        <ReactTabulator
          data={product.relatedProducts}
          columns={columns}
          options={{ layout: "fitColumns", pagination: "local", paginationSize: 5 }}
        />
      </Paper>
    </Box>
  );
}

🔍 제품 관리 시스템 예제 설명

이 예제는 동적 라우트를 활용한 제품 상세 페이지의 전형적인 구현 사례입니다. 주니어 개발자가 주목해야 할 포인트는 다음과 같습니다.

페이지 구조

  • URL의 동적 세그먼트([category], [id])는 페이지 컴포넌트의 params prop으로 전달됩니다. 이를 통해 어떤 제품을 보여줘야 할지 알 수 있습니다.
  • 예를 들어 /products/electronics/123 URL로 접근 시, params 객체는 { category: 'electronics', id: '123' }이 됩니다.

주요 기능

  • 제품 정보 조회: 페이지가 로드되면 useEffect 훅을 통해 params에 있는 ID를 사용하여 API로부터 제품 정보를 자동으로 가져옵니다.
  • 제품 정보 수정: 각 입력 필드의 값이 변경되면 product 상태가 업데이트되고, ‘저장’ 버튼을 누르면 이 정보가 서버로 전송됩니다.
  • 관련 제품 표시: 조회된 제품 데이터에 포함된 관련 제품 목록을 ReactTabulator 라이브러리를 사용해 깔끔한 테이블 형태로 보여줍니다.

상태 관리

  • product: API로부터 받아온 현재 제품의 상세 정보를 저장합니다.
  • loading: 데이터를 로딩하는 동안 로딩 스피너를 보여주기 위한 상태입니다.
  • error: API 호출 중 오류가 발생하면 에러 메시지를 저장하여 사용자에게 보여줍니다.
  • saving: ‘저장’ 버튼을 눌렀을 때 중복 클릭을 방지하고 사용자에게 진행 상황을 알리기 위한 상태입니다.

사용된 주요 컴포넌트

  • Material-UI: TextField, Button, Paper 등 전문적인 UI를 빠르게 구축하기 위해 사용되었습니다.
  • ReactTabulator: 복잡한 데이터 테이블을 페이지네이션, 서식 지정 등의 기능과 함께 쉽게 구현하기 위해 사용되었습니다.
  • Breadcrumbs: 사용자가 현재 페이지의 위치를 쉽게 파악할 수 있도록 경로를 시각적으로 보여줍니다.

에러 처리 및 사용자 경험

  • 데이터를 불러오는 동안에는 로딩 스피너(CircularProgress)를 표시하여 사용자가 기다리고 있음을 인지시킵니다.
  • API 통신에 실패하면 사용자에게 명확한 에러 메시지(Alert)를 보여주어 문제 상황을 알립니다.
  • 정보를 저장하는 동안 버튼을 비활성화하고 텍스트를 변경하여 작업이 진행 중임을 명확히 합니다.

next.js 클라이언트 상태 관리 웹 캐싱 전략의 모든 것…

매번 똑같은 책을 빌리기 위해 도서관에 가는 것과, 자주 보는 책 몇 권은 아예 책상 위에 꺼내두고 보는 것 중 어느 쪽이 더 빠를까요?...
eve
1 min read

React 클라이언트 상태 관리 (전역 상태 관리와 Context API)

우리가 큰 건물을 짓는다고 상상해 봅시다. 건물 전체의 ‘중앙 난방 온도’나 ‘비상벨 작동 여부’ 같은 정보는 1층 로비, 10층 사무실, 지하 주차장 등 건물의...
eve
1 min read

 React 19 자동 메모이제이션 (Automatic Memoization)

혹시 개발 공부를 하다가 useMemo, useCallback 같은 훅(Hook)들을 만나고 “이건 왜 써야 하지? 너무 복잡해…”라고 생각했던 적 있으신가요? 이런 훅들은 React의 성능을 최적화하기 위해,...
eve
46 sec read