Next.js App Router 완벽 가이드 | Server Components·Streaming
이 글의 핵심
Next.js 13+ App Router 완벽 가이드. Server Components, Streaming, Server Actions, Parallel Routes, Intercepting Routes까지 실전 예제로 정리.
처음엔 pages/랑 getServerSideProps만으로도 충분했어. 근데 라우트가 늘어나면서 _app이 비대해지고, 레이아웃을 URL마다 다르게 묶고 싶을 때마다 props 지옥이 시작됐지. 그때 “그냥 App Router로 갈까?” 하고 브랜치 파서 app/ 하나씩 옮겼다. 한 주말에 홈·목록·상세만 붙여봤는데, 번들이 눈에 띄게 가벼워진 느낌이었어. 지금 생각하면 그때 이미 갈아탈 이유는 충분했던 거야.
Pages Router는 이제 “유지해야 할 레거시” 쪽에 가깝다고 본다. 공식도 신규는 app/ 권장이고, RSC·스트리밍·레이아웃 중첩 이야기할 때 기준이 전부 App Router잖아. 기존 프로젝트가 pages/에 수백 개 붙어 있으면 당연히 한 번에 못 옮기고, 점진적으로 app으로 route group 나눠가면서 옮기면 돼. 근데 새 그린필드에서 아직 pages/로 시작하는 건 솔직히 이유를 들어봐야 해. 팀 온보딩 비용이랑 레퍼런스 시기를 생각하면 기본 선택지는 App Router가 맞다고 봐.
App Router vs Pages Router — 뭐가 달라졌나
| 구분 | Pages Router (pages/) | App Router (app/) |
|---|---|---|
| 라우팅 단위 | 파일 = 페이지, _app·_document로 전역 래핑 | page.tsx = 페이지, layout.tsx로 트리 단위 래핑 |
| 데이터 | getServerSideProps / getStaticProps / getInitialProps | async 서버 컴포넌트, fetch 옵션, Route Handlers |
| 레이아웃 | 중첩 레이아웃을 직접 설계해야 함 | 폴더 깊이만큼 layout.tsx가 자동 중첩 |
| 번들 | 페이지 단위로 클라이언트 번들에 가깝게 실림 | 서버 컴포넌트 기본 → 클라이언트 JS 감소 |
| 마이그레이션 | — | pages와 app 공존 가능, 경로 단위 점진 이동 |
Pages는 “한 파일이 곧 한 URL의 엔트리”라서 익숙하지만, 레이아웃·캐시·스트리밍을 프레임워크가 일관되게 주지 못했어. App Router는 라우트 트리 전체를 서버에서 먼저 그리고, 인터랙션만 클라이언트로 쪼개는 쪽으로 기준이 바뀐 거라고 보면 된다. 다만 문서·블로그 예제가 Pages 기반이 아직 많으니, 옛 글 복붙하다 보면 헷갈린다—새 코드는 공식 App Router 문서만 보는 게 정신 건강에 낫다.
App Router 기본 — 파일 컨벤션
App Router가 뭐냐면, 폴더가 URL이 되는 건 비슷한데 app/ 아래에서 page.tsx / layout.tsx / loading.tsx / error.tsx 이런 특수 파일로 의도를 파일 이름에 박아 넣는 모델이야. 루트는 무조건 layout.tsx에 html/body 넣고, 그 아래에서만 children 받으면서 헤더·푸터를 공유하면 돼.
// app/layout.tsx — 뼈대만
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>
<header>{/* nav */}</header>
<main>{children}</main>
</body>
</html>
);
}
서버 컴포넌트 심화 — RSC가 도는 방식
서버 컴포넌트는 “그냥 서버에서만 돈다” 수준이 아니라, 서버에서 트리를 직렬화해서 스트림으로 보내고, 클라이언트는 그걸 펼쳐서 붙인다는 모델에 가깝다. .tsx가 서버에서 실행되면 DOM이 아니라 RSC 페이로드(컴포넌트 트리 + props + 서버에서만 가능한 참조)가 만들어지고, 클라이언트 경계('use client')에서만 하이드레이션이 일어난다.
솔직히 말하면 멘탈 모델은 “서버에서 React 트리 한 번 그린다”가 맞고, “기존 SSR + 하이드레이션”보다 클라이언트로 넘어가는 subtree가 작아진다는 데 이득이 있다. 주의할 점은 세 가지 정도.
- 서버 컴포넌트 안에서는
useState·useEffect·브라우저 API가 없다. 필요하면 자식을 클라이언트 컴포넌트로 내리거나, 작은 래퍼를 둔다. - 서버 컴포넌트는 직렬화 가능한 props만 자식 클라이언트로 넘긴다. 함수를 props로 넘기는 패턴은 클라이언트 쪽에서 다시 생각해야 한다(서버 액션과 조합하는 식).
- 데이터 페칭이 컴포넌트에 흩어지면 “이 API는 어디서 호출되지?” 추적이 어려워진다. 팀 규모가 커지면
lib/api같은 레이어로 모으는 걸 추천한다.
// app/dashboard/page.tsx — 서버에서만 실행; async 가능
import { noStore } from 'next/cache';
async function getMetrics() {
noStore(); // 이 요청 경로에서 동적 데이터로 취급 (전체 캐시 전략과 함께 확인)
const res = await fetch(`${process.env.API_URL}/metrics`, { cache: 'no-store' });
if (!res.ok) throw new Error('metrics failed');
return res.json();
}
export default async function DashboardPage() {
const data = await getMetrics();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
클라이언트 컴포넌트 전략 — 'use client'는 “최소 면적”
서버 컴포넌트는 기본값이야. async 붙여서 데이터 패칭을 컴포넌트 안에서 끝내면, 예전처럼 페이지 바깥에서 props로 끌어올 필요가 줄어들지. 클라이언트로 보낼 JS만 따로 'use client' 박스에 넣으면 되고.
실전 팁: 파일 최상단에 'use client'를 박는 대신, 리프에 가깝게 작은 컴포넌트만 클라이언트로 두는 게 좋다. 상위 레이아웃 전체가 클라이언트가 되면 번들 이득이 거의 사라진다. 폼·모달·차트·드래그앤드롭처럼 상태/이벤트가 필수인 조각만 use client로 가두자.
'use client';
import { useState } from 'react';
export function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
// app/posts/page.tsx — 서버에서 목록 + 클라이언트 조각만 주입
import { Counter } from './counter';
async function getPosts() {
const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
if (!res.ok) throw new Error('failed');
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<>
<ul>
{posts.map((p: { id: string; title: string }) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
<Counter />
</>
);
}
클라이언트는 정말 필요할 때만. 클릭, 훅, window, 작은 인터랙션은 use client 파일로 빼고, 나머지는 서버에 남겨. 이 경계를 어중간하게 두면 하이드레이션 지옥만 생기니까, 처음엔 “의심스러우면 서버”로 가져가는 게 편해.
중첩 레이아웃과 템플릿
layout.tsx는 같은 세그먼트 트리에서 유지되는 래퍼다. URL이 바뀌어도 공유 레이아웃은 언마운트되지 않고 state를 유지할 수 있다(예: 사이드바 탭 상태를 레이아웃 쪽 클라이언트에 두는 패턴).
template.tsx는 비슷해 보이지만 자식이 네비게이션할 때마다 새로 마운트된다. 애니메이션 진입/퇴장, 페이지뷰 로깅처럼 “페이지 전환 = 새 인스턴스”가 필요할 때 쓴다. 둘 다 같은 폴더에 둘 수는 있지만, 보통은 레이아웃 OR 템플릿 중 하나로 역할을 나눈다.
// app/(shop)/layout.tsx — (shop)은 URL에 안 붙는 route group
export default function ShopLayout({ children }: { children: React.ReactNode }) {
return (
<div className="shop-shell">
<aside>카테고리</aside>
<section>{children}</section>
</div>
);
}
병렬 라우트와 인터셉팅 라우트
Parallel Routes는 @slot 폴더로 한 화면에 여러 페이지 트리를 동시에 렌더링할 때 쓴다. 대시보드에서 “메인 + 서브 패널”을 URL로도 표현하고 싶을 때 유리하다. 각 슬롯에 default.tsx를 두는 게 중요하다—슬롯이 비는 네비게이션에서 404가 나지 않게 하려면 말이야.
// app/@modal/default.tsx
export default function Default() {
return null;
}
Intercepting Routes는 (...) 세그먼트로 “같은 레이아웃 안에서 모달로 열고, 직접 URL 치면 풀 페이지” 같은 UX를 만든다. 인스타 그리드 → 사진 상세 모달 패턴이 대표적이다. 라우트 규칙이 한 번에 머리에 안 들어오면 정상이다—필요할 때 폴더 트리 그리면서 공식 문서의 시각 자료를 병행하는 걸 추천한다.
Parallel Routes나 intercepting routes까지 여기서 다 뽑아 쓰진 않을게. 문서에 다 있고, 필요한 순간에 슬롯 폴더 열어보면 돼. 중요한 건 마이그레이션할 때 pages랑 app을 한 프로젝트에 공존시킬 수 있다는 거—한 경로씩 옮기면서 트래픽·테스트 확인하면 된다. 우리는 먼저 읽기 위주 페이지부터 옮기고, 폼·인증 같이 클라이언트 꺼무늉한 건 나중에 묶었어. 순서 잘못 잡으면 첫 주에 멘탈만 갈려.
데이터 페칭 패턴 — fetch, cache, revalidate
App Router에서 fetch는 단순 HTTP 클라이언트가 아니라 캐시 키와 연동된 추상화로 취급된다(같은 요청 병합, 태그 기반 재검증 등). 팀마다 Prisma 직접 호출 vs fetch만 쓰는 팀이 갈리는데, “캐시/재검증 스토리를 fetch에 맞출지”를 먼저 합의하는 게 좋다.
// ISR 느낌: 1시간마다 백그라운드 재검증
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 },
});
// 태그 단위 재검증 (Route Handler나 Server Action에서 revalidateTag 연동)
const res2 = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// 완전 동적 (요청마다 새 데이터)
const res = await fetch('https://api.example.com/me', { cache: 'no-store' });
캐시 얘기 나오면 표로 정리하고 싶은데, 이번엔 그냥 문장으로만 쓸게. 거의 안 바뀌는 마케팅 페이지면 기본 캐시 쪽에 두고, 대시보드처럼 자주 갱신되면 revalidate 짧게 주거나 no-store로 때우는 식. 실시간에 가깝게 가야 하면 no-store가 맞고, 뉴스·블로그처럼 “1시간마다 갱신돼도 된다”면 revalidate: 3600 같은 식으로 ISR 느낌 내는 거지. 숫자 표가 없어서 불편하면 그게 맞아—실무에서는 결국 팀이 “여기는 몇 분 stale 허용?”을 합의하는 게 먼저거든.
서버 액션 실전 예제
서버 액션은 폼 action이나 useTransition과 엮어서 뮤테이션을 서버 함수로 고정하는 패턴이다. API Route를 따로 안 뚫어도 되는 경우가 많지만, 권한 검증·입력 검증은 서버 액션 안에서 반복해야 한다—클라이언트만 믿으면 안 된다.
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = String(formData.get('title') ?? '');
if (!title.trim()) return { ok: false, error: '제목 필수' };
const res = await fetch(`${process.env.API_URL}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) return { ok: false, error: '서버 오류' };
revalidatePath('/posts');
return { ok: true };
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">저장</button>
</form>
);
}
프로덕션에선 에러 메시지 노출, 레이트 리밋, CSRF/Origin 체크(호스팅 환경에 따라)까지 붙이는 걸 잊지 마라. 서버 액션이 편하다고 보안이 공짜는 아니다.
스트리밍과 Suspense
loading.tsx는 해당 세그먼트의 즉시 스트리밍 가능한 폴백으로 쓰인다. 더 세밀하게는 페이지 안에서 Suspense로 경계를 나눠 느린 조각만 스켈레톤으로 감싼다.
import { Suspense } from 'react';
async function SlowBlock() {
await new Promise((r) => setTimeout(r, 2000));
return <div>늦게 도착한 데이터</div>;
}
export default function Page() {
return (
<Suspense fallback={<p>불러오는 중…</p>}>
<SlowBlock />
</Suspense>
);
}
솔직한 평: 스트리밍은 체감 TTFB·UX에 도움이 되지만, 캐시 정책이 꼬이면 “깜빡이는 중첩 로딩”만 늘 수 있다. loading.tsx를 남발하기보다 데이터 의존성을 쪼개서 Suspense 경계를 설계하는 편이 장기적으로 깔끔하다.
실제 프로덕션 마이그레이션에서 겪은 것들
우리 팀은 pages의 읽기 전용 화면부터 app으로 옮겼다. 이유는 단순하다—getServerSideProps 의존도가 낮고, 레이아웃 이득이 바로 보이니까. 반대로 복잡한 클라이언트 상태(온보딩 위저드, 에디터)는 당분간 pages에 두고, 라우트 충돌만 안 나게 경로를 조정했다.
겪은 함정 몇 가지를 솔직히 적으면 이렇다.
- 서드파티 라이브러리가 전부 클라이언트라서 상위가
use client로 오염되는 경우 → 래퍼를 얇게 두고dynamic(..., { ssr: false })같은 전략을 쓰기도 했다(용도에 따라 다름). - 캐시 기본값이 기대와 다르면 개발 중엔 잘 돌아가도 프로덕션에서만 stale 데이터가 터진다 →
fetch옵션과revalidatePath/revalidateTag를 코드리뷰 체크리스트에 넣었다. - 라우트 그룹
(marketing)/(app)나누는 건 URL은 깔끔해지는데, 폴더만으로 팀원이 길을 잃는다 → README에 트리 다이어그램 한 장은 진짜 도움이 된다.
나는 Next를 “리액트에 라우팅 붙인 프레임워크”보다 서버와 클라이언트 경계를 코드로 강제하는 도구에 가깝게 쓰는 편이야. App Router가 그걸 가장 분명하게 보여준다. Pages Router로 잘 돌아가는 서비스를 억지로 갈아엎을 필요는 없지만, 새로 짤 땐 고민할 단계는 지났다고 보면 된다.
배포 전에 로컬에서 한번 돌려보고 git add → commit → push 후 npm run deploy 순서만 지키면 돼.