
AI agent를 통한 개발이 활발하게 이루어지고 있고, 그에 따라 사용하는 프레임워크나 Tool 선택이 중요해졌습니다. 그 중 Next.js 가 16 버전으로 업데이트 되면서, ai와 함께 하는 풀스택 개발이 더욱 용이해진 것 같습니다.
개요 : 기존의 파일 기반 라우팅 시스템을 계승하면서도, 성능 최적화, 비동기 데이터 처리, 그리고 명시적 캐싱 모델에서 혁신적인 변화를 도입했습니다. 본 포스트에서는 이러한 핵심 개념들을 체계적으로 정리하고, 프로젝트에서 바로 활용할 수 있는 예제와 함께 정리해보겠습니다.
Next.js의 라우팅 시스템은 "Convention over Configuration" 철학을 따릅니다. 별도의 라우팅 라이브러리나 복잡한 설정 없이, 파일 시스템 구조 자체가 곧 URL 경로가 됩니다.
app 디렉터리가 라우팅의 루트가 되며, 폴더 구조가 URL 경로와 1:1로 매핑됩니다.
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
└── dashboard/
├── page.tsx → /dashboard
└── settings/
└── page.tsx → /dashboard/settings
page.tsx의 역할각 폴더 내의 page.tsx 파일이 해당 경로의 Entry Point가 됩니다. 이 파일이 없으면 해당 경로는 접근 불가능합니다.
// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>Welcome to our about page!</p>
</main>
);
}
폴더 안에 폴더를 구성하여 계층 구조를 자연스럽게 형성할 수 있습니다. 이는 다음과 같은 이점을 제공합니다:
| 장점 | 설명 |
|---|---|
| 직관적인 구조 | URL 경로와 파일 구조가 일치하여 코드 내비게이션이 쉬움 |
| 레이아웃 공유 | 상위 폴더의 레이아웃이 하위 경로에 자동 적용 |
| 코드 분할 | 각 경로별로 자동 코드 스플리팅이 적용됨 |
| 유지보수성 | 관련 코드가 물리적으로 가까이 위치 |
URL의 가변적인 세그먼트를 처리하는 동적 라우팅은 Next.js의 핵심 기능 중 하나입니다.
| 패턴 | 예시 | 매칭 경로 | params 결과 |
|---|---|---|---|
[id] | app/posts/[id]/page.tsx | /posts/1, /posts/abc | { id: '1' } |
[...slug] | app/docs/[...slug]/page.tsx | /docs/a/b/c | { slug: ['a', 'b', 'c'] } |
[[...slug]] | app/shop/[[...slug]]/page.tsx | /shop, /shop/a/b | { slug: undefined } 또는 { slug: ['a', 'b'] } |
Next.js 15/16 버전부터 params와 searchParams는 Promise 객체로 전달됩니다. 이는 프레임워크의 렌더링 최적화를 위한 중요한 변화입니다.
// ❌ 이전 방식 (Next.js 14 이하) - 더 이상 동작하지 않음
export default function Page({ params }: { params: { id: string } }) {
return <div>User ID: {params.id}</div>; // 런타임 에러!
}
// ✅ 현재 방식 (Next.js 15/16) - 비동기 처리 필수
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <div>User ID: {id}</div>;
}
클라이언트 컴포넌트에서는 async/await를 직접 사용할 수 없으므로, React 19의 use() 훅을 활용합니다.
'use client';
import { use } from 'react';
export default function UserProfile({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return (
<div className="profile-card">
<h2>User Profile</h2>
<p>ID: {id}</p>
</div>
);
}
❗마이그레이션 체크리스트
- 모든
params접근을await또는use()로 변경searchParams역시 동일하게 비동기 처리- 타입 정의를
Promise<T>로 업데이트- 기존 동기 접근 로직 제거
레이아웃은 여러 페이지 간에 공통 UI를 공유하고 상태를 유지하는 핵심 메커니즘입니다.
app/
├── layout.tsx ← 모든 페이지에 적용 (필수)
├── page.tsx
└── dashboard/
├── layout.tsx ← /dashboard/* 경로에만 적용
├── page.tsx
└── analytics/
└── page.tsx ← 상위 두 레이아웃 모두 적용
// app/layout.tsx (루트 레이아웃 - 필수)
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>
<header>
<nav>메인 네비게이션</nav>
</header>
<main>{children}</main>
<footer>© 2026 My App</footer>
</body>
</html>
);
}
// app/dashboard/layout.tsx (대시보드 전용 레이아웃)
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-wrapper">
<aside className="sidebar">
<ul>
<li>개요</li>
<li>분석</li>
<li>설정</li>
</ul>
</aside>
<section className="content">{children}</section>
</div>
);
}
Next.js 16에서 도입된 Layout Deduplication은 동일한 레이아웃을 사용하는 경로들 사이를 탐색할 때, 해당 레이아웃 컴포넌트를 재다운로드하지 않고 재사용합니다.
[기존 방식]
/dashboard/overview → /dashboard/analytics
레이아웃 번들 다운로드 (중복 발생!)
[Next.js 16 Deduplication]
/dashboard/overview → /dashboard/analytics
레이아웃 캐시 활용 (네트워크 요청 없음!)
성능 개선 효과:
| 항목 | 개선 전 | 개선 후 | 향상률 |
|---|---|---|---|
| 네트워크 요청 | 레이아웃마다 재요청 | 최초 1회만 요청 | ~80% 감소 |
| 페이지 전환 속도 | 200-500ms | 50-100ms | ~70% 단축 |
| 프리페칭 효율 | 중복 다운로드 | 스마트 캐싱 | 대역폭 절약 |
| 특성 | Layout | Template |
|---|---|---|
| 리렌더링 | 페이지 전환 시 유지 | 매 전환마다 새로 마운트 |
| 상태 유지 | O | X |
| useEffect 재실행 | X | O |
| 사용 케이스 | 네비게이션, 사이드바 | 진입 애니메이션, 페이지 뷰 로깅 |
URL 구조에 영향을 주지 않으면서 프로젝트 구조를 논리적으로 조직화할 수 있는 강력한 기능입니다.
폴더명을 소괄호 ()로 감싸면 해당 폴더는 URL 경로에서 제외됩니다.
app/
├── (marketing)/
│ ├── layout.tsx ← 마케팅 전용 레이아웃
│ ├── page.tsx → /
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
├── (dashboard)/
│ ├── layout.tsx ← 대시보드 전용 레이아웃
│ ├── overview/
│ │ └── page.tsx → /overview
│ └── settings/
│ └── page.tsx → /settings
└── (auth)/
├── layout.tsx ← 인증 전용 레이아웃 (최소 UI)
├── login/
│ └── page.tsx → /login
└── register/
└── page.tsx → /register
시나리오 1: 다중 루트 레이아웃
동일한 루트 레벨에서 완전히 다른 레이아웃을 적용해야 할 때 유용합니다.
// app/(marketing)/layout.tsx - 화려한 랜딩 페이지 레이아웃
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="marketing-theme">
<header className="hero-header">...</header>
{children}
<footer className="full-footer">...</footer>
</div>
);
}
// app/(dashboard)/layout.tsx - 미니멀한 앱 레이아웃
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="app-theme">
<Sidebar />
<main>{children}</main>
</div>
);
}
시나리오 2: 팀/도메인별 코드 분리
app/
├── (shop)/ ← 이커머스 팀 담당
│ ├── products/
│ └── cart/
├── (blog)/ ← 콘텐츠 팀 담당
│ ├── posts/
│ └── categories/
└── (admin)/ ← 관리자 기능
└── users/
Next.js는 특정 상태에 대응하기 위한 예약된 파일들을 제공하여 선언적인 UI 관리를 가능하게 합니다.
| 파일명 | 용도 | React 기반 | 필수 지시어 | 버전 |
|---|---|---|---|---|
layout.tsx | 공유 UI 래퍼 | - | - | 13+ |
page.tsx | 고유 페이지 UI | - | - | 13+ |
loading.tsx | 로딩 중 스켈레톤 UI | Suspense | - | 13+ |
error.tsx | 런타임 에러 폴백 | Error Boundary | 'use client' | 13+ |
not-found.tsx | 404 에러 페이지 | - | - | 13+ |
template.tsx | 리마운트 레이아웃 | - | - | 13+ |
forbidden.tsx | 403 권한 없음 | - | - | 16 🆕 |
unauthorized.tsx | 401 인증 필요 | - | - | 16 🆕 |
loading.tsx)// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="loading-container">
<div className="skeleton-header" />
<div className="skeleton-grid">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton-card" />
))}
</div>
<p className="loading-text">대시보드 로딩 중...</p>
</div>
);
}
error.tsx)// app/dashboard/error.tsx
'use client'; // ⚠️ 필수!
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// 에러 로깅 서비스로 전송
console.error('Dashboard Error:', error);
}, [error]);
return (
<div className="error-container">
<h2>⚠️ 문제가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset} className="retry-btn">
다시 시도
</button>
</div>
);
}
// app/admin/forbidden.tsx - 403 Forbidden
export default function AdminForbidden() {
return (
<div className="forbidden-page">
<h1>🚫 접근 권한이 없습니다</h1>
<p>이 페이지를 보려면 관리자 권한이 필요합니다.</p>
<a href="/contact">권한 요청하기</a>
</div>
);
}
// app/dashboard/unauthorized.tsx - 401 Unauthorized
export default function DashboardUnauthorized() {
return (
<div className="unauthorized-page">
<h1>🔐 로그인이 필요합니다</h1>
<p>이 페이지를 보려면 먼저 로그인해주세요.</p>
<a href="/login">로그인 페이지로 이동</a>
</div>
);
}
[!WARNING]
Error Component 주의점
error.tsx는 클라이언트 사이드에서 에러를 캡처해야 하므로 반드시 파일 최상단에'use client'지시어를 포함해야 합니다. 이를 누락하면 에러 바운더리가 제대로 동작하지 않습니다.
프론트엔드 라우팅과 동일한 파일 시스템 기반으로 백엔드 API 엔드포인트를 구축합니다.
app/
└── api/
├── users/
│ ├── route.ts → GET/POST /api/users
│ └── [id]/
│ └── route.ts → GET/PUT/DELETE /api/users/:id
└── events/
└── route.ts → /api/events
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
// GET /api/users - 사용자 목록 조회
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const users = await db.user.findMany({
skip: (page - 1) * limit,
take: limit,
});
return NextResponse.json({
data: users,
pagination: { page, limit },
});
}
// POST /api/users - 사용자 생성
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email } = body;
const newUser = await db.user.create({
data: { name, email },
});
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
);
}
}
// app/api/events/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params; // ⚠️ 비동기 처리 필수!
const event = await db.event.findUnique({
where: { id },
include: { attendees: true },
});
if (!event) {
return Response.json(
{ error: 'Event not found' },
{ status: 404 }
);
}
return Response.json(event);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
const updatedEvent = await db.event.update({
where: { id },
data: body,
});
return Response.json(updatedEvent);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.event.delete({ where: { id } });
return new Response(null, { status: 204 });
}
Next.js 16 캐싱의 핵심은 "기본 동적 렌더링, 선택적 캐싱(Opt-in)"으로의 패러다임 전환입니다.
- SSG(Static Site Generation)나 ISR(Incremental Static Regeneration)은 이제 별도의 설정이 아니라, 개발자가 Cache Boundaries를 어떻게 정의하느냐에 따른 결과물로 통합되었습니다.
| 항목 | 변경 전 (Next.js 14 이하) | 변경 후 (Next.js 15/16) |
|---|---|---|
fetch 기본값 | force-cache (정적) | no-store (동적) |
| 캐싱 결정 | 프레임워크가 자동 판단 | 개발자가 명시적 선언 |
| Stale Data 가능성 | 높음 | 낮음 |
// next.config.ts에서 활성화 필요
const nextConfig = {
experimental: {
cacheComponents: true,
},
};
// 파일 단위 캐싱
'use cache';
export default async function CachedPage() {
const data = await fetchExpensiveData();
return <div>{data}</div>;
}
// 함수 단위 캐싱
async function getCachedProducts(category: string) {
'use cache';
const products = await db.product.findMany({
where: { category },
});
return products;
}
// 컴포넌트 단위 캐싱
async function CachedSidebar() {
'use cache';
const categories = await getCategories();
return (
<aside>
{categories.map((cat) => (
<a key={cat.id} href={`/products/${cat.slug}`}>{cat.name}</a>
))}
</aside>
);
}
| API | 설명 | 사용 예시 |
|---|---|---|
cacheLife('max') | 최대한 오래 캐싱 | 정적 콘텐츠 |
cacheLife('hours') | 수 시간 캐싱 | 뉴스 피드 |
cacheLife('days') | 며칠간 캐싱 | 제품 목록 |
cacheLife({ revalidate: 60 }) | 60초마다 갱신 | 실시간 데이터 |
import { cacheLife } from 'next/cache';
async function getWeatherData(city: string) {
'use cache';
cacheLife('hours'); // 1시간 캐싱
const response = await fetch(`https://weather-api.com/${city}`);
return response.json();
}
import { revalidateTag, updateTag } from 'next/cache';
// Server Action에서 캐시 갱신
export async function updateProduct(productId: string, data: ProductData) {
'use server';
await db.product.update({
where: { id: productId },
data,
});
// 태그 기반 캐시 무효화 (프로필 지정 필수)
revalidateTag(`product-${productId}`, 'default');
// 🆕 Read-your-writes 시맨틱: 변경 즉시 반영
updateTag(`product-${productId}`);
}
로컬 개발 중 HMR 발생 시, 서버 컴포넌트 내의 fetch 응답을 캐싱합니다.
// 개발 모드에서 자동 적용
async function DataComponent() {
// 코드 수정으로 HMR이 발생해도
// 이 fetch는 캐시된 결과를 반환 (재호출 X)
const data = await fetch('https://api.example.com/expensive-data');
return <div>{data}</div>;
}
이점:
// next.config.ts
const nextConfig = {
experimental: {
turbopackFileSystemCacheForDev: true,
},
};
효과:
[클라이언트 사이드 렌더링 (CSR)] 크롤러 요청 → 빈 HTML → JS 로드 → 렌더링 → 콘텐츠 색인 (크롤러가 JS를 실행하지 않으면 색인 실패) [서버 사이드 렌더링 (SSR) - Next.js] 크롤러 요청 → 완성된 HTML → 즉시 콘텐츠 색인 ✅ (JS 실행 필요 없음)
설정 기반 (Config-based):
// 정적 메타데이터
export const metadata = {
title: 'My Blog',
description: 'A blog about web development',
openGraph: {
title: 'My Blog',
description: 'A blog about web development',
images: ['/og-image.png'],
},
};
// 동적 메타데이터 (⚠️ params 비동기 처리 필수)
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
파일 기반 (File-based):
app/
├── favicon.ico → <link rel="icon">
├── icon.png → <link rel="icon">
├── apple-icon.png → <link rel="apple-touch-icon">
├── opengraph-image.png → <meta property="og:image">
├── twitter-image.png → <meta name="twitter:image">
└── sitemap.ts → /sitemap.xml
| 지표 | 설명 | Next.js 16 목표 |
|---|---|---|
| FCP (First Contentful Paint) | 첫 콘텐츠 렌더링 | ~300ms |
| LCP (Largest Contentful Paint) | 최대 요소 렌더링 | ~1.2s |
| TTFB (Time to First Byte) | 첫 바이트 수신 | ~100ms |
PPR은 정적 구조와 동적 데이터를 한 페이지 내에서 결합하는 혁신적인 렌더링 전략입니다.
export default async function ProductPage({ params }: Props) {
const { id } = await params;
return (
<main>
{/* 정적 영역: 빌드 시 프리렌더링 */}
<Header />
<ProductImages productId={id} />
{/* 동적 영역: Suspense로 스트리밍 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<LiveReviews productId={id} />
</Suspense>
{/* 캐시된 영역 */}
<CachedRecommendations category={product.category} />
<Footer />
</main>
);
}
1. 즉시 (0ms) → 정적 Shell 표시 (Header, Footer) 2. 50ms → 캐시된 ProductImages 로드 3. 100-200ms → DynamicPrice 스트리밍 완료 4. 200-500ms → LiveReviews 스트리밍 완료
Next.js 16은 "명시성(Explicitness)"을 핵심 가치로 삼아, 개발자가 애플리케이션의 동작을 더 정확하게 예측하고 제어할 수 있도록 설계되었습니다.
await 적용"use cache" + cacheLife 조합으로 명시적 설정forbidden.tsx, unauthorized.tsx 도입"use cache"를 활용한 하이브리드 렌더링| 기능 | Next.js 14 | Next.js 15 | Next.js 16 |
|---|---|---|---|
| params 접근 | 동기 | 비동기 (도입) | 비동기 (필수) |
| 기본 캐싱 | 자동 | 선택적 | 명시적 |
| PPR | 실험적 | 안정화 진행 | 완전 통합 |
| 특수 파일 | 기본 | 기본 | 401/403 추가 |
| Layout 최적화 | 기본 | 개선 | Deduplication |
👍업데이트 된 Next.js 16 의 핵심 내용을 전반적으로 살펴 보았습니다.
Vecel 등에서 공유한 React, Nextjs 관련 Skills 을 엮어 Spec 을 정의하고 agent와 함께 더욱 예측 가능하고 유지보수 하기 쉬운 프로덕트를 개발하기 용이할 것 같습니다!!
Reference