웹 개발을 진행하다 보면 한 번쯤 브라우저 개발자 도구 콘솔에 붉은색 메시지로 뜨는 'Access to XMLHttpRequest at... blocked by CORS policy' 에러를 마주해본 경험이 있을 것이다. 다만 정확한 발생 원인이나 브라우저 보안 메커니즘을 이해하지 못한 채 백엔드 개발자에게 와일드카드(*) 설정을 요청하거나 프론트엔드 개발 서버 프록시만 대충 수정하고 넘어가는 경우가 많다. 이번 글에서는 CORS 정책이 정확히 무엇인지, 브라우저가 왜 이를 강제하는지, 그리고 백엔드 언어별 올바른 헤더 설정과 프론트엔드 해결책을 완벽하게 정리해서 소개하겠다.
CORS(Cross-Origin Resource Sharing)를 이해하려면 먼저 브라우저의 기본 보안 정책인 동일 출처 정책(SOP, Same-Origin Policy)을 알아야 한다. SOP는 어떤 출처(Origin)에서 불러온 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 것을 제한하는 브라우저 보안 정책이다.
출처(Origin)는 프로토콜(Protocol), 도메인(Host), 포트번호(Port) 세 가지 조합으로 결정된다. 이 중 하나라도 다르면 브라우저는 다른 출처(Cross-Origin)로 판단한다.
| 비교 항목 | 요청 URL | 기준 URL (https://example.com:443) | 동일 출처 여부 |
|---|---|---|---|
| 프로토콜 변경 | http://example.com | https vs http | X (다른 출처) |
| 도메인 변경 | https://api.example.com | example.com vs api.example.com | X (다른 출처) |
| 포트 변경 | https://example.com:8080 | 443 vs 8080 | X (다른 출처) |
| 경로 변경 | https://example.com/v1/users | 동일함 | O (동일 출처) |
SOP 정책에 따라 교차 출처 요청은 차단되는 것이 기본이다. 하지만 웹 애플리케이션 서비스가 고도화되면서 API 서버와 웹 프론트엔드 서버를 분리하는 구조가 일반화되었다. 이에 따라 서버 측에서 허용한 출처에 한해 접근을 허용하는 표준 메커니즘인 CORS가 도입되었다.
CORS 에러는 백엔드 서버가 다운되었거나 응답을 보내지 못해서 발생하는 서버 에러가 아니다. 서버는 정상적으로 200 OK 응답을 전달했더라도, 브라우저가 응답 헤더를 검사했을 때 허용 출처 정보(Access-Control-Allow-Origin)가 없거나 요청 출처와 다르면 브라우저 자체에서 해당 데이터를 차단하고 콘솔에 에러를 출력하는 것이다.
브라우저는 교차 출처 요청 시 크게 두 가지 방식으로 동작한다.
- 단순 요청(Simple Request): GET, POST, HEAD 메서드이면서 인증 헤더나 커스텀 헤더가 없는 경우. 본 요청을 바로 보낸 뒤 응답 헤더를 검사한다.
- 예비 요청(Preflight Request): PUT, DELETE, PATCH 메서드를 사용하거나 authorization 등 커스텀 헤더를 포함하는 경우. 본 요청을 보내기 전 OPTIONS 메서드로 사전 문의 요청을 보낸다.
CORS 문제의 근본적인 해결책은 요청을 받는 백엔드 API 서버에서 올바른 HTTP 응답 헤더를 설정해 보내주는 것이다.
✗ 잘못된 코드(와일드카드 남용 및 인증정보 차단):
// 출처 구분 없이 모두 허용하면 보안상 취약하며, 쿠키 전송 시 브라우저에서 거부됨
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST");
✓ 올바른 코드(화이트리스트 기반 동적 출처 허용):
<?php
$allowed_origins = [
'https://example.com',
'https://admin.example.com',
'http://localhost:3000'
];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, $allowed_origins, true)) {
header("Access-Control-Allow-Origin: " . $origin);
header("Access-Control-Allow-Credentials: true");
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With");
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
?>
✗ 잘못된 코드(모든 도메인 무조건 허용):
const express = require('express');
const cors = require('cors');
const app = express();
// 무조건 모든 요청 허용 (실무 운영 환경에서 보안상 위험)
app.use(cors());
✓ 올바른 코드(도메인 검증 및 옵션 설정):
const express = require('express');
const cors = require('cors');
const app = express();
const whitelist = ['https://example.com', 'http://localhost:3000'];
const corsOptions = {
origin: (origin, callback) => {
if (!origin || whitelist.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('CORS 정책에 의해 차단된 출처입니다.'));
}
},
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
};
app.use(cors(corsOptions));
웹 서버 차원에서 CORS 헤더를 일괄 적용하려면 Nginx 설정파일에 다음과 같이 추가한다.
# Nginx server 블록 내부 설정
location /api/ {
if ($http_origin ~* (https://example.com|http://localhost:3000)) {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
}
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
proxy_pass http://backend_upstream;
}
CORS 설정 중 개발자들이 가장 많이 범하는 실수는 와일드카드(*)와 인증 정보(Credentials) 옵션의 충돌이다.
✗ 잘못된 조합:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
브라우저 보안 표준에 의해 Access-Control-Allow-Credentials가 true로 설정된 경우 와일드카드(*) 출처 사용은 금지된다. 이 두 설정을 동시에 전달하면 브라우저는 요청을 즉시 차단한다.
✓ 올바른 조합:
Access-Control-Allow-Origin: https://example.com
Access-Control-Allow-Credentials: true
쿠키나 Authorization 헤더를 통한 인증 기반 통신이 필요하다면, 반드시 요청 들어온 Origin 헤더 값을 읽어 허용된 목록인지 검증한 뒤 해당 명확한 Origin 문자열을 그대로 반환해야 한다.
로컬 개발 단계에서 백엔드 API 서버의 CORS 설정을 변경하기 힘든 경우, 프론트엔드 개발 서버(Webpack Dev Server, Vite, Next.js 등)의 리버스 프록시 기능을 활용하면 CORS 에러를 간단히 우회할 수 있다.
브라우저는 개발 서버(http://localhost:3000)로 요청을 보내므로 동일 출처로 인식되며, 실제 외부 API 통신은 Node.js 기반 개발 서버가 백엔드로 대신 수행(서버 대 서버 통신에는 CORS가 적용되지 않음)하기 때문이다.
Access-Control-Allow-Origin 에러는 웹 보안의 기초이자 프론트엔드와 백엔드가 안전하게 통신하기 위한 필수 관문이다. 무작정 와일드카드(*)를 허용하는 나쁜 습관을 버리고, 올바른 보안 헤더와 출처 검증 체계를 갖추는 것이 모여서 안전한 웹 서비스를 만든다는 점을 잊지 말자. 이 글의 헤더 설정 예제와 프록시 가이드를 참고해 개발 환경 및 운영 환경을 정비하면, CORS 에러 없는 쾌적한 개발 환경을 얻을 수 있을 것이다.