[Next.js] Route Handlers에서의 CORS 설정




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 정책을 적용하여 보안과 개발 편의성의 균형을 맞출 수 있습니다.

베스트 프랙티스

  1. 최소 권한 원칙: 꼭 필요한 origin, method, header만 허용
  2. 환경별 차별화: 개발 환경과 프로덕션 환경의 CORS 정책 분리
  3. 모니터링 강화: CORS 위반 시도와 패턴을 지속적으로 모니터링
  4. 성능 고려: 캐싱과 효율적인 검증 로직으로 응답 속도 최적화
  5. 문서화: 팀 내 CORS 정책과 설정 방법에 대한 명확한 문서화

Route Handlers의 유연성을 활용하여 안전하고 효율적인 CORS 시스템을 구축함으로써, 다양한 클라이언트와의 안전한 통신을 보장하면서도 보안을 타협하지 않는 API를 만들 수 있습니다.




댓글 남기기