이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • CORS가 서버 간 통신 문제가 아니라 브라우저 보안 정책이라는 말을 어떻게 증명하는가?
  • preflight 요청은 언제 발생하고 백엔드 필터/게이트웨이에서 왜 자주 막히는가?
  • credential 포함 CORS를 안전하게 허용하려면 어떤 header 조합이 필요한가?

개요

CORS는 브라우저가 cross-origin 요청의 응답을 JavaScript에 노출할지 판단하는 정책이다. 서버는 Access-Control-* 응답 header로 허용 범위를 알려 준다. 같은 API가 curl이나 서버 간 호출에서는 성공하지만 브라우저 fetch에서는 실패하는 대표 이유가 CORS다.

  • Origin은 scheme, host, port 조합이다.
  • 단순 요청이 아니면 브라우저는 실제 요청 전에 OPTIONS preflight를 보낸다.
  • credential 요청은 credentials: "include"Access-Control-Allow-Credentials: true가 함께 필요하다.
  • credential 요청에서는 Access-Control-Allow-Origin: *를 사용할 수 없다.

원리

브라우저 요청 흐름은 다음처럼 나뉜다.

Frontend https://app.example.com
  -> OPTIONS /api/orders
     Origin: https://app.example.com
     Access-Control-Request-Method: POST
     Access-Control-Request-Headers: content-type,x-csrf-token
 
API response
  -> Access-Control-Allow-Origin: https://app.example.com
  -> Access-Control-Allow-Methods: POST
  -> Access-Control-Allow-Headers: content-type,x-csrf-token
  -> Access-Control-Allow-Credentials: true
 
Browser sends actual POST only if preflight passes

preflight가 실패하면 실제 POST는 가지 않는다. 그래서 애플리케이션 비즈니스 로그가 비어 있을 수 있다.

Preflight가 발생하는 조건

대표적으로 다음 조건이면 preflight가 발생한다.

  • PUT, PATCH, DELETE 같은 method 사용
  • Content-Type: application/json
  • 커스텀 header 사용
  • 인증 header 사용

백엔드 개발자는 “왜 OPTIONS가 오지?”가 아니라 “이 요청은 preflight가 정상”이라고 봐야 한다. 오히려 OPTIONS를 인증 필터가 막거나 gateway가 라우팅하지 않는 것이 문제다.

Credential CORS Header 조합

credential 포함 요청의 기본 조합은 다음과 같다.

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin

동적으로 origin을 반사한다면 반드시 allowlist 검증 후 반사해야 한다. 아무 origin이나 그대로 돌려주는 것은 사실상 브라우저 보안 경계를 없애는 일이다. Vary: Origin은 cache가 origin별 응답을 섞지 않도록 하는 데 중요하다.

장애 유형별 증거

증상더 가까운 원인확인할 증거
curl 성공, 브라우저 실패CORS 정책 차단DevTools CORS error, response header
실제 POST 로그 없음preflight 실패OPTIONS status/header, gateway log
cookie가 안 감credential 조합 불일치SameSite, Secure, Allow-Credentials
401/403 body가 JS에 안 보임오류 응답에 CORS header 없음error response header
특정 origin만 실패allowlist/cache key 문제Origin, Vary: Origin, CDN HIT

이 표를 쓰면 “CORS 에러”를 브라우저 차단, preflight routing, cookie 정책, gateway/cache 문제로 나눌 수 있다.

Spring에서의 설정 예시

Spring MVC CORS 설정 예시다.

@Configuration
class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://app.example.com")
            .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
            .allowedHeaders("Content-Type", "X-CSRF-TOKEN", "X-Request-ID")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

Spring Security를 쓰면 CORS 처리가 보안 필터보다 먼저 적용되도록 함께 설정해야 한다.

http
    .cors(Customizer.withDefaults())
    .csrf(Customizer.withDefaults());

코드와 명령으로 확인하기

브라우저 fetch 예시다.

await fetch("https://api.example.com/api/orders", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "X-CSRF-TOKEN": csrfToken
  },
  body: JSON.stringify({ productId: "p-1" })
});

preflight를 CLI로 재현한다.

curl -i -X OPTIONS https://api.example.com/api/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,x-csrf-token'

실제 요청도 Origin과 cookie를 넣어 비교한다.

curl -i https://api.example.com/api/orders \
  -H 'Origin: https://app.example.com' \
  -H 'Content-Type: application/json' \
  -b 'sid=test' \
  --data '{"productId":"p-1"}'

CLI는 브라우저처럼 CORS를 차단하지 않는다. 응답 header가 올바른지 확인하는 도구로 쓴다.

인프라 협업 포인트

  • gateway와 WAF가 OPTIONS 요청을 app까지 전달하거나 edge에서 일관된 CORS 응답을 내려야 한다.
  • CDN cache를 쓰면 origin별 CORS 응답이 섞이지 않도록 Vary: Origin을 확인한다.
  • API gateway에서 CORS를 처리할지 app에서 처리할지 소유권을 정한다. 양쪽에서 다르게 설정하면 장애가 길어진다.
  • 인프라 팀에 전달할 때는 preflight request/response header와 실제 request/response header를 나눠 제공한다.

주니어가 자주 하는 오해

  • CORS를 서버 간 통신 문제로 생각하고 backend-to-backend 호출에서도 같은 에러가 난다고 착각한다.
  • Access-Control-Allow-Origin: *를 넣으면 모든 인증 요청이 해결된다고 생각한다.
  • OPTIONS 요청을 이상한 공격 트래픽으로 보고 인증 필터나 WAF에서 막는다.
  • 콘솔의 CORS 에러만 보고 실제 status code, redirect, cookie rejected reason을 보지 않는다.

시니어의 설계 판단 기준

  • CORS 소유권을 gateway, application, CDN 중 한 곳으로 명확히 둔다.
  • credential CORS는 origin allowlist, cookie 정책, CSRF 정책을 한 세트로 검토한다.
  • preview 도메인, 고객별 subdomain, localhost를 운영 allowlist에 어떻게 넣을지 별도 정책으로 둔다.
  • 기업 운영에서는 WAF, CDN cache, API gateway의 CORS 응답 생성 위치와 로그 위치를 문서화한다.

실전 팁

  • CORS allowlist는 배포 환경별로 관리한다. local, dev, staging, production origin이 섞이면 운영에서 과허용이 된다.
  • wildcard subdomain 허용은 신중하게 쓴다. preview 도메인이나 사용자 생성 서브도메인이 인증 API를 호출할 수 있게 될 수 있다.
  • preflight cache(Access-Control-Max-Age)는 장애를 숨길 수 있다. 설정 변경 검증 때는 브라우저 캐시와 preflight cache를 고려한다.
  • 401/403 응답에도 CORS header가 있어야 브라우저가 프론트 코드에 오류 본문을 노출할 수 있다.
  • CORS 오류가 콘솔에 보이더라도 실제 원인은 redirect일 수 있다. preflight가 301/302를 만나는지 확인한다.
  • 개인 프로젝트에서는 production origin만 credential CORS로 허용하고, localhost는 개발 profile에서만 열어 두는 식으로 분리한다.

위험 신호!

  • credential 요청에 Access-Control-Allow-Origin: *를 넣는다.
  • 허용 origin을 정규식으로 넓게 열고 소유하지 않은 하위 도메인을 포함한다.
  • OPTIONS 요청이 인증 미들웨어에서 401로 막힌다.
  • CORS 설정이 gateway와 application에 중복되어 서로 다른 header를 낸다.

확인 질문

확인 질문

  • preflight가 실패하면 왜 실제 API 로그가 없을 수 있는가?
    • 브라우저가 실제 요청을 보내기 전에 OPTIONS 응답으로 허용 여부를 판단하고 실패하면 본 요청을 보내지 않기 때문이다.
  • credential CORS에서 wildcard origin이 안 되는 이유는 무엇인가?
    • 인증 정보를 포함한 응답을 모든 origin에 노출하는 것을 브라우저가 허용하지 않기 때문이다.
  • Vary: Origin이 중요한 이유는 무엇인가?
    • cache가 특정 origin용 CORS 응답을 다른 origin 요청에 재사용하지 않도록 하기 위해서다.

참고 문서