CORS

한 줄 정의

CORS(Cross-Origin Resource Sharing)는 브라우저가 다른 출처(Origin)의 서버에 요청할 때, 서버가 이를 허용하는지 확인하는 보안 메커니즘이다.

실무에서 왜 중요한가

CORS를 이해하지 못하면 다음 문제가 생긴다.

  • 프론트엔드에서 API를 호출하면 브라우저에서 에러가 나는데 Postman에서는 정상이다.
  • Preflight 요청을 모르고 OPTIONS 메서드를 차단해서 모든 API가 실패한다.
  • CORS 설정을 *로 열어두고 운영에 배포해서 보안 문제가 생긴다.
  • 인증 헤더(Cookie, Authorization)를 포함한 요청이 CORS에 막히는 원인을 모른다.

Origin이란

Origin = 프로토콜 + 호스트 + 포트의 조합이다.

https://app.example.com:443  → Origin
  │         │            │
  프로토콜   호스트        포트
URL AURL B같은 Origin?
https://app.comhttps://app.com/apiO (경로는 무관)
https://app.comhttp://app.comX (프로토콜 다름)
https://app.comhttps://api.app.comX (호스트 다름)
https://app.comhttps://app.com:8080X (포트 다름)

CORS 동작 흐름

Simple Request (단순 요청)

조건을 모두 만족하면 Preflight 없이 바로 요청한다.

  • 메서드: GET, HEAD, POST
  • 헤더: Accept, Content-Type(application/x-www-form-urlencoded, multipart/form-data, text/plain만), Accept-Language 등 기본 헤더만
  • Content-Type이 application/json이면 단순 요청이 아니다
Browser                          Server
  │── GET /api/data ──────────▶   │
  │   Origin: https://app.com     │
  │                                │
  │◀── 200 OK ────────────────   │
  │   Access-Control-Allow-Origin: https://app.com

Preflight Request (사전 요청)

단순 요청 조건을 만족하지 않으면, 브라우저가 먼저 OPTIONS 요청을 보내서 허용 여부를 확인한다.

Browser                              Server
  │── OPTIONS /api/orders ────────▶   │  ← Preflight
  │   Origin: https://app.com         │
  │   Access-Control-Request-Method: POST
  │   Access-Control-Request-Headers: Content-Type, Authorization
  │                                    │
  │◀── 204 No Content ───────────   │  ← 허용 응답
  │   Access-Control-Allow-Origin: https://app.com
  │   Access-Control-Allow-Methods: GET, POST, PUT, DELETE
  │   Access-Control-Allow-Headers: Content-Type, Authorization
  │   Access-Control-Max-Age: 3600
  │                                    │
  │── POST /api/orders ───────────▶   │  ← 실제 요청
  │   Origin: https://app.com         │
  │   Content-Type: application/json   │
  │                                    │
  │◀── 201 Created ──────────────   │

Access-Control-Max-Age는 Preflight 결과를 캐시하는 시간(초)이다. 이 시간 동안 같은 요청에 대해 Preflight를 다시 보내지 않는다.

주요 CORS 헤더

응답 헤더 (서버 → 브라우저)

헤더용도예시
Access-Control-Allow-Origin허용할 Originhttps://app.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 캐시 시간 (초)3600
Access-Control-Expose-Headers브라우저에서 접근 가능한 응답 헤더X-Total-Count

인증 정보 포함 요청 (Credentials)

Cookie나 Authorization 헤더를 포함하려면 양쪽 모두 설정이 필요하다.

// 프론트엔드
fetch('https://api.example.com/data', {
    credentials: 'include'  // 쿠키 포함
});
// 서버 응답 헤더
Access-Control-Allow-Origin: https://app.example.com  ← * 불가!
Access-Control-Allow-Credentials: true

credentials: true일 때 Allow-Origin: *는 허용되지 않는다. 반드시 특정 Origin을 지정해야 한다.

Spring에서 CORS 설정

방법 1: @CrossOrigin (컨트롤러 단위)

@CrossOrigin(origins = "https://app.example.com")
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    // ...
}

방법 2: WebMvcConfigurer (전역 설정)

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

방법 3: CorsFilter (Spring Security 사용 시)

@Configuration
@EnableWebSecurity
public class SecurityConfig {
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
        // ...
        return http.build();
    }
 
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
 
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

Spring Security를 사용하면 WebMvcConfigurer만으로는 부족하다. Security 필터가 먼저 실행되어 Preflight가 차단될 수 있으므로, CorsConfigurationSource를 Security에 등록해야 한다.

Postman에서는 되는데 브라우저에서 안 되는 이유

CORS는 브라우저가 강제하는 보안 정책이다. Postman, curl 같은 도구는 CORS를 확인하지 않으므로 요청이 성공한다.

Postman → 서버: CORS 헤더 무관하게 요청 성공
브라우저 → 서버: CORS 헤더 없으면 응답을 차단

서버는 정상적으로 응답했지만, 브라우저가 Access-Control-Allow-Origin 헤더가 없으면 응답을 JavaScript에 전달하지 않는다.

자주 나는 실수

  • Access-Control-Allow-Origin: *으로 설정하고 credentials: true를 사용해서 에러가 발생한다.
  • Spring Security 환경에서 WebMvcConfigurer만 설정하고 Security에 CORS를 등록하지 않는다.
  • OPTIONS 메서드를 차단해서 Preflight가 실패한다.
  • 개발 환경에서 프록시로 우회하다가 운영에서 CORS 문제를 발견한다.
  • Access-Control-Max-Age를 설정하지 않아서 매 요청마다 Preflight가 발생한다.

핵심 요약

CORS는 브라우저가 다른 Origin의 요청을 제한하는 보안 정책으로, 서버가 응답 헤더로 허용 여부를 결정합니다. application/json 요청은 Preflight(OPTIONS)가 먼저 발생하며, 서버가 이를 허용해야 실제 요청이 전송됩니다.

credentials: true일 때는 Allow-Origin: *를 사용할 수 없고 특정 Origin을 지정해야 합니다. Spring Security 환경에서는 CorsConfigurationSource를 Security에 등록해야 합니다.

꼬리 질문

관련 문서