junyeokk
Blog
Next.js·2025. 10. 04

페이지 라우팅 설정하기

Next.js의 Page Router는 pages 폴더 내의 파일 구조를 기반으로 자동으로 라우팅을 생성하는 시스템이다. 별도의 라우팅 설정 파일 없이 파일과 폴더만으로 URL 경로를 정의할 수 있다.

Page Router vs App Router
Next.js 13부터 App Router가 도입되었지만, Page Router는 여전히 안정적이고 널리 사용된다. App Router는 Server Components를 기본으로 하는 새로운 패러다임이며, Page Router는 기존의 Client-side Navigation을 중심으로 한다.

기본 라우팅

pages 폴더 내의 파일이 자동으로 라우트가 된다. 파일명이 곧 URL 경로다.

plain
pages/
├── index.tsx          → /
├── about.tsx          → /about
├── contact.tsx        → /contact
└── products.tsx       → /products

위 구조에서 index.tsx는 루트 경로 /에 매핑되고, about.tsx/about 경로에 매핑된다.

typescript
// pages/index.tsx
export default function Home() {
  return <h1>홈 페이지</h1>;
}
typescript
// pages/about.tsx
export default function About() {
  return <h1>소개 페이지</h1>;
}

각 파일은 기본적으로 React 컴포넌트를 default export 해야 한다. Next.js는 이 컴포넌트를 자동으로 페이지로 렌더링한다.

중첩 라우팅

폴더를 사용하면 중첩된 경로를 만들 수 있다.

plain
pages/
├── index.tsx              → /
├── blog/
│   ├── index.tsx          → /blog
│   ├── first-post.tsx     → /blog/first-post
│   └── second-post.tsx    → /blog/second-post
└── products/
    ├── index.tsx          → /products
    └── electronics.tsx    → /products/electronics

폴더 내의 index.tsx는 해당 폴더 경로의 기본 페이지가 된다. 예를 들어 blog/index.tsx/blog 경로에 매핑된다.

typescript
// pages/blog/index.tsx
export default function Blog() {
  return <h1>블로그 목록</h1>;
}
typescript
// pages/blog/first-post.tsx
export default function FirstPost() {
  return <h1>첫 번째 포스트</h1>;
}

이렇게 폴더 구조만으로 직관적인 URL 계층을 구성할 수 있다.

동적 라우팅 (URL Parameter)

동적 경로를 갖는 페이지를 생성하려면 파일명을 대괄호로 감싸면 된다. [id].tsx와 같이 작성하면 해당 부분이 동적 세그먼트가 된다.

plain
pages/
└── posts/
    ├── index.tsx          → /posts
    └── [id].tsx           → /posts/:id

[id].tsx 파일은 /posts/1, /posts/abc, /posts/hello 등 모든 경로에 매칭된다.

typescript
// pages/posts/[id].tsx
import { useRouter } from 'next/router';

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

  return <h1>포스트 ID: {id}</h1>;
}

위 코드에서 useRouter 훅을 통해 URL의 동적 파라미터에 접근할 수 있다. router.query 객체에서 파일명에 정의한 id를 구조 분해 할당으로 가져온다.

사용자가 /posts/123에 접속하면 id"123"이 되고, /posts/hello에 접속하면 id"hello"가 된다.

여러 동적 세그먼트

여러 동적 세그먼트를 조합할 수도 있다.

plain
pages/
└── posts/
    └── [category]/
        └── [id].tsx       → /posts/:category/:id
typescript
// pages/posts/[category]/[id].tsx
import { useRouter } from 'next/router';

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

  return (
    <div>
      <h1>카테고리: {category}</h1>
      <p>포스트 ID: {id}</p>
    </div>
  );
}

/posts/tech/123에 접속하면 category"tech", id"123"이 된다.

Query String

Query String은 페이지 경로에 영향을 주지 않으며, ? 뒤에 오는 파라미터들을 의미한다. 예를 들어 /search?q=nextjs&sort=latest에서 qsort가 Query String이다.

별도의 설정 없이 useRouter 훅으로 접근할 수 있다.

주의: next/router 사용useRouter는 next/router에서 import 해야 한다. next/navigation의 useRouter는 App Router 전용이므로 Page Router에서는 사용할 수 없다.

typescript
// pages/search.tsx
import { useRouter } from 'next/router';

export default function Search() {
  const router = useRouter();
  const { q, sort } = router.query;

  return (
    <div>
      <h1>검색어: {q}</h1>
      <p>정렬: {sort}</p>
    </div>
  );
}

사용자가 /search?q=nextjs&sort=latest에 접속하면 q"nextjs", sort"latest"가 된다.

Query String은 동적 라우팅 파라미터와 함께 사용할 수도 있다. 모든 파라미터는 router.query에 통합된다.

typescript
// pages/posts/[id].tsx
import { useRouter } from 'next/router';

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

  return (
    <div>
      <h1>포스트 ID: {id}</h1>
      {comments && <p>댓글 표시 모드</p>}
    </div>
  );
}

/posts/123?comments=true에 접속하면 id"123", comments"true"가 된다.

Catch-all Routes

단일 동적 세그먼트는 한 단계의 경로만 매칭한다. /posts/[id].tsx/posts/123은 매칭하지만 /posts/123/comments는 매칭하지 못한다.

여러 단계의 경로를 한 번에 처리하려면 Catch-all Routes를 사용한다. 대괄호 안에 ...을 붙이면 된다.

plain
pages/
└── docs/
    └── [...slug].tsx      → /docs/* (모든 하위 경로)

[...slug].tsx/docs/a, /docs/a/b, /docs/a/b/c 등 모든 하위 경로를 매칭한다.

typescript
// pages/docs/[...slug].tsx
import { useRouter } from 'next/router';

export default function Docs() {
  const router = useRouter();
  const { slug } = router.query;

  // slug는 배열로 전달됨
  return (
    <div>
      <h1>문서 경로</h1>
      <p>세그먼트: {Array.isArray(slug) ? slug.join(' / ') : slug}</p>
    </div>
  );
}

위 코드에서 slug는 경로 세그먼트를 배열로 담는다.

  • /docs/introduction 접속 시: slug = ["introduction"]
  • /docs/getting-started/installation 접속 시: slug = ["getting-started", "installation"]
  • /docs/api/hooks/useRouter 접속 시: slug = ["api", "hooks", "useRouter"]

배열의 각 요소가 경로의 각 세그먼트에 해당한다. 이를 활용해 동적으로 컨텐츠를 렌더링하거나 파일 시스템에서 마크다운 파일을 읽어올 수 있다.

Catch-all Routes의 한계

하지만 [...slug].tsx는 최소 하나의 세그먼트가 필요하다. /docs에는 매칭되지 않고 /docs/something부터 매칭된다.

/docs도 같은 컴포넌트로 처리하고 싶다면 두 가지 방법이 있다.

  1. docs/index.tsx를 별도로 만든다
  2. Optional Catch-all Routes를 사용한다

Optional Catch-all Routes

대괄호를 한 번 더 감싸면 Optional Catch-all Routes가 된다.

plain
pages/
└── docs/
    └── [[...slug]].tsx    → /docs 및 /docs/* (모든 하위 경로)

[[...slug]].tsx/docs도 매칭하고, /docs/a, /docs/a/b 등 모든 하위 경로도 매칭한다.

typescript
// pages/docs/[[...slug]].tsx
import { useRouter } from 'next/router';

export default function Docs() {
  const router = useRouter();
  const { slug } = router.query;

  if (!slug) {
    // /docs 경로
    return <h1>문서 홈</h1>;
  }

  // /docs/* 경로
  return (
    <div>
      <h1>문서 경로</h1>
      <p>세그먼트: {slug.join(' / ')}</p>
    </div>
  );
}
  • /docs 접속 시: slug = undefined
  • /docs/introduction 접속 시: slug = ["introduction"]
  • /docs/api/hooks 접속 시: slug = ["api", "hooks"]

이렇게 하면 하나의 컴포넌트로 모든 문서 경로를 처리할 수 있다.

Next.js에서 페이지 간 이동은 next/linkLink 컴포넌트를 사용한다. HTML의 <a> 태그와 달리 클라이언트 사이드 네비게이션을 제공한다.

typescript
import Link from 'next/link';

export default function Nav() {
  return (
    <nav>
      <Link href="/"></Link>
      <Link href="/about">소개</Link>
      <Link href="/blog">블로그</Link>
    </nav>
  );
}

Link 컴포넌트를 사용하면 페이지 전체를 새로고침하지 않고 필요한 부분만 업데이트한다. 이를 통해 빠른 페이지 전환과 상태 유지가 가능하다.

동적 경로는 문자열 보간이나 객체 형태로 전달할 수 있다.

typescript
import Link from 'next/link';

export default function PostList() {
  const posts = [
    { id: 1, title: '첫 번째 포스트' },
    { id: 2, title: '두 번째 포스트' },
  ];

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

객체 형태로도 전달할 수 있다.

typescript
<Link
  href={{
    pathname: '/posts/[id]',
    query: { id: post.id },
  }}
>
  {post.title}
</Link>

Prefetching

Link 컴포넌트는 기본적으로 viewport에 보이는 링크를 자동으로 prefetch한다. 사용자가 링크를 클릭하기 전에 미리 페이지를 로드해두기 때문에 즉각적인 페이지 전환이 가능하다.

프로덕션 빌드에서만 동작하며, prefetch={false}로 비활성화할 수 있다.

typescript
<Link href="/about" prefetch={false}>
  소개
</Link>

useRouter 훅

useRouter 훅은 현재 라우터 정보에 접근하고 프로그래매틱하게 네비게이션을 제어할 수 있게 한다.

typescript
import { useRouter } from 'next/router';

export default function Page() {
  const router = useRouter();

  console.log(router.pathname);  // 현재 경로 (예: /posts/[id])
  console.log(router.asPath);    // 브라우저에 표시되는 전체 경로 (예: /posts/123?sort=latest)
  console.log(router.query);     // 쿼리 파라미터 객체 (예: { id: '123', sort: 'latest' })

  return <div>페이지</div>;
}

주요 속성은 다음과 같다.

  • pathname: 현재 페이지의 경로 패턴 (동적 세그먼트는 대괄호 형태로 표시됨)
  • asPath: 실제 브라우저에 표시되는 전체 경로
  • query: URL 파라미터와 Query String을 담은 객체
  • route: pathname과 동일

프로그래매틱 네비게이션

Link 컴포넌트 외에 코드로 페이지를 이동할 수 있다. router.push()router.replace()를 사용한다.

router.push()

router.push()는 새로운 URL을 브라우저 히스토리에 추가하고 이동한다.

typescript
import { useRouter } from 'next/router';

export default function LoginPage() {
  const router = useRouter();

  const handleLogin = async () => {
    // 로그인 로직...

    // 로그인 성공 후 대시보드로 이동
    router.push('/dashboard');
  };

  return <button onClick={handleLogin}>로그인</button>;
}

Query String이나 동적 세그먼트도 전달할 수 있다.

typescript
// 문자열 형태
router.push('/posts/123?sort=latest');

// 객체 형태
router.push({
  pathname: '/posts/[id]',
  query: { id: '123', sort: 'latest' },
});

router.replace()

router.replace()는 현재 히스토리를 대체한다. 뒤로가기를 눌렀을 때 이전 페이지로 돌아가지 않는다.

typescript
const handleRedirect = () => {
  // 히스토리에 추가하지 않고 대체
  router.replace('/new-page');
};

로그인 후 리다이렉트처럼 사용자가 뒤로가기로 돌아가지 못하게 하고 싶을 때 유용하다.

기타 메서드

  • router.back(): 브라우저 히스토리에서 뒤로 이동
  • router.reload(): 현재 페이지 새로고침
  • router.prefetch(url): 특정 페이지를 미리 로드

라우팅 우선순위

여러 라우트 패턴이 동일한 URL에 매칭될 수 있는 경우, Next.js는 다음 우선순위로 라우트를 결정한다.

  1. 정적 라우트 (우선순위 가장 높음)
  2. 동적 라우트
  3. Catch-all 라우트 (우선순위 가장 낮음)
plain
pages/
├── posts/
│   ├── index.tsx          (1) /posts
│   ├── create.tsx         (2) /posts/create
│   ├── [id].tsx           (3) /posts/:id
│   └── [...slug].tsx      (4) /posts/*

위 구조에서:

  • /posts 접속 시: index.tsx 매칭
  • /posts/create 접속 시: create.tsx 매칭 (정적 라우트가 우선)
  • /posts/123 접속 시: [id].tsx 매칭
  • /posts/a/b/c 접속 시: [...slug].tsx 매칭

create.tsx가 있다면 /posts/create[id].tsx보다 우선한다. 정적 라우트가 동적 라우트보다 우선순위가 높기 때문이다.

흔한 실수와 주의사항

파일명 충돌

동적 라우트와 정적 라우트가 같은 경로에 매칭될 수 있다. 의도치 않은 충돌을 피하려면 라우팅 우선순위를 이해해야 한다.

plain
pages/
└── posts/
    ├── create.tsx         → /posts/create (정적)
    └── [id].tsx           → /posts/:id (동적)

위 구조에서 /posts/create에 접속하면 create.tsx가 매칭된다. 하지만 /posts/edit 같은 경로를 추가하려면 매번 파일을 만들어야 한다. 차라리 /posts/new처럼 명확하게 분리하거나, 관리자 기능은 /admin/posts/create처럼 별도 경로로 두는 것이 좋다.

router.query의 초기값은 빈 객체

클라이언트 사이드에서 useRouter를 사용하면 첫 렌더링 시 router.query가 빈 객체일 수 있다. 이는 Next.js가 정적 최적화를 수행하기 때문이다.

typescript
// 잘못된 예
const Post = () => {
  const router = useRouter();
  const { id } = router.query;
  
  // 첫 렌더링에서 id는 undefined
  const post = posts.find(p => p.id === id);  // 항상 undefined
  
  return <div>{post.title}</div>;  // 에러 발생
};

// 올바른 예
const Post = () => {
  const router = useRouter();
  const { id } = router.query;
  
  // router.isReady를 체크하거나 id가 없을 때 처리
  if (!router.isReady || !id) {
    return <div>로딩 중...</div>;
  }
  
  const post = posts.find(p => p.id === id);
  return <div>{post?.title}</div>;
};

getServerSidePropsgetStaticProps를 사용하면 서버에서 이미 파라미터가 결정되므로 이 문제가 발생하지 않는다.

pages 폴더 외부 파일 import 주의

pages 폴더 내의 모든 파일이 라우트로 등록된다. 컴포넌트나 유틸 파일을 실수로 pages 폴더에 두면 의도치 않은 라우트가 생긴다.

plain
// 잘못된 구조
pages/
├── index.tsx
├── about.tsx
└── Button.tsx           → /Button 라우트가 생김 (의도하지 않음)

// 올바른 구조
pages/
├── index.tsx
└── about.tsx
components/
└── Button.tsx           → 라우트 아님

pages 폴더에는 페이지 컴포넌트만 두고, 공용 컴포넌트는 components 폴더에, 유틸은 lib 또는 utils 폴더에 두자.

Catch-all과 Optional Catch-all 혼동

[...slug].tsx[[...slug]].tsx의 차이를 혼동하기 쉽다.

패턴/docs 매칭/docs/intro 매칭
[...slug].tsxXO
[[...slug]].tsxOO

루트 경로까지 처리하려면 [[...slug]].tsx를 사용해야 한다. 그렇지 않으면 /docs 접속 시 404가 발생한다.

Link 컴포넌트에 객체로 href를 전달할 때, pathname은 파일 경로 패턴을 사용해야 한다.

typescript
// 잘못된 예 - 실제 URL을 pathname에 넣음
<Link href={{ pathname: '/posts/123', query: { sort: 'latest' } }}>

// 올바른 예 - 파일 경로 패턴 사용
<Link href={{ pathname: '/posts/[id]', query: { id: '123', sort: 'latest' } }}>

query 객체에서 동적 세그먼트 값(id)과 쿼리스트링(sort)이 자동으로 분리되어 최종 URL은 /posts/123?sort=latest가 된다.

404 페이지

존재하지 않는 경로에 접속할 때 표시되는 404 페이지를 커스터마이징할 수 있다. pages 폴더 최상위에 404.tsx 파일을 만들면 된다.

typescript
// pages/404.tsx
import Link from 'next/link';

export default function Custom404() {
  return (
    <div>
      <h1>404 - 페이지를 찾을 수 없습니다</h1>
      <p>요청하신 페이지가 존재하지 않습니다.</p>
      <Link href="/">홈으로 돌아가기</Link>
    </div>
  );
}

빌드 시 자동으로 정적 페이지로 생성되므로 추가 서버 부하 없이 빠르게 제공된다.

참고 자료