[Next.js] 동적 라우팅

반응형

Next.js를 사용하다 보면 언젠가는 반드시 마주치게 되는 동적 라우팅, 처음에는 저도 개념을 이해하는 데 꽤 시간이 걸렸습니다. 특히 getStaticPaths와 getStaticProps의 관계가 헷갈리더군요. 오늘은 제가 겪었던 시행착오를 줄여드리기 위해 Next.js의 동적 라우팅 구현 방법을 처음부터 끝까지 정리해봤습니다. 개념부터 실제 코드, 그리고 흔히 발생하는 문제들까지 다룰 예정이니 끝까지 읽어주세요.

Next.js 동적 라우팅

동적 라우팅이라는 용어부터 생소하게 느껴질 수 있지만, 실제로는 꽤 직관적인 개념입니다. 일반적인 웹 페이지들은 정해진 URL 패턴을 가지고 있죠. 예를 들어 /about, /contact 같은 고정된 경로를 가집니다. 반면, 동적 라우팅은 /posts/1, /posts/2처럼 URL의 일부가 변수로 작동하는 방식입니다.

Next.js에서 동적 라우팅의 핵심은 파일 이름에 대괄호([param])를 사용한다는 점입니다. 이 대괄호 안에 들어가는 이름이 URL 파라미터가 되고, 이를 통해 페이지 내에서 해당 값을 사용할 수 있게 됩니다. 사실 다른 프레임워크들도 비슷한 개념을 가지고 있지만, Next.js는 파일 시스템 기반 라우팅을 사용하기 때문에 특히 직관적이라고 할 수 있습니다.

특히 중요한 건 Next.js의 동적 라우팅은 단순히 클라이언트 측 라우팅에 그치지 않고, 서버 사이드 렌더링(SSR)과 정적 사이트 생성(SSG)과도 밀접하게 연관되어 있다는 점입니다. 이는 페이지 로딩 속도나 SEO에 큰 영향을 미치는 부분이죠.

동적 라우팅을 위한 파일 구조 설계

Next.js에서 동적 라우팅을 구현하기 위한 파일 구조는 프로젝트의 성격에 따라 달라질 수 있지만, 기본적인 패턴들이 존재합니다. 가장 중요한 것은 pages 디렉토리(또는 최신 Next.js의 app 디렉토리) 내의 파일 구조입니다. 아래 표는 일반적인 동적 라우팅 파일 구조와 각각의 URL 패턴을 보여줍니다.

파일 경로 URL 패턴 설명
pages/posts/[id].js /posts/1, /posts/abc 기본적인 동적 라우팅
pages/[category]/[product].js /electronics/iphone 중첩된 동적 라우팅
pages/posts/[...slug].js /posts/2020/01/01 Catch-all 라우팅
pages/posts/[[...slug]].js /posts, /posts/a/b Optional catch-all 라우팅

위 표에서 볼 수 있듯이, Next.js는 다양한 형태의 동적 라우팅을 지원합니다. 특히 [...slug] 형식의 catch-all 라우팅은 블로그나 문서 사이트 같은 복잡한 경로 구조를 가진 애플리케이션에서 매우 유용합니다.

동적 라우팅 구현 방법 3가지

Next.js에서 동적 라우팅을 구현하는 방법은 크게 세 가지가 있습니다. 각 방법은 서로 다른 사용 사례와 장단점을 가지고 있어 프로젝트의 성격에 맞게 선택해야 합니다.

  1. 클라이언트 사이드 데이터 페칭 방식

    useRouter 훅을 사용하여 현재 URL의 파라미터를 가져온 다음, 해당 파라미터를 기반으로 useEffect 내에서 데이터를 가져오는 방식입니다. 가장 간단하지만 SEO와 초기 로딩 성능 면에서는 불리합니다.

  2. 서버 사이드 렌더링(SSR) 방식

    getServerSideProps 함수를 사용하여 요청 시점에 서버에서 데이터를 가져와 페이지를 렌더링하는 방식입니다. SEO에 유리하지만, 매 요청마다 서버에서 페이지를 생성하므로 성능상 부담이 있습니다.

  3. 정적 사이트 생성(SSG) 방식

    getStaticPaths와 getStaticProps를 함께 사용하여 빌드 타임에 정적 페이지를 생성하는 방식입니다. 가장 빠른 페이지 로딩 속도를 제공하며 SEO에도 유리하지만, 데이터가 자주 변경되는 페이지에는 적합하지 않을 수 있습니다.

📝 TIP: 언제 어떤 방식을 사용해야 할까요?

클라이언트 사이드: 사용자별 개인화된 콘텐츠(대시보드 등)에 적합합니다.
SSR: 실시간 데이터가 중요한 페이지(주식 정보, 실시간 뉴스 등)에 적합합니다.
SSG: 자주 변경되지 않는 콘텐츠(블로그, 제품 카탈로그 등)에 가장 적합합니다.
ISR(Incremental Static Regeneration): SSG의 장점과 실시간성을 절충한 방식으로, revalidate 옵션을 사용해 특정 시간마다 페이지를 재생성합니다.

동적 라우팅 성능 최적화 전략

동적 라우팅은 편리하지만, 잘못 사용하면 성능 문제가 발생할 수 있습니다. 특히 많은 페이지를 생성해야 하는 경우 빌드 시간이 길어질 수 있고, 메모리 소비도 증가합니다. 이러한 문제를 해결하기 위한 몇 가지 최적화 전략을 살펴보겠습니다.

Incremental Static Regeneration(ISR) 활용

Next.js의 가장 강력한 기능 중 하나인 ISR은 정적 사이트 생성(SSG)의 장점을 유지하면서도 콘텐츠의 신선도를 보장합니다. getStaticProps 함수에 revalidate 속성을 추가하는 것만으로 설정할 수 있습니다.

fallback 옵션의 전략적 활용

getStaticPaths의 fallback 옵션은 빌드 시 생성되지 않은 페이지를 어떻게 처리할지 결정합니다. 이 옵션의 세 가지 값(false, true, 'blocking')을 적절히 활용하면 성능과 사용자 경험 사이의 균형을 맞출 수 있습니다.

fallback 값 동작 방식 사용 사례
false 빌드 시 생성되지 않은 경로는 404 페이지 반환 모든 가능한 경로가 정해져 있는 소규모 사이트
true 초기에 fallback 상태 표시, 백그라운드에서 페이지 생성 대규모 e커머스 사이트, 사용자 생성 컨텐츠가 많은 사이트
'blocking' SSR처럼 페이지 생성 완료될 때까지 로딩 (로딩 UI 없음) SEO가 중요하고 로딩 UI가 바람직하지 않은 경우

빌드 시간 최적화

수천 개의 페이지를 가진 대규모 사이트에서는 빌드 시간이 문제가 될 수 있습니다. 이런 경우 몇 가지 전략을 통해 빌드 시간을 최적화할 수 있습니다.

자주 발생하는 문제와 해결 방법

동적 라우팅을 구현하다 보면 몇 가지 일반적인 문제에 직면하게 됩니다. 이러한 문제들과 그 해결책을 알아봅시다.

  • hydration 불일치 오류

    서버에서 렌더링된 HTML과 클라이언트에서 렌더링된 HTML이 일치하지 않을 때 발생합니다. 이는 보통 서버와 클라이언트에서 서로 다른 데이터를 사용하거나, 조건부 렌더링이 환경에 따라 달라질 때 발생합니다.

    해결책: useEffect 내에서 클라이언트 사이드 로직을 실행하고, 서버와 클라이언트 렌더링이 동일한 출력을 생성하도록 합니다. 또한 dynamic import와 ssr: false 옵션을 사용하여 특정 컴포넌트를 클라이언트에서만 렌더링할 수 있습니다.

  • 404 에러 (경로를 찾을 수 없음)

    getStaticPaths에서 생성되지 않은 경로에 접근하고 fallback이 false로 설정된 경우 발생합니다.

    해결책: fallback 값을 true 또는 'blocking'으로 설정하여 빌드 시 생성되지 않은 경로도 처리할 수 있게 합니다. 또는 getStaticPaths에서 더 많은 경로를 포함시킵니다.

  • 빌드 시간 초과

    getStaticPaths에서 너무 많은 경로를 생성하려고 할 때 발생할 수 있습니다.

    해결책: 빌드 시 중요한 페이지만 생성하고 나머지는 fallback: true를 사용하여 온디맨드로 생성합니다. 또한 Next.js 12부터 지원되는 On-Demand ISR을 활용하여 특정 페이지만 선택적으로 재생성할 수 있습니다.

📝 TIP: 동적 라우팅 디버깅 방법

동적 라우팅 문제를 디버깅할 때는 다음 단계를 따르는 것이 도움이 됩니다.

  1. getStaticPaths의 반환값 로깅하여 올바른 경로가 생성되는지 확인
  2. 개발 환경과 프로덕션 환경의 차이점 이해 (개발 환경에서는 모든 페이지가 요청 시 렌더링됨)
  3. router.isFallback을 사용하여 fallback 상태 확인 및 적절한 로딩 UI 제공
  4. next build && next export 명령 대신 일반 next build 사용 (export는 동적 라우팅 불가)

블로그, 쇼핑몰, 대시보드에서의 활용

동적 라우팅은 다양한 웹 애플리케이션에서 활용될 수 있습니다. 몇 가지 실전 사례를 통해 어떻게 구현하는지 살펴보겠습니다.

블로그 플랫폼 구현

블로그 플랫폼은 동적 라우팅의 가장 일반적인 사용 사례 중 하나입니다. 게시물 ID나 슬러그를 기반으로 URL을 구성하고, 해당 콘텐츠를 렌더링합니다. SSG와 ISR 조합이 가장 효과적인 접근 방식입니다.

  • 파일 구조: pages/blog/[slug].js
  • 데이터 페칭: CMS, 마크다운 파일, 또는 데이터베이스에서 블로그 포스트 데이터 가져오기
  • 렌더링 방식: getStaticPaths와 getStaticProps를 사용한 SSG, revalidate 옵션으로 ISR 구현
📝 TIP: Next.js 앱 라우터(app router)의 동적 라우팅

Next.js 13 이상에서 도입된 앱 라우터(App Router)에서는 동적 라우팅 구현 방식이 약간 다릅니다. 폴더 구조가 app/blog/[slug]/page.js 형태로 변경되며, getStaticProps와 getStaticPaths 대신 generateStaticParams 함수가 사용됩니다. 또한 React Server Components를 통해 서버에서 직접 데이터를 가져올 수 있어 구조가 더 단순해집니다.

⚠️ 주의: 데이터 요청 오류 처리하기

동적 라우팅에서 데이터 요청 실패 시 적절한 오류 처리 방법을 구현해야 합니다. getStaticProps나 getServerSideProps에서 try/catch 블록을 사용하여 오류를 잡고, 사용자에게 의미 있는 오류 메시지를 표시하세요. 또한 notFound 옵션을 반환하여 404 페이지로 리디렉션할 수도 있습니다.

Next.js 동적 라우팅 코드 예제

이제 다양한 동적 라우팅 패턴을 구현하는 실제 코드 예제를 살펴보겠습니다. 각 예제는 실제 프로젝트에서 바로 사용할 수 있도록 작성되었습니다.

1. 기본 동적 라우팅 (pages 디렉토리)

// pages/products/[id].js
import { useRouter } from 'next/router';
import Head from 'next/head';
import Link from 'next/link';

// 제품 데이터 가져오는 함수
const fetchProductById = async (id) => {
  // 실제로는 API 또는 데이터베이스에서 데이터를 가져옴
  const res = await fetch(`https://api.example.com/products/${id}`);
  
  if (!res.ok) {
    throw new Error(`제품 데이터를 가져오는데 실패했습니다: ${res.status}`);
  }
  
  return res.json();
};

// 모든 제품 ID 가져오는 함수
const fetchProductIds = async () => {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();
  return products.map(product => product.id);
};

export default function Product({ product, error }) {
  const router = useRouter();
  
  // fallback: true를 사용할 때 필요한 로딩 상태 처리
  if (router.isFallback) {
    return <div>로딩 중...</div>
  }
  
  // 오류 처리
  if (error) {
    return (
      <div>
        <h1>오류가 발생했습니다</h1>
        <p>{error}</p>
        <Link href="/products">제품 목록으로 돌아가기</Link>
      </div>
    );
  }
  
  return (
    <>
      <Head>
        <title>{product.name} | 온라인 스토어</title>
        <meta name="description" content={product.description} />
      </Head>
      
      <main>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
        <p>가격: ${product.price}</p>
        
        <Link href="/products">제품 목록으로 돌아가기</Link>
      </main>
    </>
  );
}

// 빌드 시 생성할 경로 지정
export async function getStaticPaths() {
  try {
    const ids = await fetchProductIds();
    
    // 처음 10개의 제품만 빌드 시 생성 (나머지는 요청 시 생성)
    const paths = ids.slice(0, 10).map(id => ({
      params: { id: id.toString() }
    }));
    
    return {
      paths,
      fallback: true // true: 요청 시 페이지 생성, false: 404 반환, 'blocking': SSR처럼 동작
    };
  } catch (error) {
    console.error('정적 경로를 생성하는 중 오류 발생:', error);
    return { paths: [], fallback: true };
  }
}

// 각 페이지의 데이터 가져오기
export async function getStaticProps({ params }) {
  try {
    const product = await fetchProductById(params.id);
    
    // 제품이 없는 경우 404 페이지 표시
    if (!product) {
      return { notFound: true };
    }
    
    return { 
      props: { product },
      // 10분마다 페이지 재생성 (ISR)
      revalidate: 600
    };
  } catch (error) {
    console.error(`제품 ID ${params.id}의 데이터를 가져오는 중 오류 발생:`, error);
    
    // 클라이언트에 오류 전달 (프로덕션에서는 자세한 오류 메시지 노출 주의)
    return { 
      props: { 
        product: null, 
        error: '제품 정보를 불러올 수 없습니다. 잠시 후 다시 시도해주세요.' 
      },
      revalidate: 60 // 오류 발생 시 더 빠르게 재시도
    };
  }
}

2. Catch-all 라우팅 (여러 세그먼트 처리)

// pages/blog/[...slug].js
// 예: /blog/2023/04/my-post

import { useRouter } from 'next/router';

export default function BlogPost({ post }) {
  const router = useRouter();
  
  // slug는 배열 형태: ['2023', '04', 'my-post']
  const { slug } = router.query;
  
  if (router.isFallback) {
    return <div>로딩 중...</div>
  }
  
  return (
    <div>
      <h1>{post.title}</h1>
      <p>날짜: {post.date}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </div>
  );
}

export async function getStaticPaths() {
  // 여기서는 메인 페이지에 보여줄 주요 게시물 경로만 미리 생성
  // 나머지는 요청 시 생성됩니다
  const mainPosts = [
    { year: '2023', month: '04', slug: 'getting-started-with-nextjs' },
    { year: '2023', month: '03', slug: 'dynamic-routing-explained' }
  ];
  
  const paths = mainPosts.map(post => ({
    params: { 
      slug: [post.year, post.month, post.slug]
    }
  }));
  
  return { paths, fallback: true };
}

export async function getStaticProps({ params }) {
  const { slug } = params;
  
  // slug 배열에서 데이터 추출
  const [year, month, postSlug] = slug;
  
  try {
    // 실제로는 여기서 API 호출 또는 파일 시스템에서 블로그 포스트를 가져옴
    const post = await fetchBlogPost(year, month, postSlug);
    
    if (!post) {
      return { notFound: true };
    }
    
    return {
      props: { post },
      revalidate: 3600 // 1시간마다 재생성
    };
  } catch (error) {
    console.error('블로그 포스트를 가져오는 중 오류 발생:', error);
    return { notFound: true };
  }
}

3. Next.js 13+ App Router 예제

// app/products/[id]/page.js

// 페이지 컴포넌트 (React Server Component)
export default async function ProductPage({ params }) {
  // 서버 컴포넌트에서 바로 데이터 가져오기
  const product = await getProduct(params.id);
  
  if (!product) {
    // App Router에서는 notFound 함수를 호출하여 404 페이지 표시
    notFound();
  }
  
  return (
    <div>
      <h1>{product.title}</h1>
      <p>{product.description}</p>
      <p>가격: ${product.price}</p>
    </div>
  );
}

// getStaticPaths 대신 generateStaticParams 사용
export async function generateStaticParams() {
  const products = await getProducts();
  
  // 처음 20개 제품만 빌드 시 생성
  return products.slice(0, 20).map((product) => ({
    id: product.id.toString(),
  }));
}

// 데이터 가져오는 함수들
async function getProducts() {
  const res = await fetch('https://api.example.com/products');
  return res.json();
}

async function getProduct(id) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    // ISR 구현 (60초마다 재검증)
    next: { revalidate: 60 }
  });
  
  if (!res.ok) return null;
  return res.json();
}

Next.js의 동적 라우팅은 단순히 URL 패턴을 처리하는 것을 넘어서 웹 애플리케이션의 성능과 사용자 경험에 직접적인 영향을 미치는 중요한 기능입니다. 이 글에서 살펴본 것처럼 SSG, SSR, ISR 등 다양한 렌더링 방식과 결합해서 보통 사용합니다.

동적 라우팅은 초기에는 약간 복잡하게 느껴질 수 있지만, 기본 개념을 이해하고 몇 가지 패턴을 익히면 잘 활용을 할 수 있는 것 같습니다. 특히 getStaticPaths와 getStaticProps(또는 App Router의 generateStaticParams)를 효과적으로 활용하는 방법을 알아두면 대부분의 웹 프로젝트에서 훌륭한 성능과 사용자 경험을 구현할 수 있습니다. 감사합니다.

Designed by JB FACTORY