[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 에서 빠지니까 용량이 줄어든다.
그래서 대부분은 서버 컴포넌트로 두고, 상호작용이 필요한 부분만 클라이언트 컴포넌트로 쓰는 게 기본 전략이다.
주의할 점
- 클라이언트 컴포넌트에서 서버 컴포넌트를 import 하면 클라이언트 컴포넌트로 바뀐다 (children 으로 넘기는 건 가능)
- 서버 → 클라이언트로 넘기는 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) 이 편하다.