DEV LOG

주니엘의 개발일지

개발 공부 및 개발자 이야기를 올리는 블로그입니다.

JavaScript/Next.js

[Next.js] Page Router vs App Router (ft. 리모델링)

Next.js 에는 라우터가 두 개 ?

Next.js 를 처음 공부하면 제일 먼저 헷갈리는 부분

"pages 폴더로 하라는 강의도 있고, app 폴더로 하라는 강의도 있는데…?"

둘 다 맞다.
Next.js 에는 Page Router (pages 폴더) 와 App Router (app 폴더) 두 가지 방식이 존재한다.

같은 집을 리모델링 했다고 생각하면 편하다.
주소(URL) 는 그대로인데, 방 배치(폴더 구조) 와 배관(데이터 페칭) 이 싹 바뀌었다.

(SSR , CSR 개념이 헷갈린다면 이전 글 SSR vs CSR 을 먼저 보고 오면 좋습니다)


Page Router (구옥)

Next.js 13 이전부터 쓰던 방식
pages 폴더 안의 파일명이 곧 주소가 된다.

pages/
 ├─ index.tsx        → /
 ├─ search.tsx       → /search
 ├─ 404.tsx          → 404 페이지
 └─ book/
     └─ [id].tsx     → /book/1 , /book/100

데이터 페칭

페이지 컴포넌트 위에 약속된 함수를 export 해서 데이터를 받아온다.
이 함수는 컴포넌트보다 먼저, 서버에서만 실행된다.

함수 렌더링 방식 언제 만들어지나
getServerSideProps SSR 요청이 들어올 때마다
getStaticProps SSG 빌드 타임에 한 번
getStaticProps + revalidate ISR 빌드 타임 + 설정한 시간마다 재생성
export const getStaticProps = async () => {
  const [allBooks, recoBooks] = await Promise.all([
    fetchBooks(),
    fetchRandomBooks(),
  ]);

  return {
    props: { allBooks, recoBooks },
    revalidate: 3, // 3초마다 페이지 재생성 (ISR)
  };
};

export default function Home({
  allBooks,
  recoBooks,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  // ...
}

동적 경로를 SSG 로 만들려면 getStaticPaths 로 경로를 미리 알려줘야 한다.

export const getStaticPaths = () => {
  return {
    paths: [
      { params: { id: "1" } },
      { params: { id: "2" } },
      { params: { id: "3" } },
    ],
    fallback: false, // false : 404 , 'blocking' : SSR 처럼 , true : 폴백 화면 먼저
  };
};

레이아웃

헤더 , 푸터 같은 공통 레이아웃은 _app.tsx 에서 감싸준다.
문제는 특정 페이지에만 레이아웃을 주고 싶을 때다.

// 페이지마다 getLayout 을 직접 달아줘야 함
Home.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};
// _app.tsx 에서도 타입 정의 + 분기 처리 필요
type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactNode) => ReactNode;
};

export default function App({
  Component,
  pageProps,
}: AppProps & { Component: NextPageWithLayout }) {
  const getLayout = Component.getLayout ?? ((page: ReactNode) => page);

  return <GlobalLayout>{getLayout(<Component {...pageProps} />)}</GlobalLayout>;
}

레이아웃 하나 붙이는데 손이 꽤 간다.

구옥의 한계

  • 페이지별 레이아웃 설정이 번거롭다
  • 데이터 페칭이 페이지 컴포넌트 하나에 몰린다 (하위 컴포넌트는 props 로 받아야 함)
  • 서버에서만 쓰는 컴포넌트도 JS Bundle 에 전부 포함된다

App Router (리모델링)

Next.js 13 부터 등장한 방식 (현재 공식 권장)
React 18 의 기능 (서버 컴포넌트 , Suspense) 을 본격적으로 쓰기 위해 구조를 갈아엎었다.

폴더가 주소 , page.tsx 가 화면

app/
 ├─ layout.tsx            → 모든 페이지 공통 레이아웃
 ├─ page.tsx              → /
 ├─ (with-searchbar)/     → 라우트 그룹 (주소에 영향 없음)
 │   ├─ layout.tsx        → 서치바 레이아웃
 │   └─ search/
 │       ├─ page.tsx      → /search
 │       └─ loading.tsx   → 로딩 화면
 └─ book/
     └─ [id]/
         ├─ page.tsx      → /book/1
         └─ error.tsx     → 에러 화면

파일 이름에 역할이 정해져 있다.

  • page.tsx : 화면
  • layout.tsx : 레이아웃 (하위 폴더까지 자동 적용 , 중첩 가능)
  • loading.tsx : 로딩 화면
  • error.tsx : 에러 화면
  • not-found.tsx : 404

Page Router 에서 getLayout 으로 고생하던 레이아웃이 폴더에 layout.tsx 하나 두면 끝이다.

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div>
      <SearchBar />
      {children}
    </div>
  );
}

괄호로 감싼 폴더 (with-searchbar) 는 라우트 그룹이라 주소에는 영향이 없다.
서치바가 필요한 페이지만 그룹으로 묶어서 레이아웃을 줄 수 있다.

React Server Component

App Router 의 핵심
모든 컴포넌트는 기본이 서버 컴포넌트다.

서버 컴포넌트 클라이언트 컴포넌트
선언 기본값 파일 최상단에 'use client'
실행 서버에서 1번 서버 1번 + 브라우저(하이드레이션) 1번
JS Bundle 포함 안 됨 포함됨
useState , useEffect , onClick 사용 불가 사용 가능
async / await 사용 가능 사용 불가

브라우저로 보낼 필요 없는 컴포넌트는 JS Bundle 에서 빠지니까 용량이 줄어든다.
그래서 대부분은 서버 컴포넌트로 두고, 상호작용이 필요한 부분만 클라이언트 컴포넌트로 쓰는 게 기본 전략이다.

주의할 점

  1. 클라이언트 컴포넌트에서 서버 컴포넌트를 import 하면 클라이언트 컴포넌트로 바뀐다 (children 으로 넘기는 건 가능)
  2. 서버 → 클라이언트로 넘기는 props 는 직렬화가 가능해야 한다 (함수는 못 넘김)

데이터 페칭 : 필요한 곳에서 직접

getServerSideProps , getStaticProps 가 사라졌다.
서버 컴포넌트는 async 가 되니까 데이터가 필요한 컴포넌트에서 직접 fetch 하면 된다.

async function RecoBooks() {
  const response = await fetch(`${API_URL}/book/random`, {
    next: { revalidate: 3 }, // 3초마다 갱신 (ISR 과 비슷)
  });
  const books: BookData[] = await response.json();

  return <BookList books={books} />;
}

fetch 옵션으로 캐싱 방식을 정한다.

{ cache: "no-store" }          // 캐싱 안 함 (SSR 처럼)
{ cache: "force-cache" }       // 무조건 캐싱 (SSG 처럼)
{ next: { revalidate: 3 } }    // 3초마다 갱신 (ISR 처럼)
{ next: { tags: ["review"] } } // 태그 단위로 갱신 (revalidateTag)

Page Router 의 SSR / SSG / ISR 이 페이지 단위였다면, App Router 는 fetch 요청 단위로 정할 수 있다.

(Next.js 15 부터는 fetch 가 기본적으로 캐싱되지 않으니, 캐싱이 필요하면 옵션을 꼭 명시하자)

스트리밍 : 밑반찬부터

오래 걸리는 컴포넌트 때문에 화면 전체가 멈추지 않도록, 준비된 부분부터 먼저 보내는 기능

식당에서 메인 요리 나오기 전에 밑반찬부터 깔아주는 느낌이다.

export default function Home() {
  return (
    <div>
      <section>
        <h3>지금 추천하는 도서</h3>
        <Suspense fallback={<BookListSkeleton count={3} />}>
          <RecoBooks />
        </Suspense>
      </section>
      <section>
        <h3>등록된 모든 도서</h3>
        <Suspense fallback={<BookListSkeleton count={10} />}>
          <AllBooks />
        </Suspense>
      </section>
    </div>
  );
}

페이지 전체 로딩이면 loading.tsx , 컴포넌트 단위면 Suspense 로 감싸면 된다.

Server Actions

폼 제출 같은 작업을 API 라우트 없이 서버 함수로 바로 처리한다.

async function createReviewAction(formData: FormData) {
  "use server";

  const content = formData.get("content");
  const author = formData.get("author");

  await fetch(`${API_URL}/review`, {
    method: "POST",
    body: JSON.stringify({ bookId, content, author }),
  });

  revalidatePath(`/book/${bookId}`); // 해당 페이지 다시 생성
}

export default function ReviewEditor() {
  return (
    <form action={createReviewAction}>
      <input name="content" placeholder="리뷰 내용" />
      <input name="author" placeholder="작성자" />
      <button type="submit">작성하기</button>
    </form>
  );
}

그 외 새로 생긴 것들

  • Parallel Route : @폴더명 으로 한 화면에 여러 페이지를 동시에 (사이드바 + 피드)
  • Intercepting Route : (.)폴더명 으로 기존 화면 위에 모달처럼 띄우기 (인스타그램 게시물 클릭)
  • Metadata API : next/head 대신 metadata 객체 , generateMetadata 함수로 SEO 설정

Page Router vs App Router

항목 Page Router App Router
폴더 pages/ app/
라우팅 파일명 = 주소 (search.tsx) 폴더명 = 주소 (search/page.tsx)
레이아웃 _app.tsx + getLayout 직접 구현 layout.tsx (중첩 자동)
컴포넌트 전부 클라이언트 컴포넌트 기본 서버 컴포넌트 + 'use client'
데이터 페칭 getServerSideProps / getStaticProps 컴포넌트에서 직접 async fetch
렌더링 단위 페이지 단위 (SSR / SSG / ISR) fetch 요청 단위 (cache , revalidate)
로딩 / 에러 직접 구현 loading.tsx / error.tsx
스트리밍 미지원 Suspense , loading.tsx
데이터 변경 API 라우트 (pages/api) Server Actions
SEO next/head Metadata API
JS Bundle 모든 컴포넌트 포함 클라이언트 컴포넌트만 포함

그래서 뭘 써야 할까 ?

  • 새 프로젝트라면 App Router
    공식 문서도 App Router 기준이고, 새로운 기능은 App Router 에만 들어온다.
  • 이미 Page Router 로 만든 프로젝트라면 굳이 급하게 옮길 필요는 없다
    Page Router 도 계속 지원되고, 한 프로젝트 안에서 pages 와 app 을 같이 쓰면서 조금씩 옮길 수도 있다.

정리하면 구옥 (Page Router) 도 살 만하지만,
새로 들어갈 집이라면 리모델링된 집 (App Router) 이 편하다.