CORS 오류는 웹 개발에서 가장 답답한 문제 중 하나입니다. 콘솔에 빨간 에러 메시지가 나타나고, API 호출이 실패하며, 아무것도 작동하지 않는 것처럼 보입니다. 이 가이드는 CORS가 무엇인지 정확히 이해하고, 구체적인 오류를 진단하고, 올바른 솔루션으로 수정하는 방법을 도와줍니다.
CORS란? (30초 설명)
Cross-Origin Resource Sharing(CORS)는 웹 페이지가 페이지를 제공하는 도메인과 다른 도메인으로 요청을 보내는 것을 차단하는 브라우저 보안 기능입니다. 프론트엔드 http://localhost:3000이 http://api.example.com에서 데이터를 가져오려고 할 때, 브라우저는 API 서버가 특별한 HTTP 헤더를 통해 이 크로스오리진 요청을 명시적으로 허용하는지 확인합니다.
CORS 작동 원리: 흐름도
┌─────────────────┐ ┌─────────────────────┐
│ Browser │ │ API Server │
│ (Frontend) │ │ (Backend) │
│ │ │ │
│ http://localhost │ ──────> │ https://api.example │
│ :3000 │ fetch │ .com │
│ │ │ │
└─────────────────┘ └─────────────────────┘
│
┌───────────────┴───────────────┐
│ │
CORS Headers No CORS Headers
Present? Present?
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ Response sent │ │ Browser BLOCKS │
│ to JavaScript │ │ the response │
└─────────────────┘ └──────────────────┘CORS가 올바르게 설정되면, 서버는 브라우저에 "네, 이 출처의 요청을 허용합니다"라고 알리는 헤더로 응답합니다.
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Content-Type: application/json
{"data": "success"}CORS가 설정되지 않으면, 브라우저는 응답을 차단합니다(서버가 요청을 성공적으로 처리했더라도).
HTTP/1.1 200 OK
Content-Type: application/json
# No Access-Control-Allow-Origin header!
{"data": "success"} <-- Browser received this but BLOCKS JavaScript from reading it가장 흔한 5가지 CORS 오류 메시지
오류 1: Access-Control-Allow-Origin 헤더 없음
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.의미: 서버가 응답에 Access-Control-Allow-Origin 헤더를 포함하지 않았습니다. 가장 흔한 CORS 오류로, 서버에 CORS 설정이 전혀 없다는 의미입니다.
오류 2: 와일드카드와 자격 증명 충돌
The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'.의미: 쿠키나 Authorization 헤더(credentials: "include")를 보내고 있지만, 서버가 Access-Control-Allow-Origin: *로 응답합니다. 자격 증명이 관련된 경우 와일드카드가 아닌 정확한 출처를 지정해야 합니다.
오류 3: 사전 요청 실패
Response to preflight request doesn't pass access control check.의미: 실제 요청 전에 브라우저가 권한을 확인하는 OPTIONS 요청(프리플라이트)을 보냈습니다. 서버가 OPTIONS 메서드를 처리하지 않거나 프리플라이트 응답에 CORS 헤더를 포함하지 않았습니다.
오류 4: 메서드 허용되지 않음
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.의미: 서버의 Access-Control-Allow-Methods 헤더에 사용 중인 HTTP 메서드(PUT, DELETE, PATCH 등)가 포함되어 있지 않습니다.
오류 5: 헤더 허용되지 않음
Request header field X-Custom-Header is not allowed by Access-Control-Allow-Headers.의미: 서버가 Access-Control-Allow-Headers에서 커스텀 헤더를 명시적으로 허용하지 않았습니다.
진단 플로우차트: 문제가 어디에 있는가?
이 결정 트리를 사용하여 CORS 문제가 프론트엔드, 백엔드, 프록시 설정 중 어디에 있는지 빠르게 식별합니다:
단계 1: DevTools > Network 탭을 엽니다. 실제 요청 전에 OPTIONS 요청이 보이나요?
예 -> 프리플라이트 요청이 전송되고 있습니다. OPTIONS 응답이 상태 200이고 CORS 헤더를 포함하는지 확인하세요.
아니오 -> 단순 요청입니다. 서버 응답에 CORS 헤더가 없는 것이 문제입니다.
단계 2: 오류 메시지에 "preflight"가 언급되어 있나요?
예 -> 백엔드 문제입니다. 서버에서 OPTIONS 요청을 처리하고 CORS 헤더를 반환해야 합니다.
아니오 -> 자격 증명을 사용하고 있는지 확인하세요. 사용 중이라면 와일드카드(*)를 사용할 수 없습니다.
단계 3: API를 직접 호출(curl이나 Postman)하면 작동하나요?
예 -> CORS 문제(브라우저 전용)임이 확인됩니다.
아니오 -> CORS 문제가 아닙니다. API 자체에 문제가 있습니다.
요약: CORS 오류의 90%는 백엔드 서버에서 올바른 헤더를 설정하면 해결됩니다.
프레임워크별 서버 측 수정
Express.js (Node.js)
// Method 1: Using the cors package (recommended)
npm install cors
const express = require('express');
const cors = require('cors');
const app = express();
// Allow specific origins
app.use(cors({
origin: ['http://localhost:3000', 'https://yoursite.com'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400 // Cache preflight for 24 hours
}));
// Method 2: Manual middleware
app.use((req, res, next) => {
const allowedOrigins = ['http://localhost:3000', 'https://yoursite.com'];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Allow-Credentials', 'true');
// Handle preflight
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Max-Age', '86400');
return res.status(204).end();
}
next();
});Django (Python)
# Install django-cors-headers
pip install django-cors-headers
# settings.py
INSTALLED_APPS = [
...
'corsheaders',
...
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # Must be FIRST
'django.middleware.common.CommonMiddleware',
...
]
# Option 1: Allow specific origins
CORS_ALLOWED_ORIGINS = [
'http://localhost:3000',
'https://yoursite.com',
]
# Option 2: Allow all origins (dev only!)
# CORS_ALLOW_ALL_ORIGINS = True
# Allow credentials (cookies, auth headers)
CORS_ALLOW_CREDENTIALS = True
# Allowed headers
CORS_ALLOW_HEADERS = [
'content-type',
'authorization',
'x-requested-with',
]
# Allowed methods
CORS_ALLOW_METHODS = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
'OPTIONS',
]Flask (Python)
# Install flask-cors
pip install flask-cors
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
# Option 1: Allow specific origins
CORS(app, origins=['http://localhost:3000', 'https://yoursite.com'],
supports_credentials=True,
allow_headers=['Content-Type', 'Authorization'],
methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH'])
# Option 2: Manual approach
@app.after_request
def after_request(response):
origin = request.headers.get('Origin')
allowed = ['http://localhost:3000', 'https://yoursite.com']
if origin in allowed:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Methods'] = 'GET,POST,PUT,DELETE'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization'
response.headers['Access-Control-Allow-Credentials'] = 'true'
return responseGo (net/http)
package main
import (
"net/http"
"strings"
)
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
allowedOrigins := map[string]bool{
"http://localhost:3000": true,
"https://yoursite.com": true,
}
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods",
strings.Join([]string{"GET","POST","PUT","DELETE","PATCH"}, ","))
w.Header().Set("Access-Control-Allow-Headers",
"Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
}
// Handle preflight
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", handleData)
handler := corsMiddleware(mux)
http.ListenAndServe(":8080", handler)
}Nginx 설정
server {
listen 80;
server_name api.example.com;
# CORS headers for all locations
set $cors_origin "";
if ($http_origin ~* "^https?://(localhost:3000|yoursite\.com)$") {
set $cors_origin $http_origin;
}
location /api/ {
# CORS headers
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 86400 always;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain';
return 204;
}
proxy_pass http://backend:8080;
}
}리버스 프록시를 CORS 미들웨어로 사용
백엔드 API를 수정할 수 없는 경우(서드파티 API 등), 리버스 프록시를 사용하여 CORS 헤더를 추가합니다.
Nginx 리버스 프록시
# Nginx as CORS proxy for a third-party API
server {
listen 80;
server_name cors-proxy.yoursite.com;
location /proxy/ {
# Add CORS headers
add_header 'Access-Control-Allow-Origin' 'https://yoursite.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://yoursite.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
# Strip /proxy/ prefix and forward to third-party API
rewrite ^/proxy/(.*) /$1 break;
proxy_pass https://third-party-api.com;
proxy_set_header Host third-party-api.com;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Usage: fetch('https://cors-proxy.yoursite.com/proxy/endpoint')Apache 리버스 프록시
# Apache as CORS proxy
<VirtualHost *:80>
ServerName cors-proxy.yoursite.com
# Enable required modules
# a2enmod proxy proxy_http headers rewrite
<Location "/proxy/">
Header always set Access-Control-Allow-Origin "https://yoursite.com"
Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
Header always set Access-Control-Allow-Credentials "true"
# Handle preflight
RewriteEngine On
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=204,L]
# Proxy to backend
ProxyPass "http://backend:8080/"
ProxyPassReverse "http://backend:8080/"
</Location>
</VirtualHost>개발 환경 전용 솔루션
이 솔루션들은 로컬 개발 전용입니다. 프로덕션에서는 절대 사용하지 마세요.
Vite 프록시
Vite의 내장 프록시는 개발 서버에서 API로 요청을 전달하여, 브라우저가 동일 출처 요청만 보기 때문에 CORS를 완전히 우회합니다:
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// Proxy /api requests to backend
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
// Optional: rewrite path
// rewrite: (path) => path.replace(/^\/api/, ''),
},
// Proxy with WebSocket support
'/ws': {
target: 'ws://localhost:8080',
ws: true,
},
},
},
});
// In your code, use relative URLs:
// fetch('/api/users') instead of fetch('http://localhost:8080/api/users')Webpack devServer 프록시
Create React App 및 webpack 기반 설정도 유사한 프록시를 지원합니다:
// package.json (Create React App)
{
"proxy": "http://localhost:8080"
}
// Or for more control, create src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'http://localhost:8080',
changeOrigin: true,
})
);
};
// webpack.config.js (manual setup)
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
};Next.js 리라이트
Next.js는 rewrites를 사용하여 자체 서버를 통해 API 요청을 프록시할 수 있습니다:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
];
},
};
module.exports = nextConfig;
// In your code:
// fetch('/api/users') -- proxied to http://localhost:8080/api/users흔한 함정과 엣지 케이스
함정 1: 와일드카드(*)와 자격 증명
프론트엔드가 자격 증명을 보낼 때, 서버는 Access-Control-Allow-Origin: *를 사용할 수 없습니다. 정확한 출처를 반환해야 합니다:
잘못된 방법(자격 증명에서 실패):
// Frontend
fetch('https://api.example.com/data', {
credentials: 'include' // Sending cookies
});
// Backend response header:
Access-Control-Allow-Origin: * // FAILS! Cannot use * with credentials올바른 방법(자격 증명용 동적 출처):
// Backend (Express.js example)
app.use((req, res, next) => {
// Echo back the exact origin
const origin = req.headers.origin;
const allowedOrigins = ['http://localhost:3000', 'https://yoursite.com'];
if (allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin); // Exact origin
}
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});함정 2: OPTIONS 핸들러 누락(프리플라이트)
비단순 요청은 프리플라이트 OPTIONS 요청을 트리거합니다. 서버가 OPTIONS에 404나 405를 반환하면 실제 요청 전에 CORS가 실패합니다.
| Trigger | Example |
|---|---|
| Custom headers | Authorization, X-Request-ID, X-API-Key |
| Non-simple methods | PUT, DELETE, PATCH |
| Non-simple Content-Type | application/json, application/xml |
| ReadableStream body | Streaming request body |
// Ensure your server handles OPTIONS for all API routes:
app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
res.status(204).end();
});함정 3: 쿠키와 SameSite
크로스오리진 쿠키에는 세 가지 조건이 필요합니다:
// 1. Frontend: include credentials
fetch('https://api.example.com/data', {
credentials: 'include'
});
// 2. Backend: allow credentials + exact origin
res.setHeader('Access-Control-Allow-Origin', 'https://yoursite.com'); // NOT *
res.setHeader('Access-Control-Allow-Credentials', 'true');
// 3. Cookie must have correct attributes
Set-Cookie: session=abc123; SameSite=None; Secure; HttpOnly; Path=/
// IMPORTANT: SameSite=None requires Secure (HTTPS).
// This means cross-origin cookies do NOT work on HTTP (localhost).
// For local dev, use a proxy instead of cross-origin cookies.함정 4: 프리플라이트 응답 캐싱
프리플라이트(OPTIONS) 요청은 느릴 수 있습니다. Access-Control-Max-Age로 캐싱합니다:
// Server response header:
Access-Control-Max-Age: 86400 // Cache preflight for 24 hours
// Without this header, the browser sends a preflight OPTIONS
// request before EVERY non-simple request, adding latency.
// With caching, the browser remembers the CORS permissions
// and skips the preflight for subsequent requests.참고: Chrome 최대값은 7200초(2시간), Firefox 최대값은 86400초(24시간)입니다.
함정 5: 리다이렉트에서의 CORS
API 엔드포인트가 리다이렉트(301/302)하는 경우, CORS 헤더가 원래 응답과 리다이렉트된 응답 모두에 있어야 합니다. API 엔드포인트에서 리다이렉트를 피하세요.
// PROBLEM: API endpoint redirects
// GET https://api.example.com/users -> 301 -> https://api.example.com/v2/users
// CORS headers must be on BOTH responses!
// SOLUTION: Avoid redirects in API endpoints
// Option 1: Update the frontend URL
fetch('https://api.example.com/v2/users'); // Use final URL directly
// Option 2: Make the redirect include CORS headers
// (requires server configuration on both the old and new endpoint)빠른 참조: CORS 헤더
| 헤더 | 목적 | 값 예시 |
|---|---|---|
| Access-Control-Allow-Origin | Which origins can access the resource | https://yoursite.com or * |
| Access-Control-Allow-Methods | Which HTTP methods are allowed | GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers | Which request headers are allowed | Content-Type, Authorization |
| Access-Control-Allow-Credentials | Whether cookies/auth can be sent | true |
| Access-Control-Max-Age | How long to cache preflight (seconds) | 86400 |
| Access-Control-Expose-Headers | Which response headers JS can read | X-Total-Count, X-Request-ID |
자주 묻는 질문
브라우저에서 CORS를 비활성화할 수 있나요?
기술적으로 가능합니다(Chrome을 --disable-web-security 플래그로 실행). 하지만 매우 위험하며 빠른 디버깅 이외에는 사용해서는 안 됩니다. 개발 환경에서는 프록시(Vite, webpack)를 사용하세요.
Postman에서는 API가 작동하는데 브라우저에서는 안 되는 이유는?
CORS는 브라우저 전용 보안 메커니즘입니다. Postman, curl, 서버 간 요청은 CORS를 적용하지 않습니다. 브라우저는 사용자를 보호하기 위해 CORS 헤더를 확인합니다.
CORS는 프론트엔드 수정인가요 백엔드 수정인가요?
거의 항상 백엔드 수정입니다. 서버가 올바른 CORS 응답 헤더를 보내야 합니다. 프론트엔드는 CORS를 우회할 수 없습니다. 유일한 해결책은 프록시 사용입니다.
프로덕션에서 Access-Control-Allow-Origin: *를 사용해야 하나요?
쿠키나 인증을 사용하지 않는 완전히 공개된 API에만 해당됩니다. 자격 증명이 필요한 API는 정확한 출처를 지정해야 합니다.
프리플라이트 요청이란 무엇이며 왜 발생하나요?
프리플라이트 요청은 브라우저가 실제 요청 전에 자동으로 보내는 OPTIONS 요청입니다. 커스텀 헤더, 비단순 HTTP 메서드, 특수 Content-Type을 사용할 때 발생합니다.
WebSocket 연결의 CORS를 어떻게 수정하나요?
WebSocket 연결은 CORS 규칙을 따르지 않습니다. 초기 HTTP 업그레이드 핸드셰이크에 Origin 헤더가 포함되지만 서버가 수동으로 검증해야 합니다. CORS 오류가 나타나면 보통 초기 HTTP 핸드셰이크 엔드포인트의 문제입니다.