13.5 배포 — Vercel/Cloudflare 배포, 환경 변수 관리, ISR 전략
Next.js 앱을 완성했다면 이제 세상에 공개할 차례입니다. **배포(Deployment)**는 코드를 실제 서버나 엣지 네트워크에 올려 사용자들이 접근할 수 있게 만드는 과정입니다. Next.js 15는 Vercel, Cloudflare Pages, AWS, Docker 등 다양한 플랫폼을 지원합니다.
배포 플랫폼 비교
| 플랫폼 | 특징 | 무료 한도 | 추천 상황 |
|---|---|---|---|
| Vercel | Next.js 최적화, 자동 배포 | 100GB 대역폭/월 | Next.js 프로젝트 기본 선택 |
| Cloudflare Pages | 글로벌 엣지, Workers 통합 | 무제한 요청 | 글로벌 서비스, 엣지 연산 |
| AWS Amplify | AWS 생태계 통합 | 1,000분 빌드/월 | AWS 인프라 사용 팀 |
| Railway | 간단한 컨테이너 배포 | $5 크레딧/월 | 풀스택 앱, DB 포함 |
| Self-hosted | 완전한 제어 | 서버 비용만 | 엔터프라이즈, 특수 요구사항 |
Vercel 배포
Vercel이란?
Vercel은 Next.js를 만든 회사가 운영하는 배포 플랫폼입니다. Next.js의 모든 기능(ISR, Edge Runtime, 서버 컴포넌트 등)을 제로 설정으로 지원합니다.
기본 배포 (GitHub 연동)
# 1. Vercel CLI 설치
npm install -g vercel
# 2. 로그인
vercel login
# 3. 프로젝트 루트에서 배포
vercel
# 4. 프로덕션 배포
vercel --prod
GitHub 저장소를 Vercel에 연결하면 main 브랜치에 push할 때마다 자동으로 배포됩니다.
vercel.json 설정
{
"version": 2,
"regions": ["icn1", "sin1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=31536000; includeSubDomains"
}
]
},
{
"source": "/api/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "no-store"
}
]
}
],
"rewrites": [
{
"source": "/old-path",
"destination": "/new-path"
}
]
}
Vercel 환경별 설정
# 개발 환경 변수 설정
vercel env add DATABASE_URL development
# 미리보기 환경
vercel env add DATABASE_URL preview
# 프로덕션 환경
vercel env add DATABASE_URL production
# 모든 환경 변수 확인
vercel env ls
Cloudflare Pages 배포
Cloudflare Pages + Next.js
Cloudflare Pages는 @cloudflare/next-on-pages 어댑터를 사용해 Next.js를 Workers 위에서 실행합니다.
# 어댑터 설치
npm install -D @cloudflare/next-on-pages
# wrangler 설치
npm install -D wrangler
next.config.ts 설정
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// Cloudflare Pages용 Edge Runtime 설정
experimental: {
// 필요시 활성화
},
};
export default nextConfig;
wrangler.toml 설정
# wrangler.toml
name = "my-next-app"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
[vars]
NODE_ENV = "production"
package.json 스크립트
{
"scripts": {
"dev": "next dev",
"build": "next build",
"build:cf": "npx @cloudflare/next-on-pages",
"preview": "npm run build:cf && wrangler pages dev",
"deploy": "npm run build:cf && wrangler pages deploy"
}
}
Cloudflare Pages 배포 명령
# 빌드 및 배포
npm run deploy
# 또는 개별 단계
npx @cloudflare/next-on-pages
wrangler pages deploy .vercel/output/static --project-name=my-next-app
환경 변수 관리
.env 파일 계층 구조
.env # 모든 환경 기본값 (git 커밋 가능)
.env.local # 로컬 전용 오버라이드 (git 무시)
.env.development # 개발 환경 전용
.env.production # 프로덕션 환경 전용
.env.test # 테스트 환경 전용
우선순위: .env.local > .env.{NODE_ENV} > .env
클라이언트/서버 환경 변수 구분
# .env.local
# 서버 전용 (클라이언트에 노출 안 됨)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=super-secret-key-never-expose
STRIPE_SECRET_KEY=sk_live_xxxx
SENDGRID_API_KEY=SG.xxxx
# 클라이언트에도 노출 (NEXT_PUBLIC_ 접두사 필수)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxx
환경 변수 사용 예시
// app/lib/db.ts — 서버에서만 실행
import { Pool } from 'pg';
// 서버 컴포넌트, API Route, Server Action에서 사용 가능
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
export default pool;
// app/components/Analytics.tsx — 클라이언트 컴포넌트
'use client';
import { useEffect } from 'react';
export function Analytics() {
useEffect(() => {
// NEXT_PUBLIC_ 변수만 클라이언트에서 접근 가능
const gaId = process.env.NEXT_PUBLIC_GA_ID;
if (gaId) {
// Google Analytics 초기화
gtag('config', gaId);
}
}, []);
return null;
}
환경 변수 유효성 검증
// app/lib/env.ts
import { z } from 'zod';
// 서버 환경 변수 스키마
const serverEnvSchema = z.object({
DATABASE_URL: z.string().url('유효한 DB URL이 필요합니다'),
JWT_SECRET: z.string().min(32, 'JWT 시크릿은 최소 32자 이상이어야 합니다'),
NODE_ENV: z.enum(['development', 'test', 'production']),
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
});
// 클라이언트 환경 변수 스키마
const clientEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
});
// 서버 사이드에서만 실행
function validateServerEnv() {
const parsed = serverEnvSchema.safeParse({
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
NODE_ENV: process.env.NODE_ENV,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
});
if (!parsed.success) {
console.error('❌ 환경 변수 유효성 검사 실패:');
console.error(parsed.error.format());
throw new Error('환경 변수 설정을 확인하세요.');
}
return parsed.data;
}
export const env = validateServerEnv();
ISR (Incremental Static Regeneration) 전략
ISR이란?
ISR은 정적 페이지를 배포 후에도 주기적으로 업데이트하는 Next.js의 핵심 기능입니다. SSG(빠른 응답)와 SSR(최신 데이터)의 장점을 결합합니다.
요청 1 (캐시 없음) → 서버에서 렌더링 → 캐시에 저장
요청 2~N (캐시 유효) → 캐시에서 즉시 응답
revalidate 시간 초과 후 첫 요청 → 캐시 반환 + 백그라운드 재생성
다음 요청 → 새 페이지 응답
시간 기반 ISR
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
// 60초마다 재검증
export const revalidate = 60;
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 },
});
if (!res.ok) throw new Error('포스트를 불러올 수 없습니다');
return res.json();
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p className="text-gray-500">
마지막 업데이트: {new Date(post.updatedAt).toLocaleDateString('ko-KR')}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
On-Demand ISR (웹훅 기반 재검증)
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
// 보안: 시크릿 토큰 검증
const token = request.headers.get('x-revalidate-token');
if (token !== process.env.REVALIDATE_TOKEN) {
return NextResponse.json({ error: '권한 없음' }, { status: 401 });
}
const body = await request.json();
const { type, slug, tag } = body;
try {
if (type === 'path' && slug) {
// 특정 경로 재검증
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog'); // 목록 페이지도 재검증
return NextResponse.json({
revalidated: true,
message: `/blog/${slug} 재검증 완료`,
});
}
if (type === 'tag' && tag) {
// 태그 기반 재검증 (더 세밀한 제어)
revalidateTag(tag);
return NextResponse.json({
revalidated: true,
message: `태그 '${tag}' 재검증 완료`,
});
}
return NextResponse.json({ error: '유효하지 않은 요청' }, { status: 400 });
} catch (error) {
return NextResponse.json(
{ error: '재검증 중 오류 발생' },
{ status: 500 }
);
}
}
fetch 태그 기반 캐시 관리
// app/lib/api.ts
import { unstable_cache } from 'next/cache';
// 제품 목록 — 'products' 태그로 캐시
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
tags: ['products'],
revalidate: 3600, // 1시간
},
});
return res.json();
}
// 특정 제품 — 'products', 'product-{id}' 태그로 캐시
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
tags: ['products', `product-${id}`],
revalidate: 300, // 5분
},
});
return res.json();
}
// unstable_cache로 DB 쿼리 캐시
export const getCachedUser = unstable_cache(
async (userId: string) => {
// DB 직접 쿼리도 캐시 가능
const user = await db.user.findUnique({ where: { id: userId } });
return user;
},
['user'], // 캐시 키
{
tags: ['users'],
revalidate: 60,
}
);
실전 예제: 블로그 플랫폼 전체 배포 파이프라인
프로젝트 구조
my-blog/
├── app/
│ ├── blog/
│ │ ├── page.tsx # 블로그 목록 (ISR 60s)
│ │ └── [slug]/
│ │ └── page.tsx # 개별 포스트 (ISR 300s)
│ ├── api/
│ │ └── revalidate/
│ │ └── route.ts # 웹훅 재검증 엔드포인트
│ └── layout.tsx
├── .env.local
├── .env.production
├── vercel.json
└── next.config.ts
완전한 블로그 목록 페이지
// app/blog/page.tsx
import { Suspense } from 'react';
import Link from 'next/link';
export const revalidate = 60;
interface Post {
id: string;
slug: string;
title: string;
excerpt: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
}
async function getPosts(): Promise<Post[]> {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, {
next: { tags: ['posts'], revalidate: 60 },
});
if (!res.ok) {
throw new Error('포스트 목록을 불러올 수 없습니다');
}
return res.json();
}
function PostCard({ post }: { post: Post }) {
return (
<article className="border rounded-lg p-6 hover:shadow-md transition-shadow">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-bold mb-2 hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{post.author.name}</span>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('ko-KR')}
</time>
</div>
<div className="flex gap-2 mt-3">
{post.tags.map(tag => (
<span key={tag} className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
{tag}
</span>
))}
</div>
</article>
);
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">블로그</h1>
<div className="grid gap-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}
CI/CD 파이프라인 (GitHub Actions)
# .github/workflows/deploy.yml
name: Deploy to Vercel
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Node.js 설정
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: 의존성 설치
run: npm ci
- name: 타입 검사
run: npm run type-check
- name: 린트
run: npm run lint
- name: 단위 테스트
run: npm test
deploy-preview:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Vercel 미리보기 배포
run: |
npm install -g vercel
vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
vercel build --token=${{ secrets.VERCEL_TOKEN }}
vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}
deploy-production:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Vercel 프로덕션 배포
run: |
npm install -g vercel
vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
성능 모니터링 및 분석
next.config.ts 성능 설정
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// 번들 분석
...(process.env.ANALYZE === 'true' && {
// @next/bundle-analyzer 사용 시
}),
// 이미지 최적화
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**',
},
],
minimumCacheTTL: 86400, // 24시간
},
// 압축
compress: true,
// 전원 사용 최적화
poweredByHeader: false,
// 보안 헤더
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
];
},
// 리다이렉트
async redirects() {
return [
{
source: '/old-blog/:slug',
destination: '/blog/:slug',
permanent: true,
},
];
},
};
export default nextConfig;
고수 팁
팁 1: 배포 전 체크리스트 자동화
// scripts/pre-deploy-check.ts
import { execSync } from 'child_process';
const checks = [
{ name: '타입 검사', cmd: 'npx tsc --noEmit' },
{ name: '린트', cmd: 'npx eslint . --ext .ts,.tsx' },
{ name: '빌드', cmd: 'npm run build' },
{ name: '환경 변수', cmd: 'node -e "require(\'./app/lib/env\')"' },
];
async function runChecks() {
console.log('🚀 배포 전 검사 시작...\n');
for (const check of checks) {
process.stdout.write(` ${check.name}... `);
try {
execSync(check.cmd, { stdio: 'pipe' });
console.log('✅');
} catch (error) {
console.log('❌');
console.error(`\n${check.name} 실패:`);
process.exit(1);
}
}
console.log('\n✅ 모든 검사 통과! 배포 진행 가능합니다.');
}
runChecks();
팁 2: ISR 캐시 워밍 전략
// scripts/warm-cache.ts
// 배포 후 주요 페이지 사전 캐시 로드
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL!;
const criticalPaths = [
'/',
'/blog',
'/products',
'/about',
];
async function warmCache() {
console.log('캐시 워밍 시작...');
await Promise.allSettled(
criticalPaths.map(async (path) => {
const url = `${BASE_URL}${path}`;
const res = await fetch(url);
console.log(`${res.ok ? '✅' : '❌'} ${url} (${res.status})`);
})
);
console.log('캐시 워밍 완료!');
}
warmCache();
팁 3: 멀티 리전 배포 설정
{
"regions": ["icn1", "sin1", "sfo1", "fra1"],
"functions": {
"app/api/**": {
"maxDuration": 30,
"memory": 1024
}
}
}
팁 4: 환경별 기능 플래그
// app/lib/feature-flags.ts
type Environment = 'development' | 'preview' | 'production';
const flags: Record<string, Record<Environment, boolean>> = {
newDashboard: {
development: true,
preview: true,
production: false, // 아직 프로덕션 미적용
},
betaSearch: {
development: true,
preview: false,
production: false,
},
};
export function isFeatureEnabled(feature: string): boolean {
const env = (process.env.VERCEL_ENV || 'development') as Environment;
return flags[feature]?.[env] ?? false;
}
// 사용 예시
// if (isFeatureEnabled('newDashboard')) { ... }