Next.js App Router 패턴은(는) 무엇을 하나요?
Next.js 14+ App Router 아키텍처, 서버 컴포넌트 및 최신 풀스택 React 개발을 위한 포괄적인 패턴입니다.
이 스킬을 사용해야 할 때
- App Router를 사용한 새로운 Next.js 애플리케이션 구축
- Pages Router에서 App Router로 마이그레이션
- 서버 컴포넌트 및 스트리밍 구현
- 병렬 및 인터셉팅 라우트 설정
- 데이터 페칭 및 캐싱 최적화
- 서버 액션을 통한 풀스택 기능 구축
핵심 개념
1. 렌더링 모드
| 모드 | 위치 | 사용 시기 |
|---|---|---|
| 서버 컴포넌트 | 서버 전용 | 데이터 페칭, 무거운 연산, 비밀 정보 |
| 클라이언트 컴포넌트 | 브라우저 | 상호작용, 훅, 브라우저 API |
| 정적(Static) | 빌드 시점 | 거의 변경되지 않는 콘텐츠 |
| 동적(Dynamic) | 요청 시점 | 개인화된 데이터 또는 실시간 데이터 |
| 스트리밍(Streaming) | 점진적 | 큰 페이지, 느린 데이터 소스 |
2. 파일 컨벤션
app/
├── layout.tsx # 공유 UI 래퍼
├── page.tsx # 라우트 UI
├── loading.tsx # 로딩 UI (Suspense)
├── error.tsx # 오류 경계(Error boundary)
├── not-found.tsx # 404 UI
├── route.ts # API 엔드포인트
├── template.tsx # 다시 마운트되는 레이아웃
├── default.tsx # 병렬 라우트 폴백
└── opengraph-image.tsx # OG 이미지 생성
빠른 시작
// app/layout.tsx
import { Inter } from 'next/font/google'
import { Providers } from './providers'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: { default: '내 앱', template: '%s | 내 앱' },
description: 'Next.js App Router로 구축됨',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
)
}
// app/page.tsx - 기본적으로 서버 컴포넌트
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 }, // ISR: 매시간 재검증
})
return res.json()
}
export default async function HomePage() {
const products = await getProducts()
return (
<main>
<h1>제품 목록</h1>
<ProductGrid products={products} />
</main>
)
}
패턴
패턴 1: 데이터 페칭이 있는 서버 컴포넌트
// app/products/page.tsx
import { Suspense } from 'react'
import { ProductList, ProductListSkeleton } from '@/components/products'
import { FilterSidebar } from '@/components/filters'
interface SearchParams {
category?: string
sort?: 'price' | 'name' | 'date'
page?: string
}
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
return (
<div className="flex gap-8">
<FilterSidebar />
<Suspense
key={JSON.stringify(params)}
fallback={<ProductListSkeleton />}
>
<ProductList
category={params.category}
sort={params.sort}
page={Number(params.page) || 1}
/>
</Suspense>
</div>
)
}
// components/products/ProductList.tsx - 서버 컴포넌트
async function getProducts(filters: ProductFilters) {
const res = await fetch(
`${process.env.API_URL}/products?${new URLSearchParams(filters)}`,
{ next: { tags: ['products'] } }
)
if (!res.ok) throw new Error('제품을 불러오는 데 실패했습니다')
return res.json()
}
export async function ProductList({ category, sort, page }: ProductFilters) {
const { products, totalPages } = await getProducts({ category, sort, page })
return (
<div>
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
<Pagination currentPage={page} totalPages={totalPages} />
</div>
)
}
패턴 2: 'use client'를 사용한 클라이언트 컴포넌트
// components/products/AddToCartButton.tsx
'use client'
import { useState, useTransition } from 'react'
import { addToCart } from '@/app/actions/cart'
export function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
const handleClick = () => {
setError(null)
startTransition(async () => {
const result = await addToCart(productId)
if (result.error) {
setError(result.error)
}
})
}
return (
<div>
<button
onClick={handleClick}
disabled={isPending}
className="btn-primary"
>
{isPending ? '추가 중...' : '장바구니에 담기'}
</button>
{error && <p className="text-red-500 text-sm">{error}</p>}
</div>
)
}
패턴 3: 서버 액션(Server Actions)
// app/actions/cart.ts
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
export async function addToCart(productId: string) {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session")?.value;
if (!sessionId) {
redirect("/login");
}
try {
await db.cart.upsert({
where: { sessionId_productId: { sessionId, productId } },
update: { quantity: { increment: 1 } },
create: { sessionId, productId, quantity: 1 },
});
revalidateTag("cart");
return { success: true };
} catch (error) {
return { error: "장바구니에 항목을 추가하지 못했습니다" };
}
}
export async function checkout(formData: FormData) {
const address = formData.get("address") as string;
const payment = formData.get("payment") as string;
// 검증
if (!address || !payment) {
return { error: "필수 필드가 누락되었습니다" };
}
// 주문 처리
const order = await processOrder({ address, payment });
// 확인 페이지로 리디렉션
redirect(`/orders/${order.id}/confirmation`);
}
패턴 4: 병렬 라우트(Parallel Routes)
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard-grid">
<main>{children}</main>
<aside className="analytics-panel">{analytics}</aside>
<aside className="team-panel">{team}</aside>
</div>
)
}
// app/dashboard/@analytics/page.tsx
export default async function AnalyticsSlot() {
const stats = await getAnalytics()
return <AnalyticsChart data={stats} />
}
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return <ChartSkeleton />
}
// app/dashboard/@team/page.tsx
export default async function TeamSlot() {
const members = await getTeamMembers()
return <TeamList members={members} />
}
패턴 5: 인터셉팅 라우트 (모달 패턴)
// 파일 구조: 사진 모달
// app/
// ├── @modal/
// │ ├── (.)photos/[id]/page.tsx # 인터셉트
// │ └── default.tsx
// ├── photos/
// │ └── [id]/page.tsx # 전체 페이지
// └── layout.tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal'
import { PhotoDetail } from '@/components/PhotoDetail'
export default async function PhotoModal({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<Modal>
<PhotoDetail photo={photo} />
</Modal>
)
}
// app/photos/[id]/page.tsx - 전체 페이지 버전
export default async function PhotoPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const photo = await getPhoto(id)
return (
<div className="photo-page">
<PhotoDetail photo={photo} />
<RelatedPhotos photoId={id} />
</div>
)
}
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
패턴 6: Suspense를 사용한 스트리밍
// app/product/[id]/page.tsx
import { Suspense } from 'react'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
// 이 데이터는 먼저 로드됨 (블로킹)
const product = await getProduct(id)
return (
<div>
{/* 즉시 렌더링 */}
<ProductHeader product={product} />
{/* 리뷰 스트리밍 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={id} />
</Suspense>
{/* 추천 제품 스트리밍 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={id} />
</Suspense>
</div>
)
}
// 이 컴포넌트들은 자신의 데이터를 가져옴
async function Reviews({ productId }: { productId: string }) {
const reviews = await getReviews(productId) // 느린 API
return <ReviewList reviews={reviews} />
}
async function Recommendations({ productId }: { productId: string }) {
const products = await getRecommendations(productId) // ML 기반, 느림
return <ProductCarousel products={products} />
}
패턴 7: 라우트 핸들러 (API 라우트)
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const category = searchParams.get("category");
const products = await db.product.findMany({
where: category ? { category } : undefined,
take: 20,
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const product = await db.product.create({
data: body,
});
return NextResponse.json(product, { status: 201 });
}
// app/api/products/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) {
return NextResponse.json({ error: "제품을 찾을 수 없습니다" }, { status: 404 });
}
return NextResponse.json(product);
}
패턴 8: 메타데이터와 SEO
// app/products/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const product = await getProduct(slug)
if (!product) return {}
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [{ url: product.image, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.image],
},
}
}
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } })
return products.map((p) => ({ slug: p.slug }))
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params
const product = await getProduct(slug)
if (!product) notFound()
return <ProductDetail product={product} />
}
캐싱 전략
데이터 캐시
// 캐시 없음 (항상 최신)
fetch(url, { cache: "no-store" });
// 영구 캐시 (정적)
fetch(url, { cache: "force-cache" });
// ISR - 60초 후 재검증
fetch(url, { next: { revalidate: 60 } });
// 태그 기반 무효화
fetch(url, { next: { tags: ["products"] } });
// 서버 액션을 통한 무효화
("use server");
import { revalidateTag, revalidatePath } from "next/cache";
export async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data });
revalidateTag("products");
revalidatePath("/products");
}
모범 사례
해야 할 일
- 서버 컴포넌트로 시작 - 필요한 경우에만 'use client' 추가
- 데이터 페칭을 함께 배치 - 데이터를 사용하는 곳에서 페칭
- Suspense 경계 사용 - 느린 데이터에는 스트리밍 활성화
- 병렬 라우트 활용 - 독립적인 로딩 상태
- 서버 액션 사용 - 점진적 향상(progressive enhancement)이 있는 변이에 사용
하지 말아야 할 일
- 직렬화할 수 없는 데이터 전달 금지 - 서버 → 클라이언트 경계 제한
- 서버 컴포넌트에서 훅 사용 금지 - useState, useEffect 사용 불가
- 클라이언트 컴포넌트에서 데이터 페칭 금지 - 서버 컴포넌트 또는 React Query 사용
- 레이아웃 과도 중첩 금지 - 각 레이아웃은 컴포넌트 트리에 추가됨
- 로딩 상태 무시 금지 - 항상 loading.tsx 또는 Suspense 제공