API 보안 기초 — CORS, CSRF, Rate Limiting

웹 개발자가 반드시 알아야 할 API 보안 개념 3가지를 정리합니다.

왜 API 보안이 필요한가?

웹 애플리케이션은 결국 클라이언트(브라우저)와 서버가 HTTP로 대화하는 구조다. 문제는 이 대화에 아무나 끼어들 수 있다는 것이다.

[내 사이트]  ──요청──▶  [내 API 서버]   ← 정상
[악성 사이트] ──요청──▶  [내 API 서버]   ← 비정상인데 서버는 구분 못함

서버 입장에서는 요청이 어디서 왔는지, 누가 보냈는지, 얼마나 보내는지를 스스로 검증해야 한다. 이 3가지 문제를 각각 해결하는 것이 CORS, CSRF 방어, Rate Limiting이다.

보안 개념 해결하는 문제
CORS 어디서 온 요청인가? (출처 제어)
CSRF 사용자가 진짜 의도한 요청인가? (위조 방지)
Rate Limiting 요청이 너무 많지 않은가? (남용 방지)

CORS (Cross-Origin Resource Sharing)

같은 출처(Origin)란?

브라우저는 프로토콜 + 도메인 + 포트가 모두 같아야 "같은 출처"로 판단한다.

https://mysite.com:443/api    ← 기준

https://mysite.com:443/other  ← ✅ 같은 출처 (경로만 다름)
http://mysite.com:443/api     ← ❌ 프로토콜 다름
https://mysite.com:3000/api   ← ❌ 포트 다름
https://other.com:443/api     ← ❌ 도메인 다름

브라우저의 기본 정책: Same-Origin Policy

브라우저는 기본적으로 다른 출처의 API 요청을 차단한다. 이것은 서버가 막는 게 아니라 브라우저가 막는 것이다.

[localhost:3000]  ──fetch──▶  [api.mysite.com]
                                    │
                  ◀──응답──────────┘
                  
브라우저: "출처가 다르네? 응답 차단!"

CORS는 이 제한을 풀어주는 장치

서버가 응답 헤더에 "이 출처에서 오는 요청은 허용한다"고 명시하면 브라우저가 통과시켜준다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://mysite.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

Preflight 요청

브라우저는 본 요청을 보내기 전에 OPTIONS 메서드로 사전 확인 요청을 보낸다. "이 요청 보내도 되나요?" 하고 서버에 먼저 물어보는 것이다.

1. 브라우저 → 서버: OPTIONS /api/users (preflight)
2. 서버 → 브라우저: "허용합니다" (CORS 헤더 포함)
3. 브라우저 → 서버: POST /api/users (본 요청)

단, 모든 요청에 preflight가 발생하는 것은 아니다. GET 요청이면서 커스텀 헤더가 없는 단순 요청(Simple Request)은 preflight 없이 바로 전송된다.

Next.js에서 CORS 설정

// app/api/users/route.ts
export async function GET(request: Request) {
  const data = await getUsers()

  return Response.json(data, {
    headers: {
      'Access-Control-Allow-Origin': 'https://mysite.com',
      'Access-Control-Allow-Methods': 'GET, POST',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  })
}

주의: Access-Control-Allow-Origin: *로 모든 출처를 허용하면 편하지만, 인증이 필요한 API에서는 절대 쓰지 않는다.


CSRF (Cross-Site Request Forgery)

CSRF란?

사용자가 로그인된 상태에서, 악성 사이트가 사용자의 브라우저를 이용해 의도하지 않은 요청을 보내는 공격이다.

1. 사용자가 bank.com에 로그인 (쿠키 발급됨)
2. 사용자가 악성 사이트 방문
3. 악성 사이트에 숨겨진 코드:
   <img src="https://bank.com/api/transfer?to=hacker&amount=1000000">
4. 브라우저가 bank.com으로 요청 → 쿠키가 자동으로 포함됨
5. 서버는 정상 사용자의 요청으로 판단 → 송금 실행

핵심은 브라우저가 쿠키를 자동으로 포함시킨다는 점이다. 서버 입장에서는 사용자가 직접 보낸 요청과 악성 사이트가 보낸 요청을 구분할 수 없다.

CSRF 방어 방법

1. CSRF Token

서버가 폼을 렌더링할 때 랜덤 토큰을 숨겨서 같이 보낸다. 이 토큰은 악성 사이트에서는 알 수 없기 때문에 위조된 요청을 구분할 수 있다.

// 서버: 폼 렌더링 시 토큰 포함
<form action="/api/transfer" method="POST">
  <input type="hidden" name="csrf_token" value="abc123random" />
  <input type="text" name="amount" />
  <button type="submit">송금</button>
</form>

// 서버: 요청 처리 시 토큰 검증
if (request.body.csrf_token !== session.csrfToken) {
  return Response.json({ error: 'Invalid CSRF token' }, { status: 403 })
}

2. SameSite 쿠키

쿠키에 SameSite 속성을 설정하면 다른 사이트에서 오는 요청에 쿠키가 포함되지 않는다.

Set-Cookie: session=abc123; SameSite=Strict; Secure; HttpOnly
SameSite 값 동작
Strict 다른 사이트에서 오는 모든 요청에 쿠키 미포함
Lax GET 요청(링크 클릭)에만 쿠키 포함, POST 등은 미포함
None 모든 요청에 쿠키 포함 (Secure 필수)

3. Authorization 헤더 사용

쿠키 대신 Authorization: Bearer <token> 헤더를 사용하면 브라우저가 자동으로 포함시키지 않기 때문에 CSRF 자체가 불가능하다.

// 쿠키가 아닌 헤더로 인증 → CSRF 걱정 없음
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ to: 'friend', amount: 50000 }),
})

Rate Limiting

Rate Limiting이란?

일정 시간 내에 허용되는 요청 횟수를 제한하는 것이다. 비유하자면 놀이공원 입장 제한과 같다 — 한 번에 너무 많은 사람이 들어오면 시설이 마비되듯, 서버도 요청이 폭주하면 다운된다.

왜 필요한가?

❌ Rate Limiting이 없으면:
- 로그인 API에 비밀번호를 1초에 1000번 시도 (브루트포스 공격)
- 크롤러가 데이터를 무한으로 긁어감
- 한 사용자가 서버 자원을 독점 → 다른 사용자 서비스 불가

일반적인 전략

전략 설명 예시
Fixed Window 고정 시간 창에서 요청 수 카운트 1분에 100회
Sliding Window 현재 시점 기준 이동 시간 창 최근 1분간 100회
Token Bucket 토큰이 일정 속도로 채워지고, 요청마다 소모 버스트 허용

응답 헤더

Rate Limiting이 적용된 API는 보통 아래 헤더를 포함한다.

HTTP/1.1 200 OK
X-RateLimit-Limit: 100        ← 최대 요청 수
X-RateLimit-Remaining: 57     ← 남은 요청 수
X-RateLimit-Reset: 1712700000 ← 리셋 시각 (Unix timestamp)

제한을 초과하면 429 Too Many Requests 응답이 온다.

HTTP/1.1 429 Too Many Requests
Retry-After: 30

Next.js에서 간단한 Rate Limiting 구현

// 메모리 기반 간단 구현 (프로덕션에서는 Redis 사용 권장)
const rateLimit = new Map<string, { count: number; resetTime: number }>()

const WINDOW_MS = 60 * 1000 // 1분
const MAX_REQUESTS = 100

function isRateLimited(ip: string): boolean {
  const now = Date.now()
  const record = rateLimit.get(ip)

  if (!record || now > record.resetTime) {
    rateLimit.set(ip, { count: 1, resetTime: now + WINDOW_MS })
    return false
  }

  record.count++
  return record.count > MAX_REQUESTS
}

// app/api/users/route.ts
export async function GET(request: Request) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown'

  if (isRateLimited(ip)) {
    return Response.json(
      { error: 'Too many requests' },
      { status: 429, headers: { 'Retry-After': '60' } }
    )
  }

  const data = await getUsers()
  return Response.json(data)
}

프로덕션 환경에서는 서버 메모리 대신 Redis 같은 외부 저장소를 사용하고, Vercel이나 Cloudflare 같은 플랫폼의 내장 Rate Limiting 기능을 활용하는 것이 일반적이다.


정리

개념 한 줄 요약 누가 담당?
CORS 허용된 출처만 API에 접근하게 한다 서버가 헤더로 설정, 브라우저가 검사
CSRF 사용자의 의도하지 않은 요청을 막는다 서버가 토큰/쿠키 정책으로 방어
Rate Limiting 요청 횟수를 제한해 남용을 막는다 서버가 요청 수를 카운트하여 차단

이 3가지는 서로 보완적이다. CORS로 출처를 제한하고, CSRF 방어로 위조 요청을 차단하고, Rate Limiting으로 폭주를 막는다. 어느 하나만으로는 충분하지 않고, 함께 적용해야 안전한 API가 된다.