TechFeedTechFeed
Security

CORS 완벽 이해 — 왜 차단되고 어떻게 해결하나

한 줄 요약: CORS는 브라우저가 다른 출처의 리소스 요청을 제한하는 보안 정책이다. 서버가 올바른 헤더를 응답에 포함해야만 브라우저가 응답을 허용한다. 개발하다 보면 반드시 한 번은 마주치는 에러가 있다. Access to XMLHttpRequest at 'http://api.example.com' from origin 'http://localhost:3000' has been blocked by CORS policy . 이 에러가 뜨는 이유가 뭔지, 어디서 무엇을 고쳐야 하는지 정확...

by

한 줄 요약: 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 에러가 없다. 브라우저에서만 발생한다.


Same-Origin Policy — 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-AgePreflight 결과 캐시 시간(초)86400
Access-Control-Expose-HeadersJS에서 접근 허용할 응답 헤더X-Total-Count

CORS 헤더 — 서버가 응답에 무엇을 넣어야 하나 — 위협 모델 시각화
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 레벨은 여러 백엔드를 동시에 처리할 때 유리하다. 둘 다 설정하면 헤더가 중복될 수 있으니 한 레벨에서만 처리하는 것을 권장한다.


Preflight 요청 — OPTIONS 메서드의 정체 — 취약점 분석 플로우차트
CORS 완벽 이해 — 왜 차단되고 어떻게 해결하나 — 취약점 분석 플로우차트 (출처: 공식 문서 및 벤치마크 데이터 기반)
Express — cors 패키지 사용 (환경별 출처 제어)
const cors = require('cors'); const allowedOrigins = [ 'https://example.com', 'https://www.example.com', ...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []) ]; const corsOptions = { origin: (origin, callback) => { // origin이 없으면 서버-서버 요청 (Postman 포함) — 허용 if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('CORS policy violation')); } }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, // 쿠키/인증 헤더 허용 시 maxAge: 86400 // Preflight 캐시 24시간 }; app.use(cors(corsOptions)); // OPTIONS Preflight 처리 app.options('*', cors(corsOptions));
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를 먼저 보내므로, 서버가 그 요청에도 올바른 헤더를 응답하도록 설정하는 것이 해결의 본질입니다.


CORS보안웹개발ExpressNginx

함께 보면 좋은 문제 해결

관련 포스트