본문으로 건너뛰기

13.5 배포 — Vercel/Cloudflare 배포, 환경 변수 관리, ISR 전략

Next.js 앱을 완성했다면 이제 세상에 공개할 차례입니다. 배포(Deployment) 는 코드를 실제 서버나 엣지 네트워크에 올려 사용자들이 접근할 수 있게 만드는 과정입니다. Next.js 15는 Vercel, Cloudflare Pages, AWS, Docker 등 다양한 플랫폼을 지원합니다.


배포 플랫폼 비교

플랫폼특징무료 한도추천 상황
VercelNext.js 최적화, 자동 배포100GB 대역폭/월Next.js 프로젝트 기본 선택
Cloudflare Pages글로벌 엣지, Workers 통합무제한 요청글로벌 서비스, 엣지 연산
AWS AmplifyAWS 생태계 통합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')) { ... }