[Next.js] pages 폴더 구조와 라우팅

반응형

안녕하세요. 최근 몇 년간 Next.js를 다양한 프로젝트에 적용하면서 많은 실수와 시행착오를 겪었고, 특히 파일 기반 라우팅 시스템을 제대로 이해하지 못해 불필요한 삽질을 했죠. 이 글에서는 Next.js의 pages 디렉토리 구조와 라우팅에 대해서 한번 정리해보고자 합니다.

Next.js의 파일 기반 라우팅 기본 개념

Next.js의 가장 강력한 특징 중 하나는 복잡한 라우팅 설정 없이도 직관적으로 페이지를 구성할 수 있는 파일 기반 라우팅 시스템이다. 리액트 프로젝트에서 React Router를 설정하느라 고생했던 개발자라면 이 시스템의 편리함을 단번에 알아챌 것이다.

간단히 말해서, pages 디렉토리 내에 파일을 생성하면 그 파일 경로가 그대로 URL 경로가 된다. 이게 전부다. 설정 파일 따위는 필요 없다. 굳이 따지자면 이건 설정보다 관습(convention)에 가깝다.

예를 들어, pages/about.js 파일을 만들면 /about URL로 접근할 수 있다. 이보다 직관적일 수 없다. 이런 방식은 프로젝트의 구조를 한눈에 파악하기 쉽게 만들어 주고, 라우팅 관련 복잡한 로직을 작성할 필요가 없게 해준다.

📝 메모

Next.js 13부터는 app 디렉토리도 도입됐지만, 이 글에서는 기존의 pages 디렉토리 기반 라우팅에 집중한다. 많은 프로젝트가 아직 이 방식을 사용하고 있고, 기본 원리를 이해하는 것이 중요하기 때문이다.

pages 폴더의 구조와 URL 매핑 원리

Next.js의 pages 폴더는 단순히 파일을 포함하는 것 이상의 의미를 가진다. 이 폴더의 구조가 곧 애플리케이션의 라우팅 구조를 결정한다. 몇 가지 기본적인 규칙을 알아보자.

파일 경로 URL 경로 설명
pages/index.js / 홈페이지 (루트 경로)
pages/about.js /about about 페이지
pages/blog/index.js /blog 블로그 메인 페이지
pages/blog/post.js /blog/post 블로그 포스트 페이지
pages/404.js 모든 404 에러 커스텀 404 에러 페이지
pages/_app.js N/A 전역 레이아웃 컴포넌트
pages/_document.js N/A HTML 문서 커스터마이징

주목할 점은 index.js 파일이 해당 경로의 기본 페이지가 된다는 것이다. 그리고 밑줄(_)로 시작하는 파일들은 특수한 역할을 하며 실제 라우트로 취급되지 않는다.

파일명이 대소문자를 구분한다는 점도 알아두자. About.jsabout.js는 Next.js에서는 다른 파일로 인식된다. 하지만 URL 경로는 항상 소문자로 정규화된다. 혼란을 피하기 위해 파일명은 일관되게 소문자와 하이픈으로 작성하는 것이 좋다.

동적 라우팅과 매개변수 활용법

실제 애플리케이션에서는 블로그 포스트, 제품 상세 페이지 등 동적인 데이터를 기반으로 페이지를 생성해야 하는 경우가 많다. Next.js에서는 동적 라우팅을 통해 이를 쉽게 구현할 수 있다.

동적 라우팅을 구현하려면 파일명에 대괄호([])를 사용하면 된다. 예를 들어, pages/blog/[id].js 파일을 생성하면 /blog/1, /blog/2, /blog/post-title 등 모든 /blog/[anything] 패턴의 URL에 매칭된다.

동적 라우팅에서 사용 가능한 주요 패턴들을 살펴보자..

  1. 기본 동적 라우팅: pages/posts/[id].js/posts/1, /posts/a
  2. 중첩 동적 라우팅: pages/[category]/[id].js/tech/1, /food/pasta
  3. 캐치 올(Catch-all) 라우팅: pages/posts/[...slug].js/posts/2020/01/01/happy-new-year
  4. 선택적 캐치 올(Optional catch-all) 라우팅: pages/[[...slug]].js/, /one, /one/two

컴포넌트에서 이러한 동적 매개변수에 접근하려면 Next.js가 제공하는 useRouter 훅을 사용하면 된다:

import { useRouter } from 'next/router';

export default function Post() {
  const router = useRouter();
  const { id } = router.query;

  return <p>Post ID: {id}</p>;
}
📝 메모

캐치 올 라우팅을 사용할 때는 router.query.slug가 배열 형태로 제공된다. 예를 들어, /posts/2020/01/01 URL의 경우 slug['2020', '01', '01']이 된다.

중첩 라우팅과 레이아웃 패턴

복잡한 애플리케이션을 개발하다 보면 중첩된 라우트와 공통 레이아웃을 관리해야 하는 상황이 발생한다. 예를 들어 대시보드 페이지에는 여러 하위 페이지가 있고, 이들은 모두 동일한 사이드바와 헤더를 공유해야 한다. Next.js에서는 이런 패턴을 몇 가지 방법으로 구현할 수 있다.

가장 일반적인 패턴은 공통 레이아웃 컴포넌트를 만들고 이를 필요한 페이지에서 재사용하는 것이다:

// components/DashboardLayout.js
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard-layout">
      <nav className="sidebar">{/* 사이드바 내용 */}</nav>
      <main>{children}</main>
    </div>
  );
}

// pages/dashboard/index.js
import DashboardLayout from '../../components/DashboardLayout';

export default function Dashboard() {
  return (
    <DashboardLayout>
      <h1>대시보드 메인</h1>
      {/* 페이지 내용 */}
    </DashboardLayout>
  );
}

이 방법이 가장 명시적이지만, 모든 대시보드 페이지에 레이아웃 컴포넌트를 수동으로 추가해야 한다는 단점이 있다. 실수로 컴포넌트를 추가하지 않으면 레이아웃이 깨질 수 있다.

더 강력한 방법은 페이지 컴포넌트에 getLayout 속성을 추가하고, _app.js에서 이를 활용하는 것이다:

// pages/dashboard/index.js
import DashboardLayout from '../../components/DashboardLayout';

function Dashboard() {
  return 

대시보드 메인

; } Dashboard.getLayout = (page) => { return <DashboardLayout>{page}</DashboardLayout>; }; export default Dashboard; // pages/_app.js function MyApp({ Component, pageProps }) { // 페이지에 getLayout 함수가 있으면 사용하고, 없으면 기본 레이아웃 적용 const getLayout = Component.getLayout || ((page) => page); return getLayout(<Component {...pageProps} />); }

이 방법은 페이지별로 다른 레이아웃을 적용하거나, 레이아웃을 중첩해서 사용할 수 있는 유연성을 제공한다.

레이아웃 패턴 장점 단점
컴포넌트 래핑 간단하고 직관적 수동 작업 필요, 실수 발생 가능
getLayout 패턴 유연성, 레이아웃 중첩 가능 초기 설정이 더 복잡함
_app.js만 사용 전역 일관성 모든 페이지에 동일한 레이아웃 적용

API 라우트 구성과 백엔드 통합 전략

Next.js의 또 다른 강력한 기능은 API 라우트이다. pages/api 디렉토리 내에 파일을 생성하면 서버리스 함수로 동작하는 API 엔드포인트가 자동으로 생성된다.

API 라우트는 일반 페이지 라우트와 동일한 패턴을 따른다. 파일 기반 라우팅, 동적 라우팅 등의 기능이 모두 동일하게 적용된다.

// pages/api/hello.js
export default function handler(req, res) {
  res.status(200).json({ name: 'John Doe' });
}

// pages/api/users/[id].js
export default function handler(req, res) {
  const { id } = req.query;
  res.status(200).json({ id, name: `User ${id}` });
}

API 라우트는 다양한 사용 사례가 있다:

사용 사례 설명 예시 경로
CRUD 작업 데이터베이스 조작 api/posts
인증 로그인, 회원가입 등 api/auth/login
외부 API 프록시 CORS 회피, API 키 숨기기 api/weather
서버리스 함수 이메일 전송, 결제 처리 등 api/send-email

API 라우트를 활용할 때 주의할 점

  • API 라우트는 클라이언트 측에서 직접 임포트할 수 없다. axios나 fetch 등을 사용해 호출해야 한다.
  • API 라우트는 항상 서버에서 실행된다. 따라서 서버 측 코드(데이터베이스 연결 등)를 안전하게 작성할 수 있다.
  • API 라우트에서 사용하는 미들웨어는 일반적인 Express 미들웨어와 다를 수 있다.
  • getServerSideProps나 getStaticProps에서 API 라우트를 직접 호출하지 말아야 한다. 대신 해당 로직을 공유하는 함수를 만들어 사용하자.
📝 메모

API 라우트는 서버리스 환경에서 실행되기 때문에 상태를 유지하지 않는다. 연결 풀링이나 캐싱 같은 기법을 적절히 활용해야 성능을 최적화할 수 있다.

라우팅 최적화와 성능 향상 테크닉

Next.js의 라우팅 시스템은 기본적으로 매우 효율적이지만, 대규모 애플리케이션에서는 추가적인 최적화가 필요할 수 있다. 라우팅 성능을 향상시키고 사용자 경험을 개선하는 몇 가지 기법을 살펴보자.

  1. Link 컴포넌트 활용: next/link의 자동 프리페칭 기능은 사용자가 링크 위에 마우스를 올리기만 해도 해당 페이지를 백그라운드에서 미리 로드한다.
  2. 동적 임포트: next/dynamic을 사용해 컴포넌트를 필요할 때만 로드하여 초기 로딩 시간을 단축할 수 있다.
  3. 라우트 프리페칭: 프로그래매틱하게 router.prefetch()를 호출하여 특정 페이지를 미리 로드할 수 있다.
  4. 정적 생성(SSG) 활용: 가능한 한 많은 페이지를 빌드 시점에 미리 렌더링하여 서버 부하를 줄이고 페이지 로딩 속도를 향상시킨다.
  5. 증분 정적 재생성(ISR): getStaticPropsrevalidate 옵션을 사용해 정적 페이지를 주기적으로 업데이트한다.
  6. shallow 라우팅: 페이지를 다시 렌더링하지 않고 URL만 변경하는 기능을 활용하여 불필요한 데이터 페칭을 방지한다.

다음은 shallow 라우팅을 활용한 예시다.

import { useRouter } from 'next/router';

export default function FilterPage() {
  const router = useRouter();
  
  function updateFilters(newFilters) {
    // URL 업데이트하되 페이지는 새로 불러오지 않음
    router.push(
      {
        pathname: router.pathname,
        query: { ...router.query, ...newFilters },
      },
      undefined,
      { shallow: true }
    );
  }

  return (
    <div>
      <button onClick={() => updateFilters({ category: 'electronics' })}>
        전자제품 필터
      </button>
      <div>현재 카테고리: {router.query.category || '모두'}</div>
    </div>
  );
}

라우트 분할 전략도 잊지 말자. 대규모 앱에서는 관련 기능을 그룹화하여 코드 스플리팅을 최적화할 수 있다:

  • pages/dashboard/: 대시보드 관련 페이지
  • pages/auth/: 인증 관련 페이지
  • pages/api/v1/: API 버전 관리
  • pages/admin/: 관리자 전용 페이지
📝 메모

Next.js 라우팅에서 가장 흔히 저지르는 실수 중 하나는 pages 디렉토리를 컴포넌트 구성이나 코드 구조화를 위한 도구로 사용하려는 것이다. 라우팅과 직접적으로 관련되지 않은 코드는 components, lib, utils 등의 별도 디렉토리에 보관하는 것이 좋다.

📝 Next.js 라우팅 관련 자주 묻는 질문
Q pages와 app 디렉토리를 동시에 사용할 수 있나요?

Next.js 13 이상에서는 두 디렉토리를 동시에 사용할 수 있습니다. 동일한 경로에 대해 app 디렉토리의 라우트가 우선순위를 가집니다. 점진적으로 pages에서 app으로 마이그레이션하는 전략을 사용할 수 있습니다.

Q 서버 컴포넌트와 pages 라우팅은 어떻게 연동되나요?

pages 디렉토리는 기본적으로 서버 컴포넌트를 지원하지 않습니다. 서버 컴포넌트를 사용하려면 app 디렉토리로 마이그레이션해야 합니다. 하지만 pages에서 getServerSideProps를 사용하여 서버 사이드 데이터 페칭은 계속 활용할 수 있습니다.

Q 라우팅 충돌이 발생할 때 해결 방법은?

동적 라우트([id])와 고정 라우트(about)가 충돌할 수 있습니다. Next.js는 더 구체적인 라우트에 우선순위를 부여합니다. 예를 들어, /posts/create와 /posts/[id] 경로가 있다면 /posts/create 요청은 고정 라우트가 처리합니다. 동적 라우트를 더 구체적으로 만들려면 중첩 동적 라우트를 사용하세요.

실전 코드 예제: 완전한 라우팅 솔루션

다음은 Next.js의 pages 디렉토리를 사용하여 구현한 완전한 라우팅 솔루션 예제이다. 이 예제는 인증, 권한 제어, 레이아웃 관리 등 실제 프로젝트에서 필요한 여러 기능을 포함하고 있다.

// pages/_app.js - 전역 레이아웃 및 상태 관리
import { useState, useEffect } from 'react';
import { AuthProvider } from '../context/AuthContext';
import { ThemeProvider } from '../context/ThemeContext';
import '../styles/globals.css';

export default function MyApp({ Component, pageProps, router }) {
  // 페이지별 레이아웃 시스템
  const getLayout = Component.getLayout || ((page) => page);
  
  // 페이지 전환 프로그레스 표시기
  const [loading, setLoading] = useState(false);
  
  useEffect(() => {
    const handleStart = () => setLoading(true);
    const handleComplete = () => setLoading(false);
    
    router.events.on('routeChangeStart', handleStart);
    router.events.on('routeChangeComplete', handleComplete);
    router.events.on('routeChangeError', handleComplete);
    
    return () => {
      router.events.off('routeChangeStart', handleStart);
      router.events.off('routeChangeComplete', handleComplete);
      router.events.off('routeChangeError', handleComplete);
    };
  }, [router]);
  
  return (
    <AuthProvider>
      <ThemeProvider>
        {loading && <div className="loading-bar" />}
        {getLayout(<Component {...pageProps} />)}
      </ThemeProvider>
    </AuthProvider>
  );
}

// components/layouts/AdminLayout.js - 관리자 레이아웃
export default function AdminLayout({ children }) {
  return (
    <div className="admin-layout">
      <AdminSidebar />
      <main>{children}</main>
    </div>
  );
}

// lib/auth.js - 인증 및 권한 제어
export function withAuth(gssp) {
  return async (context) => {
    const { req, res } = context;
    const token = req.cookies.token;
    
    if (!token) {
      return {
        redirect: {
          destination: '/login?redirect=' + encodeURIComponent(context.resolvedUrl),
          permanent: false,
        },
      };
    }
    
    // 토큰 검증
    try {
      const user = await verifyToken(token);
      const gsspData = gssp ? await gssp(context) : { props: {} };
      
      return {
        ...gsspData,
        props: {
          ...gsspData.props,
          user,
        },
      };
    } catch (error) {
      // 인증 실패
      return {
        redirect: {
          destination: '/login',
          permanent: false,
        },
      };
    }
  };
}

// pages/dashboard/index.js - 관리자 대시보드 페이지
import AdminLayout from '../../components/layouts/AdminLayout';
import { withAuth } from '../../lib/auth';

function Dashboard({ user, stats }) {
  return (
    <div>
      <h1>환영합니다, {user.name}님</h1>
      <DashboardStats stats={stats} />
    </div>
  );
}

Dashboard.getLayout = (page) => <AdminLayout>{page}</AdminLayout>;

export const getServerSideProps = withAuth(async (context) => {
  // 대시보드 데이터 가져오기
  const stats = await fetchDashboardStats(context.req.cookies.token);
  
  return {
    props: {
      stats,
    },
  };
});

export default Dashboard;

// pages/blog/[slug].js - 동적 블로그 포스트 페이지
export default function BlogPost({ post }) {
  const router = useRouter();
  
  // 페이지가 아직 생성 중인 경우 로딩 표시
  if (router.isFallback) {
    return <div>Loading...</div>;
  }
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

export async function getStaticPaths() {
  // 인기 있는 블로그 포스트만 미리 생성
  const popularPosts = await fetchPopularPosts();
  
  return {
    paths: popularPosts.map((post) => ({
      params: { slug: post.slug },
    })),
    // 나머지는 요청 시 생성
    fallback: true,
  };
}

export async function getStaticProps({ params }) {
  try {
    const post = await fetchPostBySlug(params.slug);
    
    // 요청된 포스트가 존재하지 않는 경우
    if (!post) {
      return {
        notFound: true,
      };
    }
    
    return {
      props: {
        post,
      },
      // 10분마다 페이지 재생성
      revalidate: 600,
    };
  } catch (error) {
    return {
      notFound: true,
    };
  }
}

위 코드 예제에서는 다음과 같은 고급 라우팅 패턴을 활용하고 있다:

  • HOC 패턴: withAuth 함수를 사용하여 인증이 필요한 페이지를 감싸고 있다.
  • 미들웨어 패턴: 인증 로직을 재사용 가능한 함수로 분리했다.
  • 레이아웃 패턴: getLayout 메서드를 사용하여 페이지별 레이아웃을 적용했다.
  • 증분 정적 생성: 블로그 포스트 페이지에 ISR을 적용하여 성능과 최신성의 균형을 맞추었다.
  • 라우팅 이벤트 활용: 페이지 전환 시 로딩 상태를 표시하여 UX를 개선했다.

이 코드는 실제 프로덕션 환경에서 사용할 수 있는 수준의 라우팅 솔루션을 보여준다. 프로젝트의 요구사항에 맞게 수정하여 사용하면 된다.

Next.js의 라우팅 시스템은 간단한 프로젝트부터 복잡한 엔터프라이즈급 애플리케이션까지 다양한 스케일에 대응할 수 있다. 중요한 것은 프로젝트의 특성에 맞는 패턴을 선택하고 일관성 있게 적용하는 것이다.

Next.js의 파일 기반 라우팅 시스템은 단순함과 강력함을 동시에 제공한다. 이 글에서 우리는 pages 디렉토리의 기본 구조부터 동적 라우팅, 중첩 라우팅, API 라우트, 그리고 성능 최적화 전략까지 살펴보았다.

결국 Next.js의 라우팅은 규칙보다 관습에 기반한다. 이 시스템의 진정한 가치는 복잡한 설정 없이도 직관적인 구조를 빠르게 구현할 수 있다는 점이다. 물론 프로젝트가 커지면 추가적인 최적화와 패턴이 필요하지만, 기본 원칙은 변하지 않는다.

프론트엔드 개발자로서 내가 배운 가장 중요한 교훈은 "단순함이 최선"이라는 점이다. Next.js의 라우팅 시스템은 이 철학을 완벽하게 구현하고 있으며, 덕분에 우리는 불필요한 복잡성 없이 사용자 경험에 집중할 수 있다.

Next.js 13에서 도입된 app 디렉토리가 pages를 완전히 대체할지는 아직 불확실하다. 하지만 pages 디렉토리의 라우팅 시스템을 이해하는 것은 Next.js 애플리케이션을 개발하는 데 있어 여전히 중요하다. 많은 프로젝트가 아직 이 시스템을 사용하고 있으며, 기본 원리는 app 디렉토리에서도 유사하게 적용되기 때문이다.

마지막으로, Next.js의 라우팅 시스템은 그저 도구일 뿐이다. 이 도구를 얼마나 효율적으로 활용하는지가 중요하다. 프로젝트의 구조를 잘 설계하고, 일관된 패턴을 적용하며, 사용자 경험을 최우선으로 고려한다면, 성공적인 Next.js 애플리케이션을 구축할 수 있을 것이다.

Designed by JB FACTORY