본문으로 건너뛰기
Advertisement

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')) { ... }
Advertisement