Next.js 13+의 App Router와 함께 도입된 Route Handlers는 API 엔드포인트를 구축하는 새로운 방식을 제공합니다. 하지만 서로 다른 도메인에서 API를 호출할 때 발생하는 CORS 이슈는 여전히 개발자들이 직면하는 주요 과제입니다.
이 글에서는 Route Handlers에서 CORS를 효과적으로 처리하는 방법을 기초부터 고급 기법까지 상세히 다뤄보겠습니다.
CORS 기본 개념과 Route Handlers
CORS가 필요한 이유
CORS(Cross-Origin Resource Sharing)는 웹 브라우저의 보안 정책인 동일 출처 정책(Same-Origin Policy)을 완화하여, 다른 도메인의 리소스에 접근할 수 있게 해주는 메커니즘입니다.
// 동일 출처 정책에 의해 차단되는 상황들
// Frontend: https://myapp.com
// API: https://api.myapp.com ❌ (다른 서브도메인)
// API: https://myapp.com:3001 ❌ (다른 포트)
// API: http://myapp.com ❌ (다른 프로토콜)
// CORS 없이는 이런 에러가 발생합니다:
// "Access to fetch at 'https://api.example.com' from origin 'https://myapp.com'
// has been blocked by CORS policy"
Route Handlers의 CORS 처리 방식
Next.js 13+ Route Handlers에서는 페이지 기반 API Routes와 달리 Response 객체를 직접 반환하므로, CORS 헤더를 더 세밀하게 제어할 수 있습니다.
// app/api/example/route.ts
export async function GET(request: Request) {
// Route Handlers에서는 Response 객체를 직접 생성
return new Response(JSON.stringify({ message: 'Hello World' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
// CORS 헤더를 직접 추가
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
},
});
}
기본 CORS 설정 구현
단순한 CORS 헬퍼 함수
// lib/cors.ts
export interface CorsOptions {
origin?: string | string[] | boolean;
methods?: string[];
allowedHeaders?: string[];
exposedHeaders?: string[];
credentials?: boolean;
maxAge?: number;
preflightContinue?: boolean;
optionsSuccessStatus?: number;
}
export function createCorsHeaders(options: CorsOptions = {}): Headers {
const headers = new Headers();
// Access-Control-Allow-Origin 설정
if (options.origin === true) {
headers.set('Access-Control-Allow-Origin', '*');
} else if (typeof options.origin === 'string') {
headers.set('Access-Control-Allow-Origin', options.origin);
} else if (Array.isArray(options.origin)) {
// 다중 도메인은 요청에 따라 동적으로 처리해야 함
headers.set('Vary', 'Origin');
}
// Access-Control-Allow-Methods
const methods = options.methods || ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'];
headers.set('Access-Control-Allow-Methods', methods.join(', '));
// Access-Control-Allow-Headers
const allowedHeaders = options.allowedHeaders || [
'Content-Type',
'Authorization',
'X-Requested-With',
];
headers.set('Access-Control-Allow-Headers', allowedHeaders.join(', '));
// Access-Control-Expose-Headers
if (options.exposedHeaders && options.exposedHeaders.length > 0) {
headers.set('Access-Control-Expose-Headers', options.exposedHeaders.join(', '));
}
// Access-Control-Allow-Credentials
if (options.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
// Access-Control-Max-Age
if (options.maxAge !== undefined) {
headers.set('Access-Control-Max-Age', options.maxAge.toString());
}
return headers;
}
export function corsResponse(
data: any,
options: CorsOptions & { status?: number } = {}
): Response {
const { status = 200, ...corsOptions } = options;
const headers = createCorsHeaders(corsOptions);
// JSON 응답인 경우
if (typeof data === 'object') {
headers.set('Content-Type', 'application/json');
return new Response(JSON.stringify(data), { status, headers });
}
// 텍스트 응답인 경우
return new Response(data, { status, headers });
}
export function corsErrorResponse(
message: string,
status: number = 400,
corsOptions: CorsOptions = {}
): Response {
const headers = createCorsHeaders(corsOptions);
headers.set('Content-Type', 'application/json');
return new Response(
JSON.stringify({ error: message }),
{ status, headers }
);
}
기본 Route Handler 구현
// app/api/users/route.ts
import { corsResponse, corsErrorResponse } from '@/lib/cors';
import { NextRequest } from 'next/server';
const corsOptions = {
origin: ['http://localhost:3000', 'https://myapp.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // 24시간
};
export async function GET(request: NextRequest) {
try {
// 비즈니스 로직
const users = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];
return corsResponse(users, corsOptions);
} catch (error) {
console.error('GET /api/users error:', error);
return corsErrorResponse('Failed to fetch users', 500, corsOptions);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// 입력 검증
if (!body.name || !body.email) {
return corsErrorResponse('Name and email are required', 400, corsOptions);
}
// 비즈니스 로직 - 사용자 생성
const newUser = {
id: Date.now(),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
};
return corsResponse(newUser, { ...corsOptions, status: 201 });
} catch (error) {
console.error('POST /api/users error:', error);
return corsErrorResponse('Failed to create user', 500, corsOptions);
}
}
// OPTIONS 요청 처리 (Preflight)
export async function OPTIONS(request: NextRequest) {
return new Response(null, {
status: 200,
headers: createCorsHeaders(corsOptions),
});
}
고급 CORS 미들웨어 구현
동적 Origin 검증
// lib/advanced-cors.ts
import { NextRequest } from 'next/server';
export interface AdvancedCorsOptions extends CorsOptions {
originValidator?: (origin: string, request: NextRequest) => boolean | Promise<boolean>;
dynamicOrigin?: boolean;
allowedDomains?: string[];
blockedDomains?: string[];
developmentMode?: boolean;
}
export class CorsManager {
private options: AdvancedCorsOptions;
constructor(options: AdvancedCorsOptions = {}) {
this.options = {
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: false,
maxAge: 86400,
developmentMode: process.env.NODE_ENV === 'development',
...options,
};
}
async isOriginAllowed(origin: string, request: NextRequest): Promise<boolean> {
// 개발 모드에서는 localhost 허용
if (this.options.developmentMode && this.isLocalhostOrigin(origin)) {
return true;
}
// 커스텀 validator가 있는 경우
if (this.options.originValidator) {
return await this.options.originValidator(origin, request);
}
// 블랙리스트 도메인 확인
if (this.options.blockedDomains?.some(domain => origin.includes(domain))) {
return false;
}
// 화이트리스트 도메인 확인
if (this.options.allowedDomains) {
return this.options.allowedDomains.some(domain => {
if (domain.startsWith('*.')) {
// 와일드카드 서브도메인 지원
const baseDomain = domain.slice(2);
return origin.endsWith(`.${baseDomain}`) || origin === baseDomain;
}
return origin === domain;
});
}
// origin 설정에 따른 처리
if (this.options.origin === true) {
return true;
} else if (typeof this.options.origin === 'string') {
return origin === this.options.origin;
} else if (Array.isArray(this.options.origin)) {
return this.options.origin.includes(origin);
}
return false;
}
private isLocalhostOrigin(origin: string): boolean {
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
}
async createCorsHeaders(request: NextRequest): Promise<Headers> {
const headers = new Headers();
const origin = request.headers.get('origin');
if (origin) {
const isAllowed = await this.isOriginAllowed(origin, request);
if (isAllowed) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Vary', 'Origin');
}
} else if (this.options.origin === true) {
headers.set('Access-Control-Allow-Origin', '*');
}
// 나머지 헤더들 설정
if (this.options.methods) {
headers.set('Access-Control-Allow-Methods', this.options.methods.join(', '));
}
if (this.options.allowedHeaders) {
headers.set('Access-Control-Allow-Headers', this.options.allowedHeaders.join(', '));
}
if (this.options.exposedHeaders && this.options.exposedHeaders.length > 0) {
headers.set('Access-Control-Expose-Headers', this.options.exposedHeaders.join(', '));
}
if (this.options.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
if (this.options.maxAge !== undefined) {
headers.set('Access-Control-Max-Age', this.options.maxAge.toString());
}
return headers;
}
async handleCors(request: NextRequest): Promise<{
isAllowed: boolean;
headers: Headers;
response?: Response;
}> {
const origin = request.headers.get('origin');
const headers = await this.createCorsHeaders(request);
// Preflight 요청 처리
if (request.method === 'OPTIONS') {
return {
isAllowed: true,
headers,
response: new Response(null, { status: 200, headers }),
};
}
// Origin 검증
if (origin) {
const isAllowed = await this.isOriginAllowed(origin, request);
if (!isAllowed) {
return {
isAllowed: false,
headers: new Headers(),
response: new Response('CORS policy violation', { status: 403 }),
};
}
}
return { isAllowed: true, headers };
}
}
고급 CORS 미들웨어 활용
// lib/cors-middleware.ts
export function withCors(
handler: (request: NextRequest) => Promise<Response>,
corsOptions: AdvancedCorsOptions = {}
) {
const corsManager = new CorsManager(corsOptions);
return async (request: NextRequest): Promise<Response> => {
try {
const { isAllowed, headers, response } = await corsManager.handleCors(request);
// CORS 정책 위반 또는 Preflight 응답
if (response) {
return response;
}
if (!isAllowed) {
return new Response('CORS policy violation', {
status: 403,
headers: new Headers({ 'Content-Type': 'text/plain' })
});
}
// 원래 핸들러 실행
const originalResponse = await handler(request);
// CORS 헤더 추가
headers.forEach((value, key) => {
originalResponse.headers.set(key, value);
});
return originalResponse;
} catch (error) {
console.error('CORS middleware error:', error);
return new Response('Internal server error', {
status: 500,
headers: await corsManager.createCorsHeaders(request)
});
}
};
}
실제 사용 예제
// app/api/protected/route.ts
import { withCors } from '@/lib/cors-middleware';
import { NextRequest } from 'next/server';
const corsConfig = {
allowedDomains: [
'https://myapp.com',
'*.myapp.com', // 서브도메인 허용
'https://partner-site.com',
],
blockedDomains: ['malicious-site.com'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
exposedHeaders: ['X-Total-Count', 'X-Rate-Limit-Remaining'],
maxAge: 3600, // 1시간
originValidator: async (origin: string, request: NextRequest) => {
// 커스텀 검증 로직
const apiKey = request.headers.get('X-API-Key');
// API 키가 있는 경우 더 관대한 CORS 정책 적용
if (apiKey && await validateApiKey(apiKey)) {
return true;
}
// 일반적인 화이트리스트 검증으로 fallback
return false; // 기본 allowedDomains 검증으로 이동
},
};
async function validateApiKey(apiKey: string): Promise<boolean> {
// API 키 검증 로직
return apiKey === 'valid-api-key';
}
const handler = async (request: NextRequest) => {
const method = request.method;
switch (method) {
case 'GET':
return new Response(JSON.stringify({
message: 'Protected data',
timestamp: new Date().toISOString(),
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
case 'POST':
const body = await request.json();
return new Response(JSON.stringify({
message: 'Data processed',
received: body,
}), {
status: 201,
headers: {
'Content-Type': 'application/json',
'X-Total-Count': '1',
},
});
default:
return new Response('Method not allowed', { status: 405 });
}
};
// CORS 미들웨어 적용
export const GET = withCors(handler, corsConfig);
export const POST = withCors(handler, corsConfig);
export const PUT = withCors(handler, corsConfig);
export const DELETE = withCors(handler, corsConfig);
export const OPTIONS = withCors(handler, corsConfig);
환경별 CORS 설정 관리
환경 설정 파일
// config/cors.ts
export interface EnvironmentCorsConfig {
development: AdvancedCorsOptions;
staging: AdvancedCorsOptions;
production: AdvancedCorsOptions;
}
export const corsConfig: EnvironmentCorsConfig = {
development: {
origin: true, // 모든 origin 허용
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
allowedHeaders: ['*'], // 모든 헤더 허용
developmentMode: true,
},
staging: {
allowedDomains: [
'https://staging.myapp.com',
'*.staging.myapp.com',
'http://localhost:3000', // 로컬 개발용
'https://preview-*.netlify.app', // Netlify 프리뷰
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'X-API-Key',
'X-Requested-With',
],
maxAge: 3600, // 1시간
},
production: {
allowedDomains: [
'https://myapp.com',
'https://www.myapp.com',
'https://app.myapp.com',
],
blockedDomains: [
'malicious-site.com',
'spam-domain.org',
],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE'], // OPTIONS 제외
allowedHeaders: [
'Content-Type',
'Authorization',
'X-API-Key',
],
exposedHeaders: [
'X-Total-Count',
'X-Rate-Limit-Remaining',
'X-Rate-Limit-Reset',
],
maxAge: 86400, // 24시간
originValidator: async (origin: string, request: NextRequest) => {
// 프로덕션 전용 추가 검증
const userAgent = request.headers.get('user-agent');
// 의심스러운 User Agent 차단
if (userAgent && /bot|crawler|spider/i.test(userAgent)) {
return false;
}
// Rate limiting 체크
const rateLimitPassed = await checkRateLimit(origin);
return rateLimitPassed;
},
},
};
async function checkRateLimit(origin: string): Promise<boolean> {
// Redis 등을 사용한 Rate Limiting 구현
return true; // 임시 구현
}
export function getCorsConfig(): AdvancedCorsOptions {
const env = process.env.NODE_ENV as keyof EnvironmentCorsConfig;
return corsConfig[env] || corsConfig.development;
}
동적 CORS 설정 로더
// lib/dynamic-cors.ts
import { getCorsConfig } from '@/config/cors';
export class DynamicCorsManager {
private static instance: DynamicCorsManager;
private corsManager: CorsManager;
private configLastUpdated: number = 0;
private configTTL: number = 5 * 60 * 1000; // 5분
private constructor() {
this.corsManager = new CorsManager(getCorsConfig());
}
public static getInstance(): DynamicCorsManager {
if (!DynamicCorsManager.instance) {
DynamicCorsManager.instance = new DynamicCorsManager();
}
return DynamicCorsManager.instance;
}
private shouldReloadConfig(): boolean {
return Date.now() - this.configLastUpdated > this.configTTL;
}
private async reloadConfig(): Promise<void> {
try {
// 외부 설정 소스에서 CORS 설정 로드 (DB, 설정 서비스 등)
const newConfig = await this.fetchConfigFromExternalSource();
if (newConfig) {
this.corsManager = new CorsManager(newConfig);
this.configLastUpdated = Date.now();
console.log('CORS configuration reloaded');
}
} catch (error) {
console.error('Failed to reload CORS configuration:', error);
// 기존 설정 유지
}
}
private async fetchConfigFromExternalSource(): Promise<AdvancedCorsOptions | null> {
// 실제 구현에서는 데이터베이스나 설정 서비스에서 로드
// 여기서는 환경변수 기반 동적 로딩 시뮬레이션
const dynamicOrigins = process.env.CORS_ALLOWED_ORIGINS?.split(',') || [];
const dynamicMethods = process.env.CORS_ALLOWED_METHODS?.split(',') || [];
if (dynamicOrigins.length > 0) {
return {
...getCorsConfig(),
allowedDomains: dynamicOrigins,
methods: dynamicMethods.length > 0 ? dynamicMethods : undefined,
};
}
return null;
}
async handleRequest(request: NextRequest) {
// 설정 갱신이 필요한지 확인
if (this.shouldReloadConfig()) {
await this.reloadConfig();
}
return this.corsManager.handleCors(request);
}
}
// 사용법
export function withDynamicCors(
handler: (request: NextRequest) => Promise<Response>
) {
const dynamicCorsManager = DynamicCorsManager.getInstance();
return async (request: NextRequest): Promise<Response> => {
const { isAllowed, headers, response } = await dynamicCorsManager.handleRequest(request);
if (response) {
return response;
}
if (!isAllowed) {
return new Response('CORS policy violation', { status: 403 });
}
const originalResponse = await handler(request);
headers.forEach((value, key) => {
originalResponse.headers.set(key, value);
});
return originalResponse;
};
}
특별한 상황별 CORS 처리
WebSocket과 Server-Sent Events
// app/api/sse/route.ts
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin');
// SSE에 특화된 CORS 헤더
const headers = new Headers({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': origin || '*',
'Access-Control-Allow-Headers': 'Cache-Control',
'Access-Control-Allow-Credentials': 'true',
});
// SSE 스트림 생성
const stream = new ReadableStream({
start(controller) {
const sendEvent = (data: any) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
};
// 초기 연결 확인
sendEvent({ type: 'connected', timestamp: new Date().toISOString() });
// 주기적으로 데이터 전송
const interval = setInterval(() => {
sendEvent({
type: 'heartbeat',
timestamp: new Date().toISOString(),
data: Math.random(),
});
}, 5000);
// 클린업
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, { headers });
}
파일 업로드 API with CORS
// app/api/upload/route.ts
import { NextRequest } from 'next/server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
const corsOptions = {
allowedDomains: ['https://myapp.com'],
methods: ['POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 3600,
};
export const OPTIONS = withCors(async () => {
return new Response(null, { status: 200 });
}, corsOptions);
export const POST = withCors(async (request: NextRequest) => {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return new Response(JSON.stringify({ error: 'No file provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 파일 타입 검증
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
return new Response(JSON.stringify({ error: 'Invalid file type' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 파일 크기 검증 (5MB 제한)
if (file.size > 5 * 1024 * 1024) {
return new Response(JSON.stringify({ error: 'File too large' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
// 파일 저장
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const path = join(process.cwd(), 'public/uploads', filename);
await writeFile(path, buffer);
return new Response(JSON.stringify({
message: 'File uploaded successfully',
filename,
size: file.size,
type: file.type,
url: `/uploads/${filename}`,
}), {
status: 201,
headers: {
'Content-Type': 'application/json',
'X-Upload-Status': 'success',
},
});
} catch (error) {
console.error('File upload error:', error);
return new Response(JSON.stringify({ error: 'Upload failed' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}, corsOptions);
GraphQL API with CORS
// app/api/graphql/route.ts
import { createYoga } from 'graphql-yoga';
import { NextRequest } from 'next/server';
const yoga = createYoga<{
request: NextRequest;
}>({
schema: /* your GraphQL schema */,
context: ({ request }) => ({
request,
// 추가 컨텍스트
}),
cors: {
origin: (origin, callback) => {
// 동적 origin 검증
const allowedOrigins = [
'https://myapp.com',
'https://admin.myapp.com',
/\.myapp\.com$/, // 정규표현식 지원
];
if (!origin) return callback(null, true); // Same-origin 허용
const isAllowed = allowedOrigins.some(allowed => {
if (allowed instanceof RegExp) {
return allowed.test(origin);
}
return allowed === origin;
});
callback(null, isAllowed);
},
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Apollo-Require-Preflight', // Apollo Client
],
exposedHeaders: ['X-Custom-Header'],
},
fetchAPI: {
Request,
Response,
},
});
// 모든 HTTP 메서드를 GraphQL Yoga에 위임
export {
yoga as GET,
yoga as POST,
yoga as OPTIONS,
};
보안 고려사항 및 베스트 프랙티스
CORS 보안 강화
// lib/secure-cors.ts
export class SecureCorsManager extends CorsManager {
private rateLimitMap = new Map<string, { count: number; resetTime: number }>();
private suspiciousOrigins = new Set<string>();
async isOriginAllowed(origin: string, request: NextRequest): Promise<boolean> {
// 1. 기본 CORS 정책 확인
const basicCheck = await super.isOriginAllowed(origin, request);
if (!basicCheck) return false;
// 2. Rate limiting 확인
if (!this.checkRateLimit(origin)) {
this.suspiciousOrigins.add(origin);
console.warn(`Rate limit exceeded for origin: ${origin}`);
return false;
}
// 3. 의심스러운 origin 차단
if (this.suspiciousOrigins.has(origin)) {
console.warn(`Blocked suspicious origin: ${origin}`);
return false;
}
// 4. 추가 보안 검사
return this.performSecurityChecks(origin, request);
}
private checkRateLimit(origin: string): boolean {
const now = Date.now();
const limit = this.rateLimitMap.get(origin);
if (!limit) {
this.rateLimitMap.set(origin, { count: 1, resetTime: now + 60000 }); // 1분
return true;
}
if (now > limit.resetTime) {
this.rateLimitMap.set(origin, { count: 1, resetTime: now + 60000 });
return true;
}
if (limit.count >= 100) { // 분당 100회 제한
return false;
}
limit.count++;
return true;
}
private async performSecurityChecks(origin: string, request: NextRequest): Promise<boolean> {
// User Agent 검사
const userAgent = request.headers.get('user-agent');
if (!userAgent || this.isSuspiciousUserAgent(userAgent)) {
console.warn(`Suspicious User-Agent from ${origin}: ${userAgent}`);
return false;
}
// Referrer 검사
const referrer = request.headers.get('referer');
if (referrer && !this.isValidReferrer(referrer, origin)) {
console.warn(`Invalid referrer from ${origin}: ${referrer}`);
return false;
}
// 요청 헤더 패턴 분석
if (this.detectAnomalousHeaders(request)) {
console.warn(`Anomalous headers detected from ${origin}`);
return false;
}
return true;
}
private isSuspiciousUserAgent(userAgent: string): boolean {
const suspiciousPatterns = [
/curl/i,
/wget/i,
/python/i,
/bot/i,
/crawler/i,
/spider/i,
];
return suspiciousPatterns.some(pattern => pattern.test(userAgent));
}
private isValidReferrer(referrer: string, origin: string): boolean {
try {
const referrerUrl = new URL(referrer);
const originUrl = new URL(origin);
return referrerUrl.origin === originUrl.origin;
} catch {
return false;
}
}
private detectAnomalousHeaders(request: NextRequest): boolean {
const headers = Array.from(request.headers.entries());
// 너무 많은 커스텀 헤더
const customHeaders = headers.filter(([key]) =>
key.startsWith('x-') || key.startsWith('custom-')
);
if (customHeaders.length > 10) {
return true;
}
// 의심스러운 헤더 조합
const hasAuthHeader = request.headers.has('authorization');
const hasApiKeyHeader = request.headers.has('x-api-key');
const hasCustomAuth = headers.some(([key]) =>
key.toLowerCase().includes('auth') && key !== 'authorization'
);
// 여러 인증 헤더가 동시에 있는 경우
if ([hasAuthHeader, hasApiKeyHeader, hasCustomAuth].filter(Boolean).length > 1) {
return true;
}
return false;
}
// 의심스러운 origin을 일정 시간 후 해제
cleanupSuspiciousOrigins(): void {
setInterval(() => {
const now = Date.now();
// Rate limit 맵 정리
for (const [origin, data] of this.rateLimitMap.entries()) {
if (now > data.resetTime) {
this.rateLimitMap.delete(origin);
}
}
// 의심스러운 origin 일부 해제 (24시간 후)
if (this.suspiciousOrigins.size > 100) {
const originsArray = Array.from(this.suspiciousOrigins);
const toRemove = originsArray.slice(0, 50); // 절반 해제
toRemove.forEach(origin => this.suspiciousOrigins.delete(origin));
}
}, 5 * 60 * 1000); // 5분마다 실행
}
}
CORS 보안 모니터링
// lib/cors-monitoring.ts
export interface CorsEvent {
timestamp: string;
origin: string | null;
method: string;
path: string;
allowed: boolean;
reason?: string;
userAgent?: string;
ip?: string;
}
export class CorsMonitor {
private events: CorsEvent[] = [];
private maxEvents = 10000;
logEvent(event: CorsEvent): void {
this.events.push(event);
// 메모리 사용량 제한
if (this.events.length > this.maxEvents) {
this.events = this.events.slice(-this.maxEvents / 2);
}
// 중요한 보안 이벤트는 즉시 알림
if (!event.allowed && event.reason) {
this.sendSecurityAlert(event);
}
}
private async sendSecurityAlert(event: CorsEvent): Promise<void> {
// Slack, 이메일, 또는 모니터링 시스템으로 알림 발송
console.error('CORS Security Alert:', {
timestamp: event.timestamp,
origin: event.origin,
method: event.method,
path: event.path,
reason: event.reason,
userAgent: event.userAgent,
ip: event.ip,
});
// 실제 구현에서는 외부 알림 서비스 사용
// await notificationService.sendAlert(event);
}
getSecurityReport(timeRangeHours = 24): {
totalRequests: number;
blockedRequests: number;
topBlockedOrigins: Array<{ origin: string; count: number }>;
securityThreats: Array<{ type: string; count: number }>;
} {
const cutoffTime = new Date(Date.now() - timeRangeHours * 60 * 60 * 1000);
const recentEvents = this.events.filter(
event => new Date(event.timestamp) > cutoffTime
);
const blockedEvents = recentEvents.filter(event => !event.allowed);
// 차단된 origin별 통계
const originCounts = new Map<string, number>();
blockedEvents.forEach(event => {
if (event.origin) {
originCounts.set(event.origin, (originCounts.get(event.origin) || 0) + 1);
}
});
const topBlockedOrigins = Array.from(originCounts.entries())
.map(([origin, count]) => ({ origin, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// 보안 위협 유형별 통계
const threatCounts = new Map<string, number>();
blockedEvents.forEach(event => {
if (event.reason) {
threatCounts.set(event.reason, (threatCounts.get(event.reason) || 0) + 1);
}
});
const securityThreats = Array.from(threatCounts.entries())
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count);
return {
totalRequests: recentEvents.length,
blockedRequests: blockedEvents.length,
topBlockedOrigins,
securityThreats,
};
}
// 실시간 모니터링을 위한 이벤트 스트림
getEventStream(): ReadableStream<string> {
return new ReadableStream({
start: (controller) => {
const interval = setInterval(() => {
const recentEvents = this.events.slice(-10);
controller.enqueue(JSON.stringify(recentEvents) + '\n');
}, 5000); // 5초마다 업데이트
return () => clearInterval(interval);
},
});
}
}
export const corsMonitor = new CorsMonitor();
// 보안 CORS 미들웨어에 모니터링 통합
export function withSecureCors(
handler: (request: NextRequest) => Promise<Response>,
corsOptions: AdvancedCorsOptions = {}
) {
const secureCorsManager = new SecureCorsManager(corsOptions);
return async (request: NextRequest): Promise<Response> => {
const startTime = Date.now();
const origin = request.headers.get('origin');
try {
const { isAllowed, headers, response } = await secureCorsManager.handleCors(request);
// 모니터링 이벤트 기록
corsMonitor.logEvent({
timestamp: new Date().toISOString(),
origin,
method: request.method,
path: new URL(request.url).pathname,
allowed: isAllowed,
reason: !isAllowed ? 'Policy violation' : undefined,
userAgent: request.headers.get('user-agent') || undefined,
ip: request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') || undefined,
});
if (response) {
return response;
}
if (!isAllowed) {
return new Response('CORS policy violation', {
status: 403,
headers: new Headers({ 'Content-Type': 'text/plain' })
});
}
const originalResponse = await handler(request);
// CORS 헤더 추가
headers.forEach((value, key) => {
originalResponse.headers.set(key, value);
});
// 성능 메트릭 추가
const processingTime = Date.now() - startTime;
originalResponse.headers.set('X-Processing-Time', `${processingTime}ms`);
return originalResponse;
} catch (error) {
console.error('Secure CORS middleware error:', error);
// 에러 모니터링
corsMonitor.logEvent({
timestamp: new Date().toISOString(),
origin,
method: request.method,
path: new URL(request.url).pathname,
allowed: false,
reason: 'Internal error',
userAgent: request.headers.get('user-agent') || undefined,
});
return new Response('Internal server error', {
status: 500,
headers: await secureCorsManager.createCorsHeaders(request)
});
}
};
}
성능 최적화 및 모니터링
CORS 캐싱 전략
// lib/cors-cache.ts
export class CorsCache {
private cache = new Map<string, { result: boolean; expiry: number }>();
private readonly TTL = 5 * 60 * 1000; // 5분
getCachedResult(origin: string): boolean | null {
const cached = this.cache.get(origin);
if (!cached) return null;
if (Date.now() > cached.expiry) {
this.cache.delete(origin);
return null;
}
return cached.result;
}
setCachedResult(origin: string, result: boolean): void {
this.cache.set(origin, {
result,
expiry: Date.now() + this.TTL,
});
}
// 메모리 사용량 관리
cleanup(): void {
const now = Date.now();
for (const [origin, cached] of this.cache.entries()) {
if (now > cached.expiry) {
this.cache.delete(origin);
}
}
}
// 통계 정보
getStats(): {
size: number;
hitRate: number;
memoryUsage: number;
} {
return {
size: this.cache.size,
hitRate: 0, // 실제 구현에서는 hit/miss 카운터 필요
memoryUsage: JSON.stringify(Array.from(this.cache.entries())).length,
};
}
}
// 성능 최적화된 CORS 매니저
export class OptimizedCorsManager extends SecureCorsManager {
private cache = new CorsCache();
async isOriginAllowed(origin: string, request: NextRequest): Promise<boolean> {
// 캐시된 결과 확인
const cachedResult = this.cache.getCachedResult(origin);
if (cachedResult !== null) {
return cachedResult;
}
// 실제 검증 수행
const result = await super.isOriginAllowed(origin, request);
// 결과 캐싱 (성공한 경우만)
if (result) {
this.cache.setCachedResult(origin, result);
}
return result;
}
// 정기적 캐시 정리
startCleanupScheduler(): void {
setInterval(() => {
this.cache.cleanup();
}, 2 * 60 * 1000); // 2분마다
}
getCacheStats() {
return this.cache.getStats();
}
}
실시간 CORS 대시보드
// app/api/admin/cors-dashboard/route.ts
import { NextRequest } from 'next/server';
import { corsMonitor } from '@/lib/cors-monitoring';
export async function GET(request: NextRequest) {
// 관리자 인증 확인
const authHeader = request.headers.get('authorization');
if (!authHeader || !isValidAdminToken(authHeader)) {
return new Response('Unauthorized', { status: 401 });
}
const url = new URL(request.url);
const format = url.searchParams.get('format') || 'json';
const timeRange = parseInt(url.searchParams.get('hours') || '24');
if (format === 'stream') {
// Server-Sent Events로 실시간 업데이트
const stream = corsMonitor.getEventStream();
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': 'https://admin.myapp.com',
'Access-Control-Allow-Credentials': 'true',
},
});
}
// JSON 형태의 보고서
const report = corsMonitor.getSecurityReport(timeRange);
return new Response(JSON.stringify({
report,
timestamp: new Date().toISOString(),
timeRangeHours: timeRange,
}), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': 'https://admin.myapp.com',
'Access-Control-Allow-Credentials': 'true',
},
});
}
function isValidAdminToken(authHeader: string): boolean {
// 실제 구현에서는 JWT 토큰 검증 등
return authHeader === 'Bearer admin-token';
}
트러블슈팅 가이드
일반적인 CORS 문제와 해결책
// lib/cors-troubleshooting.ts
export class CorsTroubleshooter {
static diagnose(request: NextRequest, response: Response): {
issues: string[];
recommendations: string[];
debugInfo: any;
} {
const issues: string[] = [];
const recommendations: string[] = [];
const origin = request.headers.get('origin');
// 1. Origin 헤더 누락
if (!origin && request.method !== 'GET') {
issues.push('Missing Origin header for non-GET request');
recommendations.push('Check if the client is sending the Origin header correctly');
}
// 2. CORS 헤더 누락 체크
const corsHeaders = [
'access-control-allow-origin',
'access-control-allow-methods',
'access-control-allow-headers',
];
corsHeaders.forEach(header => {
if (!response.headers.has(header)) {
issues.push(`Missing CORS header: ${header}`);
recommendations.push(`Add ${header} to your CORS configuration`);
}
});
// 3. Credentials 관련 문제
const allowCredentials = response.headers.get('access-control-allow-credentials');
const allowOrigin = response.headers.get('access-control-allow-origin');
if (allowCredentials === 'true' && allowOrigin === '*') {
issues.push('Cannot use wildcard origin (*) with credentials: true');
recommendations.push('Specify explicit origins when using credentials');
}
// 4. Preflight 요청 처리
if (request.method === 'OPTIONS') {
const requestMethod = request.headers.get('access-control-request-method');
const allowMethods = response.headers.get('access-control-allow-methods');
if (requestMethod && allowMethods && !allowMethods.includes(requestMethod)) {
issues.push(`Requested method ${requestMethod} not allowed`);
recommendations.push(`Add ${requestMethod} to allowed methods`);
}
}
// 5. 헤더 문제 진단
const requestHeaders = request.headers.get('access-control-request-headers');
const allowHeaders = response.headers.get('access-control-allow-headers');
if (requestHeaders && allowHeaders) {
const requested = requestHeaders.toLowerCase().split(',').map(h => h.trim());
const allowed = allowHeaders.toLowerCase().split(',').map(h => h.trim());
const notAllowed = requested.filter(h => !allowed.includes(h));
if (notAllowed.length > 0) {
issues.push(`Requested headers not allowed: ${notAllowed.join(', ')}`);
recommendations.push(`Add these headers to allowedHeaders: ${notAllowed.join(', ')}`);
}
}
return {
issues,
recommendations,
debugInfo: {
origin,
method: request.method,
requestHeaders: requestHeaders,
allowOrigin: allowOrigin,
allowMethods: response.headers.get('access-control-allow-methods'),
allowHeaders: response.headers.get('access-control-allow-headers'),
allowCredentials: allowCredentials,
userAgent: request.headers.get('user-agent'),
},
};
}
static generateDebugResponse(request: NextRequest): Response {
const diagnosis = this.diagnose(request, new Response());
return new Response(JSON.stringify({
message: 'CORS Debug Information',
diagnosis,
suggestions: [
'Check browser developer tools Network tab for CORS errors',
'Verify that your API endpoint has proper CORS headers',
'Ensure Origin header matches your allowed origins exactly',
'For credentials: true, avoid using wildcard origins',
],
documentation: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS',
}, null, 2), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
}
// 디버그 엔드포인트
// app/api/debug/cors/route.ts
export function GET(request: NextRequest) {
return CorsTroubleshooter.generateDebugResponse(request);
}
export function OPTIONS(request: NextRequest) {
return CorsTroubleshooter.generateDebugResponse(request);
}
CORS 테스트 도구
// lib/cors-testing.ts
export interface CorsTestCase {
name: string;
origin: string;
method: string;
headers?: Record<string, string>;
expectedResult: 'allow' | 'block';
description: string;
}
export class CorsTestSuite {
private testCases: CorsTestCase[] = [
{
name: 'Valid Same Origin',
origin: 'https://myapp.com',
method: 'GET',
expectedResult: 'allow',
description: 'Same origin requests should be allowed',
},
{
name: 'Valid CORS Origin',
origin: 'https://partner.com',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
expectedResult: 'allow',
description: 'Whitelisted origin should be allowed',
},
{
name: 'Blocked Origin',
origin: 'https://malicious.com',
method: 'GET',
expectedResult: 'block',
description: 'Non-whitelisted origin should be blocked',
},
{
name: 'Preflight Request',
origin: 'https://myapp.com',
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type, Authorization',
},
expectedResult: 'allow',
description: 'Preflight requests should be handled correctly',
},
];
async runTests(
corsManager: CorsManager,
baseUrl = 'http://localhost:3000'
): Promise<{
passed: number;
failed: number;
results: Array<{
test: CorsTestCase;
result: 'pass' | 'fail';
error?: string;
}>;
}> {
const results = [];
let passed = 0;
let failed = 0;
for (const testCase of this.testCases) {
try {
const mockRequest = this.createMockRequest(testCase, baseUrl);
const { isAllowed } = await corsManager.handleCors(mockRequest);
const expectAllow = testCase.expectedResult === 'allow';
const testPassed = isAllowed === expectAllow;
results.push({
test: testCase,
result: testPassed ? 'pass' : 'fail',
error: testPassed ? undefined :
`Expected ${testCase.expectedResult}, got ${isAllowed ? 'allow' : 'block'}`,
});
if (testPassed) {
passed++;
} else {
failed++;
}
} catch (error) {
results.push({
test: testCase,
result: 'fail',
error: error instanceof Error ? error.message : 'Unknown error',
});
failed++;
}
}
return { passed, failed, results };
}
private createMockRequest(testCase: CorsTestCase, baseUrl: string): NextRequest {
const url = new URL('/api/test', baseUrl);
const headers = new Headers({
origin: testCase.origin,
...testCase.headers,
});
return new NextRequest(url, {
method: testCase.method,
headers,
});
}
addTestCase(testCase: CorsTestCase): void {
this.testCases.push(testCase);
}
generateTestReport(results: any): string {
const { passed, failed, results: testResults } = results;
let report = `CORS Test Report\n`;
report += `================\n`;
report += `Total Tests: ${passed + failed}\n`;
report += `Passed: ${passed}\n`;
report += `Failed: ${failed}\n`;
report += `Success Rate: ${((passed / (passed + failed)) * 100).toFixed(1)}%\n\n`;
testResults.forEach((result: any, index: number) => {
const status = result.result === 'pass' ? '✅' : '❌';
report += `${index + 1}. ${status} ${result.test.name}\n`;
report += ` Origin: ${result.test.origin}\n`;
report += ` Method: ${result.test.method}\n`;
if (result.error) {
report += ` Error: ${result.error}\n`;
}
report += ` Description: ${result.test.description}\n\n`;
});
return report;
}
}
결론
Next.js 13+ Route Handlers에서의 CORS 설정은 현대 웹 애플리케이션의 보안과 상호 운용성을 위한 핵심 요소입니다. 이 가이드에서 다룬 내용을 요약하면:
핵심 포인트
기본 구현: Route Handlers의 Response 객체를 활용한 직접적인 CORS 헤더 제어가 가능하며, 이를 통해 더 세밀한 설정이 가능합니다.
보안 강화: 단순한 origin 화이트리스트를 넘어서 rate limiting, 의심스러운 패턴 감지, 동적 검증 등의 고급 보안 기능을 구현할 수 있습니다.
성능 최적화: 캐싱, 모니터링, 효율적인 검증 로직을 통해 CORS 처리로 인한 성능 오버헤드를 최소화할 수 있습니다.
환경별 관리: 개발, 스테이징, 프로덕션 환경에 맞는 차별화된 CORS 정책을 적용하여 보안과 개발 편의성의 균형을 맞출 수 있습니다.
베스트 프랙티스
- 최소 권한 원칙: 꼭 필요한 origin, method, header만 허용
- 환경별 차별화: 개발 환경과 프로덕션 환경의 CORS 정책 분리
- 모니터링 강화: CORS 위반 시도와 패턴을 지속적으로 모니터링
- 성능 고려: 캐싱과 효율적인 검증 로직으로 응답 속도 최적화
- 문서화: 팀 내 CORS 정책과 설정 방법에 대한 명확한 문서화
Route Handlers의 유연성을 활용하여 안전하고 효율적인 CORS 시스템을 구축함으로써, 다양한 클라이언트와의 안전한 통신을 보장하면서도 보안을 타협하지 않는 API를 만들 수 있습니다.