한 줄 요약: CORS는 브라우저가 다른 출처의 리소스 요청을 제한하는 보안 정책이다. 서버가 올바른 헤더를 응답에 포함해야만 브라우저가 응답을 허용한다. 개발하다 보면 반드시 한 번은 마주치는 에러가 있다. Access to XMLHttpRequest at 'http://api.example.com' from origin 'http://localhost:3000' has been blocked by CORS policy . 이 에러가 뜨는 이유가 뭔지, 어디서 무엇을 고쳐야 하는지 정확...
한 줄 요약: CORS는 브라우저가 다른 출처의 리소스 요청을 제한하는 보안 정책이다. 서버가 올바른 헤더를 응답에 포함해야만 브라우저가 응답을 허용한다.
개발하다 보면 반드시 한 번은 마주치는 에러가 있다. Access to XMLHttpRequest at 'http://api.example.com' from origin 'http://localhost:3000' has been blocked by CORS policy. 이 에러가 뜨는 이유가 뭔지, 어디서 무엇을 고쳐야 하는지 정확히 이해하면 다음번엔 30초 안에 해결할 수 있다.
Same-Origin Policy — CORS가 존재하는 이유
브라우저는 기본적으로 Same-Origin Policy(동일 출처 정책)를 적용한다. 'Origin'은 프로토콜 + 호스트 + 포트의 조합이다. 이 세 가지 중 하나라도 다르면 '다른 출처'로 판단한다.
비교 URL
기준 URL: http://example.com
동일 출처?
http://example.com/api
경로만 다름
동일
https://example.com
프로토콜 다름
다름
http://api.example.com
서브도메인 다름
다름
http://example.com:8080
포트 다름
다름
http://other.com
호스트 다름
다름
Same-Origin Policy가 없으면 악성 사이트가 사용자 브라우저를 통해 로그인된 다른 사이트에 요청을 보내고 응답을 읽을 수 있다. CORS는 이 정책의 예외를 서버가 명시적으로 허용하는 메커니즘이다.
핵심 포인트: CORS는 서버가 아니라 브라우저가 실행하는 정책이다. curl이나 Postman에서는 CORS 에러가 없다. 브라우저에서만 발생한다.
CORS 완벽 이해 — 왜 차단되고 어떻게 해결하나 — 보안 아키텍처 다이어그램 (출처: 공식 문서 및 벤치마크 데이터 기반)
CORS 헤더 — 서버가 응답에 무엇을 넣어야 하나
서버가 교차 출처 요청을 허용하려면 응답 헤더에 CORS 관련 헤더를 포함해야 한다. 주요 헤더와 역할은 다음과 같다.
헤더
설명
예시 값
Access-Control-Allow-Origin
허용할 출처
https://example.com 또는 *
Access-Control-Allow-Methods
허용할 HTTP 메서드
GET, POST, PUT, DELETE
Access-Control-Allow-Headers
허용할 요청 헤더
Content-Type, Authorization
Access-Control-Allow-Credentials
쿠키/인증 포함 허용
true
Access-Control-Max-Age
Preflight 결과 캐시 시간(초)
86400
Access-Control-Expose-Headers
JS에서 접근 허용할 응답 헤더
X-Total-Count
CORS 완벽 이해 — 왜 차단되고 어떻게 해결하나 — 위협 모델 시각화 (출처: 공식 문서 및 벤치마크 데이터 기반)
Preflight 요청 — OPTIONS 메서드의 정체
단순 요청(GET, POST, HEAD + 기본 헤더만 사용)이 아닌 경우, 브라우저는 실제 요청 전에 OPTIONS 메서드로 Preflight(사전 확인) 요청을 먼저 보낸다. 서버가 이 요청에 올바른 CORS 헤더로 응답해야 실제 요청이 전송된다.
Preflight가 발생하는 조건
PUT, DELETE, PATCH 메서드 사용
Content-Type: application/json 같은 비표준 Content-Type 사용
Authorization 헤더 포함
커스텀 헤더 사용 (예: X-Custom-Header)
Preflight 요청/응답 흐름 예시
// 브라우저가 먼저 보내는 Preflight 요청
OPTIONS /api/data HTTP/1.1
Origin: https://frontend.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
// 서버가 응답해야 하는 내용
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
// Preflight 통과 후 실제 요청이 전송됨
POST /api/data HTTP/1.1
Origin: https://frontend.example.com
Content-Type: application/json
Authorization: Bearer eyJ...
실전 설정 — Express와 Nginx에서 CORS 처리
CORS 설정은 두 레벨에서 가능하다. 애플리케이션 레벨(Node.js/Express)이 더 유연하고, Nginx 레벨은 여러 백엔드를 동시에 처리할 때 유리하다. 둘 다 설정하면 헤더가 중복될 수 있으니 한 레벨에서만 처리하는 것을 권장한다.
CORS 완벽 이해 — 왜 차단되고 어떻게 해결하나 — 취약점 분석 플로우차트 (출처: 공식 문서 및 벤치마크 데이터 기반)
server {
listen 443 ssl;
server_name api.example.com;
location /api/ {
# Preflight OPTIONS 요청 처리
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Content-Length' 0;
return 204;
}
# 실제 요청에 CORS 헤더 추가
add_header 'Access-Control-Allow-Origin' 'https://example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
proxy_pass http://backend:3000;
}
}
막히는 케이스 — CORS 에러 원인별 해결
CORS 에러는 유형이 몇 가지로 나뉜다. 에러 메시지를 정확히 읽으면 원인을 찾을 수 있다.
케이스 1 — credentials 포함 요청에서 * 사용 시 에러
현상: credentials: 'include'로 요청하면 CORS 에러 발생. 원인: Access-Control-Allow-Origin: *는 credentials와 함께 쓸 수 없다는 브라우저 규칙. 해결: Access-Control-Allow-Origin을 구체적인 출처로 지정하고, Access-Control-Allow-Credentials: true를 함께 설정한다.
케이스 2 — Preflight는 통과하지만 실제 요청이 막힘
현상: 네트워크 탭에서 OPTIONS 요청은 200인데 실제 요청은 CORS 에러. 원인: Preflight 응답에는 CORS 헤더가 있지만, 실제 응답에 Access-Control-Allow-Origin 헤더가 없는 경우. Nginx의 add_header는 기본적으로 2xx/3xx에만 적용된다. 해결: Nginx에서 add_header ... always를 사용하거나, 애플리케이션 레벨에서 모든 응답에 헤더를 추가한다.
케이스 3 — 개발 환경에서만 CORS 우회 프록시 사용
현상: 로컬에서는 프록시로 잘 되는데 프로덕션 배포 후 CORS 에러 발생. 원인: Create React App / Vite의 개발 서버 프록시 기능은 로컬에서만 동작한다. 프로덕션에서는 실제 CORS 헤더가 필요하다. 해결: 프로덕션 서버에서 올바른 CORS 헤더를 설정하거나, 같은 출처의 API Gateway를 통해 프록시한다.
자주 묻는 질문
실무에서 처음 도입할 때 가장 먼저 확인할 것은 무엇인가요?
CORS 헤더를 애플리케이션 레벨(Express)과 Nginx 중 어느 한 곳에서만 처리하도록 정하는 것이 먼저입니다. 두 레벨에 동시에 설정하면 Access-Control-Allow-Origin 헤더가 중복으로 붙어 브라우저가 거부합니다. 그다음 확인할 것은 쿠키나 인증 헤더를 함께 보내는지 여부입니다. credentials를 쓴다면 Allow-Origin에 와일드카드(*)를 못 쓰고 반드시 구체적인 출처를 지정한 뒤 Access-Control-Allow-Credentials: true를 같이 줘야 합니다. 마지막으로 PUT·DELETE나 Authorization 헤더, application/json을 쓴다면 브라우저가 OPTIONS Preflight를 먼저 보내므로 서버가 그 요청에도 헤더를 응답하도록 준비됐는지 봐야 합니다.
가장 자주 발생하는 실수나 함정은 무엇인가요?
Preflight(OPTIONS)는 200으로 통과하는데 실제 요청만 막히는 상황이 가장 헷갈리는 함정입니다. 원인은 대개 Nginx의 add_header가 기본적으로 2xx·3xx 응답에만 붙어, 실제 응답에는 Access-Control-Allow-Origin이 빠지기 때문입니다. add_header에 always를 붙이면 해결됩니다. 또 자주 보는 실수는 credentials: include 요청에 Allow-Origin을 *로 둔 경우로, 브라우저 규칙상 둘은 같이 못 쓰니 구체 출처로 바꿔야 합니다. 그리고 로컬에서 Create React App·Vite 개발 서버 프록시로 잘 되던 것이 프로덕션에서 깨지는 일도 흔한데, 그 프록시는 개발 환경에서만 동작하므로 배포 서버에는 별도로 진짜 CORS 헤더를 설정해야 합니다.
다른 대안과 비교했을 때 어떤 상황에 적합한가요?
CORS 헤더를 어디서 처리할지는 구조에 따라 갈립니다. 출처별 분기, credentials 조건부 허용처럼 세밀한 제어가 필요하면 애플리케이션 레벨(Express의 cors 패키지)이 유연해서 적합합니다. 반면 여러 백엔드 서비스를 한 게이트웨이 뒤에 묶고 공통 정책을 한 곳에서 거는 상황이라면 Nginx 레벨이 유리합니다. 다만 두 레벨에 동시에 설정하면 Allow-Origin 헤더가 중복돼 브라우저가 거부하니 반드시 한 곳에서만 처리하세요. 그리고 같은 출처로 묶을 수 있는 구조(프런트와 API를 한 도메인 아래 리버스 프록시로 합치는 방식)라면 애초에 CORS 자체가 필요 없으므로, 교차 출처가 꼭 필요한 게 아니라면 동일 출처 구성이 가장 단순한 대안입니다.
더 깊게 공부하려면 어떤 자료를 보면 좋을까요?
가장 정확한 1차 자료는 MDN의 CORS 문서(developer.mozilla.org)입니다. 단순 요청과 Preflight를 가르는 정확한 조건, 각 Access-Control-* 헤더의 의미가 표로 정리돼 있어 본문에서 다룬 케이스들의 근거를 그대로 확인할 수 있습니다. Preflight가 왜·언제 발생하는지 헷갈린다면 MDN의 Preflight request 항목을 따로 읽으세요. 한 발 더 들어가려면 Same-Origin Policy와 SameSite 쿠키 속성을 함께 보는 것을 권합니다. CORS와 쿠키 credentials, CSRF 방어가 어떻게 맞물리는지 이해해야 인증이 걸린 교차 출처 요청을 안전하게 구성할 수 있습니다.
CORS 완벽 이해, 한 줄로 정리하면 어떻게 되나요?
CORS는 브라우저가 프로토콜·호스트·포트가 다른 출처의 응답 읽기를 막는 정책이고, 서버가 응답에 Access-Control-Allow-Origin 같은 헤더를 넣어 명시적으로 허용해야 풀립니다. 핵심은 이게 서버가 아니라 브라우저가 강제하는 규칙이라는 점입니다. 그래서 curl이나 Postman에서는 에러가 안 나고 브라우저에서만 막힙니다. PUT·DELETE나 Authorization 헤더, application/json을 쓰면 브라우저가 OPTIONS Preflight를 먼저 보내므로, 서버가 그 요청에도 올바른 헤더를 응답하도록 설정하는 것이 해결의 본질입니다.