노트

CORS

Cross-Origin Resource Sharing

프런트엔드#http#security · 연결된 개념 13개

쉽게 말하면

CORS는 다른 출처 서버가 보낸 답장을 웹 페이지가 읽어도 되는지, 그 서버가 허락 도장을 찍어 주는 규칙이에요. 도장을 보고 답장을 넘길지 막을지는 브라우저가 정해서 curl에선 이런 막힘이 없어요.

비유가 깨지는 곳 도장이 없다고 요청이 안 간 건 아니에요. 서버에서 처리까지 됐는데 JS만 응답을 못 읽는 거고, nginx가 만든 502에도 헤더가 없어서 백엔드 장애가 CORS 오류처럼 보이기도 해요.

브라우저가 다른 출처(scheme·host·port)로 보낸 요청의 응답을 읽어도 되는지, 서버가 Access-Control-Allow-* 헤더로 허락하는 규칙. 막는 주체가 브라우저라서 curl이나 서버끼리의 호출은 통과한다.

  • 단순 요청(Simple Request)이 아니면(예: Content-Type: application/json, Authorization 헤더) 먼저 OPTIONS 사전 요청(Preflight Request)을 보낸다
  • 쿠키를 보내려면 프런트는 credentials: 'include', 서버는 Access-Control-Allow-Credentials: true와 구체적인 출처가 필요하다(* 불가) → 세션 인증
  • 스프링에서는 @CrossOrigin이나 WebMvcConfigurer.addCorsMappings로 설정하고, Security를 쓰면 필터체인 설정과 함께 본다

기본은 막고, 서버가 예외를 연다

CORS는 동일 출처 정책을 없애는 장치가 아니라 그 위에 얹는 예외 규칙이다. 허용할지는 서버가 응답 헤더로 정하고, 그 헤더를 보고 응답을 넘겨줄지 막을지는 브라우저가 집행한다. 서버가 헤더를 안 보내면 요청은 서버에 도착해 처리까지 됐더라도 JS(JavaScript)는 응답을 읽지 못한다. 서버 간 호출에 CORS가 없는 이유는 브라우저 요청과 서버 간 요청 참고.

단순 요청과 preflight

아래를 모두 만족하면 preflight 없이 바로 보낸다(단순 요청).

  • 메서드가 GET, HEAD, POST 중 하나
  • 직접 넣은 헤더가 Accept, Accept-Language, Content-Language, Content-Type 정도뿐
  • Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나

하나라도 벗어나면(PUT·DELETE·PATCH, JSON 본문, Authorization이나 커스텀 헤더) 먼저 OPTIONS로 묻는다. credentials: 'include'만으로는 preflight가 생기지 않는다.

sequenceDiagram
  participant B as 브라우저
  participant S as 서버
  Note over B,S: 단순 요청 (GET, 폼 POST)
  B->>S: GET /users + Origin
  S-->>B: 200 + Access-Control-Allow-Origin
  Note over B,S: 그 밖의 요청 (DELETE, JSON 본문, Authorization)
  B->>S: OPTIONS /users/1 (preflight)
  S-->>B: 204 + Allow-Origin · Methods · Headers · Max-Age
  B->>S: DELETE /users/1 + Origin
  S-->>B: 200 + Access-Control-Allow-Origin
  Note over B: 헤더가 맞을 때만 JS에 응답을 넘긴다
OPTIONS /users/1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization
 
→ Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Methods: GET, POST, DELETE
  Access-Control-Allow-Headers: authorization
  Access-Control-Max-Age: 86400

Access-Control-Max-Age로 preflight 결과를 캐시해 매 요청마다 묻지 않게 한다.

자주 보는 에러

콘솔 메시지(요지)원인
No 'Access-Control-Allow-Origin' header서버가 CORS 헤더를 안 보냄(에러 응답에서 빠진 경우도 많다)
credentials flag is true, but Allow-Credentials is not 'true'서버에 Allow-Credentials: true가 없음
Cannot use wildcard when credentials flag is true쿠키를 보내면서 Allow-Origin: *를 씀

CORS 오류로 위장한 502

CORS 헤더는 보통 애플리케이션(미들웨어)이 정상 응답을 만들 때 붙인다. 그런데 앞단의 nginx나 로드밸런서가 스스로 만든 오류 응답(업스트림이 죽었을 때의 502, 타임아웃의 504, 대상이 없을 때의 503)에는 그 헤더가 없다. 브라우저는 헤더 없는 교차 출처 응답을 JS에 넘기지 않고 콘솔에 "No 'Access-Control-Allow-Origin' header" 오류를 찍는다. 실제 원인은 백엔드 순단인데 CORS 설정이 깨진 것처럼 보이는 것이다.

  • 확인: DevTools 네트워크 탭에서 그 요청의 상태 코드를 먼저 본다. 4xx·5xx이면 CORS가 아니라 그 오류부터 쫓는다. curl로 같은 요청을 보내 헤더를 비교해도 된다
  • 오류가 배포 시각에 몰려 있다면 재시작 중인 서버로 요청이 간 것일 수 있다 → 커넥션 드레이닝과 무중단 재시작
  • 프록시 오류 응답에도 CORS 헤더가 붙게 하려면 nginx에서 add_header ... always처럼 오류 응답에도 헤더를 붙이는 설정이 필요하다. 다만 원인을 가릴 뿐 고치지는 않는다

허용 출처는 와일드카드보다 목록으로 검사하고, 요청의 Origin을 그대로 되돌려주는 설정은 피한다 → Origin·Referer 헤더와 Referrer-Policy. 로컬 개발에서 허용 목록에 들려고 도메인을 맞추는 방법은 hosts 파일.

스프링 예시

@RestController
@CrossOrigin(origins = "https://app.example.com", allowCredentials = "true")
class UserController { ... }

전역으로는 addCorsMappings에 같은 내용을 적는다. Spring Security를 쓰면 preflight가 인증 필터에 막히지 않도록 http.cors(Customizer.withDefaults())로 시큐리티 체인에도 CORS를 켜야 한다(인자 없는 http.cors()는 Spring Security 6.1에서 폐기 예정으로 표시됐다).

책: 5장. 제대로 된 REST API

출처: MDN — Cross-Origin Resource Sharing (CORS) · MDN — CORS: Simple requests · Spring Framework — CORS · nginx — add_header

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • XSS

    XSS(Cross-Site Scripting)는 공격자가 넣은 스크립트가 내 사이트의 출처(origin) 권한으로 사용자 브라우저에서 실행되는 공격이다. 같은 출처의 코드로 돌기 때문에 same-origin-policy가 막아 주지 못한다. 그 스크립트는 페이지를 바꾸고, 로그인한 사용자 행세를 하며 요청을 보내고, JS가 읽을 수 있는 데이터(localStorage의 토큰 등)를 빼 갈 수 있다.

  • 인증과 인가

    인증(authentication)은 "너는 누구냐"를 확인하는 것이고, 인가(authorization)는 "너는 이걸 해도 되냐"를 판단하는 것이다. 인증이 먼저고 인가가 그다음이다.

  • 크로스 브라우징

    크로스 브라우징은 브라우저 종류·버전·기기가 달라도 웹 페이지가 같은 기능을 제공하도록 만드는 일이다. 모든 브라우저에서 픽셀까지 똑같이 보이게 하는 것이 목표가 아니라, 지원하기로 한 환경에서 핵심 기능이 동작하고 오래된 환경에서도 쓸 수는 있게(점진적 향상, Progressive Enhancement) 하는 것이 목표다.

  • 스프링에서 외부 API 호출

    스프링 앱에서 다른 서버의 REST API를 부르는 도구는 여러 세대가 있다. 지금 새 코드라면 동기 호출은 RestClient, 리액티브·비동기는 WebClient, 인터페이스 선언 방식은 HTTP Interface나 OpenFeign이 흔한 선택이다(2026 기준).

보기 옵션