13.6 실전 고수 팁 — Edge Runtime, 스트리밍 SSR, Turbopack, 모노레포 구성
Next.js 15의 고급 기능들은 단순히 빠른 웹사이트를 넘어, 수백만 사용자를 처리하는 프로덕션 시스템 을 구축할 수 있게 해줍니다. 이 장에서는 현업 엔지니어들이 실제로 사용하는 패턴과 최적화 기법을 다룹니다.
Edge Runtime
Edge Runtime이란?
Edge Runtime 은 Cloudflare Workers, Vercel Edge Functions처럼 사용자와 가장 가까운 위치(엣지)에서 코드를 실행하는 환경입니다. 기존 Node.js 서버가 특정 리전(예: 서울)에 있다면, 엣지 함수는 전 세계 수백 개 PoP(Point of Presence)에서 실행됩니다.
| 구분 | Node.js Runtime | Edge Runtime |
|---|---|---|
| 시작 시간 | 수백ms (콜드 스타트) | ~0ms |
| 지연 시간 | 원거리 높음 | 전 세계 균일 낮음 |
| API | 모든 Node.js API | Web API 부분집합 |
| 메모리 | 제한 없음 | 128MB 제한 |
| 파일 시스템 | 접근 가능 | 불가능 |
Edge Runtime 활성화
// app/api/geo/route.ts
export const runtime = 'edge'; // Edge Runtime 선언
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
// Edge에서 지오 정보 접근 (Vercel 전용)
const country = request.geo?.country ?? 'KR';
const city = request.geo?.city ?? 'Seoul';
const ip = request.ip ?? '0.0.0.0';
// 국가별 다른 응답
const content = getLocalizedContent(country);
return Response.json({
country,
city,
ip,
content,
message: `${city}에서 접속하셨군요!`,
});
}
function getLocalizedContent(country: string) {
const contentMap: Record<string, string> = {
KR: '안녕하세요! 한국 사용자 전용 콘텐츠입니다.',
US: 'Hello! This is US-specific content.',
JP: 'こんにちは!日本のユーザー向けコンテンツです。',
};
return contentMap[country] ?? 'Hello from the edge!';
}
Edge Middleware로 A/B 테스트
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export const config = {
matcher: ['/landing/:path*'],
};
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
// 쿠키 기반 버킷 배정 (일관된 사용자 경험)
let bucket = request.cookies.get('ab-bucket')?.value;
if (!bucket) {
// 새 사용자: 50/50 배정
bucket = Math.random() < 0.5 ? 'a' : 'b';
}
// A 버킷: 기존 랜딩 페이지
// B 버킷: 새 랜딩 페이지로 rewrite
if (bucket === 'b') {
url.pathname = url.pathname.replace('/landing', '/landing-v2');
}
const response = NextResponse.rewrite(url);
// 쿠키 저장 (30일)
response.cookies.set('ab-bucket', bucket, {
maxAge: 60 * 60 * 24 * 30,
sameSite: 'lax',
});
// 분석을 위한 헤더 추가
response.headers.set('x-ab-bucket', bucket);
return response;
}
Edge에서 KV 스토어 사용 (Cloudflare)
// app/api/cache/route.ts
export const runtime = 'edge';
import { NextRequest } from 'next/server';
// Cloudflare KV 타입 (wrangler.toml에서 바인딩)
declare const CACHE: KVNamespace;
export async function GET(request: NextRequest) {
const key = request.nextUrl.searchParams.get('key');
if (!key) {
return Response.json({ error: '키가 필요합니다' }, { status: 400 });
}
// KV에서 캐시 조회
const cached = await CACHE.get(key, 'json');
if (cached) {
return Response.json({ data: cached, source: 'cache' });
}
// 캐시 미스: 원본 데이터 조회
const data = await fetchFromOrigin(key);
// KV에 저장 (TTL: 1시간)
await CACHE.put(key, JSON.stringify(data), { expirationTtl: 3600 });
return Response.json({ data, source: 'origin' });
}
async function fetchFromOrigin(key: string) {
// 실제 데이터 소스에서 조회
return { key, value: `data-for-${key}`, timestamp: Date.now() };
}
스트리밍 SSR
스트리밍이란?
전통적인 SSR은 모든 데이터 가 준비될 때까지 기다린 후 HTML을 한 번에 전송합니다. 스트리밍 SSR은 준비된 부분부터 점진적으로 HTML을 전송 하여 사용자가 더 빨리 콘텐츠를 볼 수 있게 합니다.
전통 SSR: [데이터 로딩 3초] → [HTML 전체 전송] → [화면 표시]
스트리밍: [즉시 껍데기 전송] → [데이터 준비되면 조각조각 전송] → [순차적 화면 표시]
Suspense로 스트리밍 구현
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserProfile, UserProfileSkeleton } from './UserProfile';
import { RecentOrders, OrdersSkeleton } from './RecentOrders';
import { Analytics, AnalyticsSkeleton } from './Analytics';
import { Recommendations, RecommendationsSkeleton } from './Recommendations';
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-4 p-6">
{/* 즉시 표시 — 정적 콘텐츠 */}
<header className="col-span-12">
<h1 className="text-2xl font-bold">대시보드</h1>
</header>
{/* 빠른 쿼리 — 먼저 로드 */}
<div className="col-span-4">
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
</div>
{/* 중간 속도 쿼리 */}
<div className="col-span-8">
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
{/* 느린 쿼리 — 마지막에 로드 */}
<div className="col-span-12">
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</div>
{/* AI 추천 — 가장 느림 */}
<div className="col-span-12">
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
</div>
);
}
스켈레톤 UI 구현
// app/dashboard/UserProfile.tsx
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import Image from 'next/image';
// 실제 컴포넌트 (느린 DB 쿼리)
export async function UserProfile() {
const session = await auth();
const user = await db.user.findUnique({
where: { id: session!.userId },
include: { _count: { select: { orders: true } } },
});
return (
<div className="bg-white rounded-xl p-6 shadow-sm">
<Image
src={user!.avatar}
alt={user!.name}
width={64}
height={64}
className="rounded-full"
/>
<h2 className="mt-3 text-lg font-semibold">{user!.name}</h2>
<p className="text-gray-500">{user!.email}</p>
<div className="mt-4 pt-4 border-t">
<span className="text-sm text-gray-600">
총 주문 수: <strong>{user!._count.orders}건</strong>
</span>
</div>
</div>
);
}
// 스켈레톤 (즉시 표시)
export function UserProfileSkeleton() {
return (
<div className="bg-white rounded-xl p-6 shadow-sm animate-pulse">
<div className="w-16 h-16 bg-gray-200 rounded-full" />
<div className="mt-3 h-5 bg-gray-200 rounded w-32" />
<div className="mt-2 h-4 bg-gray-200 rounded w-48" />
<div className="mt-4 pt-4 border-t">
<div className="h-4 bg-gray-200 rounded w-24" />
</div>
</div>
);
}
스트리밍 API Route
// app/api/stream/route.ts
import { OpenAI } from 'openai';
const openai = new OpenAI();
export async function POST(request: Request) {
const { prompt } = await request.json();
// OpenAI 스트림 생성
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
// ReadableStream으로 변환하여 클라이언트에 스트리밍
const readable = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
if (text) {
controller.enqueue(encoder.encode(text));
}
}
controller.close();
},
});
return new Response(readable, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}
// app/components/StreamingChat.tsx
'use client';
import { useState } from 'react';
export function StreamingChat() {
const [response, setResponse] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const prompt = formData.get('prompt') as string;
setLoading(true);
setResponse('');
try {
const res = await fetch('/api/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
setResponse(prev => prev + decoder.decode(value));
}
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto p-6">
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
name="prompt"
placeholder="질문을 입력하세요..."
className="flex-1 border rounded-lg px-4 py-2"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"
>
{loading ? '생성 중...' : '전송'}
</button>
</form>
{response && (
<div className="bg-gray-50 rounded-lg p-4 whitespace-pre-wrap">
{response}
{loading && <span className="animate-pulse">▌</span>}
</div>
)}
</div>
);
}
Turbopack
Turbopack이란?
Turbopack 은 Webpack을 대체하는 Rust 기반 번들러입니다. Next.js 15에서 개발 서버(next dev)에 기본 적용되며, 대규모 프로젝트에서 극적인 빌드 속도 향상을 제공합니다.
| 비교 | Webpack | Turbopack |
|---|---|---|
| 언어 | JavaScript | Rust |
| 초기 빌드 | 기준 | 최대 10배 빠름 |
| HMR | 기준 | 최대 700배 빠름 |
| 증분 빌드 | 기준 | 최대 10배 빠름 |
Turbopack 활성화
# Next.js 15에서 기본 활성화 (개발 서버)
next dev --turbopack
# package.json
{
"scripts": {
"dev": "next dev --turbopack"
}
}
next.config.ts Turbopack 설정
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
turbo: {
// 커스텀 모듈 별칭
resolveAlias: {
'@components': './app/components',
'@lib': './app/lib',
'@hooks': './app/hooks',
},
// 파일 확장자 순서
resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
// 웹팩 로더 대체
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
'*.md': {
loaders: ['raw-loader'],
as: '*.js',
},
},
},
},
};
export default nextConfig;
Turbopack 성능 측정
# 빌드 시간 측정
time npm run dev
# 번들 크기 분석 (프로덕션 빌드)
ANALYZE=true npm run build
# 상세 빌드 통계
next build --profile
모노레포 구성
모노레포란?
모노레포(Monorepo) 는 여러 프로젝트(앱, 라이브러리, 패키지)를 단일 Git 저장소에서 관리하는 구조입니다. 코드 공유, 일관된 버전 관리, 통합 테스트가 쉬워집니다.
my-monorepo/
├── apps/
│ ├── web/ # 메인 Next.js 앱
│ ├── admin/ # 관리자 Next.js 앱
│ └── mobile/ # React Native 앱
├── packages/
│ ├── ui/ # 공유 UI 컴포넌트
│ ├── config/ # 공유 설정 (ESLint, TypeScript)
│ └── utils/ # 공유 유틸리티 함수
└── package.json # 루트 package.json (워크스페이스 설정)
Turborepo 설정
# 새 모노레포 생성
npx create-turbo@latest
# 기존 프로젝트에 추가
npm install turbo -D -W
// package.json (루트)
{
"name": "my-monorepo",
"private": true,
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"test": "turbo test",
"type-check": "turbo type-check"
},
"devDependencies": {
"turbo": "^2.0.0"
}
}
// turbo.json
{
"$schema": "https://turborepo.org/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
공유 UI 패키지 구성
// packages/ui/src/Button.tsx
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from './utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', loading, className, children, disabled, ...props }, ref) => {
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
ghost: 'text-gray-600 hover:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700',
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
variants[variant],
sizes[size],
className
)}
{...props}
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
로딩 중...
</span>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
// packages/ui/package.json
{
"name": "@myapp/ui",
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
공유 패키지를 Next.js 앱에서 사용
// apps/web/package.json
{
"name": "@myapp/web",
"dependencies": {
"@myapp/ui": "*",
"@myapp/utils": "*",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
// apps/web/next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 모노레포 패키지 트랜스파일
transpilePackages: ['@myapp/ui', '@myapp/utils'],
};
export default nextConfig;
// apps/web/app/page.tsx
import { Button } from '@myapp/ui';
import { formatDate } from '@myapp/utils';
export default function HomePage() {
return (
<main>
<h1>홈페이지</h1>
<p>오늘: {formatDate(new Date())}</p>
<Button variant="primary" size="lg">
시작하기
</Button>
</main>
);
}
고급 최적화 패턴
1. Partial Prerendering (PPR)
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // 점진적 PPR 적용
},
};
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { unstable_noStore as noStore } from 'next/cache';
// 정적 껍데기 — 즉시 제공
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 정적 부분: CDN에서 즉시 제공 */}
<ProductLayout>
{/* 동적 부분: 스트리밍으로 채워짐 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} />
</Suspense>
<Suspense fallback={<StockSkeleton />}>
<DynamicStock productId={params.id} />
</Suspense>
</ProductLayout>
</div>
);
}
// 동적 컴포넌트 — 매 요청마다 실시간 데이터
async function DynamicPrice({ productId }: { productId: string }) {
noStore(); // 이 컴포넌트는 캐시하지 않음
const price = await fetchRealTimePrice(productId);
return <span className="text-2xl font-bold">₩{price.toLocaleString()}</span>;
}
2. Server Actions 최적화
// app/actions/cart.ts
'use server';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
export async function addToCart(productId: string, quantity: number) {
const session = await auth();
if (!session) throw new Error('로그인이 필요합니다');
// 낙관적 업데이트를 위한 즉시 응답
await db.cartItem.upsert({
where: {
userId_productId: {
userId: session.userId,
productId,
},
},
update: { quantity: { increment: quantity } },
create: { userId: session.userId, productId, quantity },
});
// 관련 경로 재검증
revalidatePath('/cart');
revalidatePath('/products');
return { success: true };
}
// app/components/AddToCartButton.tsx
'use client';
import { useOptimistic, useTransition } from 'react';
import { addToCart } from '@/app/actions/cart';
interface CartButtonProps {
productId: string;
initialCount: number;
}
export function AddToCartButton({ productId, initialCount }: CartButtonProps) {
const [optimisticCount, addOptimistic] = useOptimistic(
initialCount,
(state, amount: number) => state + amount
);
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(async () => {
// 낙관적 업데이트: UI 즉시 반영
addOptimistic(1);
// 실제 서버 요청
await addToCart(productId, 1);
});
}
return (
<button
onClick={handleClick}
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-lg"
>
장바구니에 추가 ({optimisticCount})
</button>
);
}
3. 번들 크기 최적화
// 동적 임포트로 코드 분할
import dynamic from 'next/dynamic';
// 무거운 에디터 — 클라이언트에서만, 필요할 때 로드
const RichTextEditor = dynamic(
() => import('@/components/RichTextEditor'),
{
ssr: false, // 서버 렌더링 불필요
loading: () => <div className="h-64 bg-gray-100 rounded animate-pulse" />,
}
);
// 차트 라이브러리 — 뷰포트 진입 시 로드
const AnalyticsChart = dynamic(
() => import('@/components/AnalyticsChart'),
{ ssr: false }
);
// 날짜 라이브러리 — 서버에서만 사용
async function formatDates(dates: Date[]) {
const { format } = await import('date-fns/format');
const { ko } = await import('date-fns/locale/ko');
return dates.map(d => format(d, 'PPP', { locale: ko }));
}
고수 팁 종합
팁 1: 요청 중복 제거 (Request Deduplication)
// app/lib/api.ts
// React의 fetch 자동 중복 제거 활용
// 같은 요청을 여러 컴포넌트에서 호출해도 실제 요청은 1번만 발생
async function getUser(id: string) {
// Next.js가 동일한 URL+옵션의 fetch를 자동으로 메모이제이션
return fetch(`/api/users/${id}`, { cache: 'no-store' }).then(r => r.json());
}
// UserCard와 UserStats가 모두 getUser를 호출해도 네트워크 요청은 1번
async function UserCard({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>{user.name}</div>;
}
async function UserStats({ userId }: { userId: string }) {
const user = await getUser(userId);
return <div>게시글 수: {user.postCount}</div>;
}
팁 2: 병렬 데이터 페칭
// app/dashboard/page.tsx
// 잘못된 예 — 순차적 (느림)
async function BadDashboard() {
const user = await getUser(); // 100ms
const orders = await getOrders(); // 200ms
const analytics = await getAnalytics(); // 300ms
// 총 600ms 소요
}
// 올바른 예 — 병렬 (빠름)
async function GoodDashboard() {
// Promise.all로 동시 실행
const [user, orders, analytics] = await Promise.all([
getUser(), // 100ms
getOrders(), // 200ms
getAnalytics(), // 300ms
]);
// 총 300ms 소요 (가장 느린 것 기준)
return (
<div>
<UserSection user={user} />
<OrdersSection orders={orders} />
<AnalyticsSection analytics={analytics} />
</div>
);
}
팁 3: 에러 경계와 폴백 전략
// app/dashboard/error.tsx
'use client';
import { useEffect } from 'react';
import { Button } from '@myapp/ui';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function DashboardError({ error, reset }: ErrorProps) {
useEffect(() => {
// 에러 모니터링 서비스에 보고
console.error('Dashboard error:', error);
// Sentry.captureException(error);
}, [error]);
return (
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
<h2 className="text-xl font-semibold text-red-600">오류가 발생했습니다</h2>
<p className="text-gray-600">{error.message}</p>
{error.digest && (
<code className="text-xs text-gray-400">오류 코드: {error.digest}</code>
)}
<Button onClick={reset} variant="primary">
다시 시도
</Button>
</div>
);
}
팁 4: 타입 안전한 라우팅
// app/lib/routes.ts
// 타입 안전한 경로 생성기
export const routes = {
home: () => '/',
blog: {
list: () => '/blog',
post: (slug: string) => `/blog/${slug}`,
tag: (tag: string) => `/blog/tag/${tag}`,
},
product: {
list: () => '/products',
detail: (id: string) => `/products/${id}`,
edit: (id: string) => `/products/${id}/edit`,
},
api: {
users: {
list: () => '/api/users',
byId: (id: string) => `/api/users/${id}`,
},
},
} as const;
// 사용 예시
// <Link href={routes.blog.post('hello-world')}>블로그 포스트</Link>
// fetch(routes.api.users.byId('123'))
팁 5: 성능 예산 설정
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
// 성능 예산 초과 시 빌드 경고
bundlePagesRouterDependencies: true,
},
// 번들 크기 제한 (바이트)
// 초과 시 경고 출력
onDemandEntries: {
maxInactiveAge: 25 * 1000,
pagesBufferLength: 2,
},
};
// 빌드 후 분석 스크립트
// package.json: "analyze": "ANALYZE=true next build"
실전 체크리스트: 프로덕션 준비 완료
## 성능
- [ ] Lighthouse 점수 90+ (Performance, Accessibility, SEO)
- [ ] Core Web Vitals: LCP < 2.5s, FID < 100ms, CLS < 0.1
- [ ] 이미지: next/image 사용, WebP/AVIF 형식
- [ ] 폰트: next/font 사용, layout shift 방지
- [ ] 번들 크기: 초기 JS < 100KB (gzip)
## 보안
- [ ] 보안 헤더 설정 (CSP, HSTS, X-Frame-Options)
- [ ] 환경 변수 검증 (zod)
- [ ] API Rate limiting 구현
- [ ] CSRF 보호 (Server Actions 기본 제공)
- [ ] 입력값 검증 (서버/클라이언트 모두)
## 안정성
- [ ] 에러 경계 (error.tsx) 모든 페이지
- [ ] 로딩 상태 (loading.tsx, Suspense)
- [ ] 404/500 페이지 커스텀
- [ ] 모니터링 (Sentry, Vercel Analytics)
## SEO
- [ ] 메타데이터 API 활용 (title, description, OG)
- [ ] robots.txt, sitemap.xml 생성
- [ ] 구조화 데이터 (JSON-LD)
- [ ] 캐노니컬 URL 설정