architecture2025-04-20·8 min·115/348

Vercel에서 외부 API 프록시 설정

Vercel에서 외부 API를 프록시하여 CORS 문제를 해결하고 보안을 강화하는 방법을 알아봅니다.

Vercel에서 외부 API 프록시 설정

Introduction

클라이언트에서 외부 API를 직접 호출할 때 발생하는 CORS 문제를 해결하고, API 키를 보호하기 위해 프록시 서버를 사용하는 것이 일반적입니다. Vercel 환경에서 외부 API 프록시를 설정하는 방법과 다양한 구현 사례를 살펴보겠습니다.

Environment

  • Vercel 프로젝트 (Next.js 14+)
  • Edge Runtime
  • 외부 API (OpenAI, Stripe 등)
  • Redis (캐싱용)

Problem

클라이언트에서 직접 외부 API를 호출할 때 발생하는 문제:

// 클라이언트 코드
const response = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ model: 'gpt-4', messages: [...] })
});

// 에러: CORS 정책 위반
// Access to fetch at 'https://api.openai.com' from origin 'https://your-app.vercel.app'
// has been blocked by CORS policy

Analysis

CORS 문제의 원인:

  1. 브라우저의 동일 출처 정책(Same-Origin Policy)
  2. 외부 API가 특정 도메인만 허용하는 경우
  3. API 키가 클라이언트에 노출되는 보안 문제

프록시의 장점:

  • CORS 문제 해결
  • API 키 보호
  • 요청/응답 변환 가능
  • 캐싱 및 레이트 리밋링 구현

Solution

1. Next.js API Routes를 사용한 프록시

// app/api/proxy/openai/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  const body = await request.json();
  
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  });
  
  const data = await response.json();
  
  return NextResponse.json(data);
}
// 클라이언트 코드
const response = await fetch('/api/proxy/openai', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'gpt-4',
    messages: [{ role: 'user', content: 'Hello' }]
  })
});

2. Edge Runtime 프록시

// app/api/proxy/edge/route.js
export const runtime = 'edge';

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');
  
  if (!url) {
    return new Response('Missing url parameter', { status: 400 });
  }
  
  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${process.env.API_KEY}`
      }
    });
    
    const data = await response.json();
    
    return Response.json(data);
  } catch (error) {
    return Response.json(
      { error: 'Proxy request failed' },
      { status: 500 }
    );
  }
}

3. 캐싱 기반 프록시

// app/api/proxy/cached/route.js
import { NextResponse } from 'next/server';
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN
});

export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const endpoint = searchParams.get('endpoint');
  const cacheKey = `proxy:${endpoint}`;
  const cacheTTL = 300; // 5분
  
  // 캐시 확인
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached));
  }
  
  // 외부 API 호출
  const response = await fetch(`https://api.example.com/${endpoint}`, {
    headers: {
      'Authorization': `Bearer ${process.env.API_KEY}`
    }
  });
  
  const data = await response.json();
  
  // 캐시 저장
  await redis.setex(cacheKey, cacheTTL, JSON.stringify(data));
  
  return NextResponse.json(data);
}

4. 요청/응답 변환 프록시

// app/api/proxy/transform/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  const body = await request.json();
  
  // 요청 변환
  const transformedRequest = {
    model: body.model || 'default',
    prompt: body.message,
    max_tokens: body.maxLength || 100,
    temperature: body.temperature || 0.7
  };
  
  const response = await fetch('https://api.ai-service.com/generate', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.AI_API_KEY
    },
    body: JSON.stringify(transformedRequest)
  });
  
  const data = await response.json();
  
  // 응답 변환
  const transformedResponse = {
    text: data.generated_text,
    tokens: data.usage.total_tokens,
    model: data.model
  };
  
  return NextResponse.json(transformedResponse);
}

5. 레이트 리밋링 프록시

// lib/rate-limiter.js
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN
});

export async function checkRateLimit(identifier, limit = 100, window = 60) {
  const key = `ratelimit:${identifier}`;
  const current = await redis.incr(key);
  
  if (current === 1) {
    await redis.expire(key, window);
  }
  
  return {
    allowed: current <= limit,
    remaining: Math.max(0, limit - current),
    reset: Date.now() + (window * 1000)
  };
}

// app/api/proxy/rate-limited/route.js
import { NextResponse } from 'next/server';
import { checkRateLimit } from '@/lib/rate-limiter';

export async function GET(request) {
  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  const rateLimit = await checkRateLimit(ip, 100, 60);
  
  if (!rateLimit.allowed) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { 
        status: 429,
        headers: {
          'X-RateLimit-Remaining': '0',
          'X-RateLimit-Reset': rateLimit.reset.toString()
        }
      }
    );
  }
  
  const response = await fetch('https://api.external.com/data', {
    headers: { 'Authorization': `Bearer ${process.env.API_KEY}` }
  });
  
  const data = await response.json();
  
  return NextResponse.json(data, {
    headers: {
      'X-RateLimit-Remaining': rateLimit.remaining.toString()
    }
  });
}

6. 에러 핸들링 강화

// app/api/proxy/safe/route.js
import { NextResponse } from 'next/server';

export async function POST(request) {
  try {
    const body = await request.json();
    
    // 입력 검증
    if (!body.url || !body.url.startsWith('https://')) {
      return NextResponse.json(
        { error: 'Invalid URL' },
        { status: 400 }
      );
    }
    
    // 외부 API 호출
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 10000);
    
    const response = await fetch(body.url, {
      method: body.method || 'GET',
      headers: {
        'Authorization': `Bearer ${process.env.API_KEY}`,
        'Content-Type': 'application/json'
      },
      signal: controller.signal
    });
    
    clearTimeout(timeout);
    
    const data = await response.json();
    
    return NextResponse.json(data);
  } catch (error) {
    console.error('Proxy error:', error);
    
    return NextResponse.json(
      { error: 'Proxy request failed' },
      { status: 500 }
    );
  }
}

Lessons Learned

  1. 보안第一: API 키는 절대 클라이언트에 노출되지 않도록 해야 합니다.
  2. CORS 처리: 프록시는 CORS 문제를 해결하는 가장 효과적인 방법입니다.
  3. 캐싱 전략: 자주 호출되는 API는 캐싱하여 비용과 지연 시간을 줄여야 합니다.
  4. 에러 핸들링: 외부 API 실패 시 적절한 에러 응답을 제공해야 합니다.
  5. 레이트 리밋링: 외부 API의 레이트 리밋을 준수하도록 프록시를 설정해야 합니다.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.