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 설정