스프링 시큐리티: CORS는 왜 필요하고, 어떻게 동작할까
프론트엔드(localhost:3000)에서 백엔드(localhost:8080)로 API를 호출했더니 브라우저가 요청을 차단합니다. 서버는 정상인데 왜 그럴까요?
브라우저에는 동일 출처 정책(Same-Origin Policy) 이라는 보안 규칙이 있습니다. 프로토콜, 도메인, 포트 중 하나라도 다르면 "다른 출처"로 간주하고 응답 읽기를 차단합니다. CORS(Cross-Origin Resource Sharing) 는 서버가 "이 출처에서 오는 요청은 허용한다"고 명시적으로 알려주는 HTTP 헤더 기반 메커니즘입니다.
CORS(Cross-Origin Resource Sharing)란
CORS 는 브라우저의 동일 출처 정책에 의해 차단되는 교차 출처 요청을, 서버가 응답 헤더를 통해 선택적으로 허용할 수 있게 해주는 메커니즘입니다.
(참고) 출처(Origin)는 프로토콜 + 도메인 + 포트 조합을 의미합니다.
http://localhost:3000과http://localhost:8080은 포트가 다르므로 서로 다른 출처입니다.http://example.com과https://example.com은 프로토콜이 다르므로 다른 출처로 취급됩니다.
브라우저의 CORS 요청 흐름

브라우저가 교차 출처로 HTTP 요청을 보낼 때, 해당 요청이 ** 단순 요청(Simple Request)** 조건을 만족하는지에 따라 동작이 달라집니다.
단순 요청(Simple Request) 조건
| 구분 | 조건 |
|---|---|
| ** 메서드** | GET, HEAD, POST 중 하나 |
| ** 요청 헤더** | 브라우저가 기본으로 붙이는 단순 헤더만 포함, 커스텀 헤더 없음 |
| ** 본문 타입** | POST인 경우 Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나 |
이 조건을 모두 만족하면 브라우저는 프리플라이트 없이 바로 요청을 보냅니다. 조건을 만족하지 않으면 ** 프리플라이트(OPTIONS) 요청 **을 먼저 보냅니다.
프리플라이트(OPTIONS)란
단순 요청 조건을 만족하지 않는 교차 출처 요청에 대해, 브라우저가 ** 실제 요청을 보내기 전에 "이 요청을 보내도 되는지" 서버에 물어보는 OPTIONS 요청 **입니다.
예를 들어, Content-Type: application/json으로 POST 요청을 보내면 단순 요청 조건에 해당하지 않기 때문에 프리플라이트가 발생합니다.
프리플라이트 요청/응답에서 주로 사용하는 헤더는 다음과 같습니다.
| 종류 | 헤더 이름 | 의미 |
|---|---|---|
| ** 요청** | Origin | 요청을 보내려는 페이지의 출처 |
| ** 요청** | Access-Control-Request-Method | 실제로 사용할 HTTP 메서드 |
| ** 요청** | Access-Control-Request-Headers | 실제 요청에 포함할 커스텀 헤더 목록 |
| ** 응답** | Access-Control-Allow-Origin | 허용하는 Origin |
| ** 응답** | Access-Control-Allow-Methods | 허용하는 메서드 |
| ** 응답** | Access-Control-Allow-Headers | 허용하는 요청 헤더 |
브라우저는 이 OPTIONS 응답을 확인한 뒤, 조건이 맞으면 실제 요청을 전송합니다.
스프링에서 CORS를 설정하는 세 가지 방법
1. @CrossOrigin — 컨트롤러/메서드 단위
특정 엔드포인트에만 예외적으로 CORS를 허용할 때 사용합니다.
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public User createUser(@RequestBody CreateUserRequest request) {
// ...
}
}
설정이 컨트롤러 곳곳에 흩어지기 때문에, 전역 정책보다는 예외적인 경우에만 사용하는 것이 좋습니다.
2. WebMvcConfigurer — MVC 전역 설정
여러 컨트롤러에 공통 CORS 정책을 적용할 때 가장 관리하기 쉬운 방법입니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
이 설정은 DispatcherServlet 이후 HandlerMapping 단계에서 적용됩니다.
3. CorsFilter — 서블릿 필터 레벨
서블릿 필터 체인에서 CORS를 처리하는 방식입니다. Spring MVC 외 다른 서블릿까지 CORS를 적용해야 할 때 사용합니다.
@Configuration
public class CorsFilterConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
}
스프링 시큐리티의 CORS 처리
스프링 시큐리티를 사용한다면 http.cors() 설정이 ** 거의 필수 **입니다. 시큐리티 필터가 MVC 핸들러보다 먼저 실행되기 때문에, @CrossOrigin이나 WebMvcConfigurer만으로는 프리플라이트 요청이 시큐리티 필터에서 차단될 수 있습니다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
http.cors()를 설정하면 시큐리티가 내부적으로 CorsFilter를 SecurityFilterChain에 등록하여, 시큐리티 필터 단계에서 프리플라이트와 실제 요청 모두 CORS 헤더를 처리합니다.
세 가지 레이어의 차이

| 레이어 | 적용 지점 | 범위 | 설정 방식 |
|---|---|---|---|
| Servlet-level | 서블릿 필터 체인 (DispatcherServlet 이전) | 모든 요청 | @Bean CorsFilter |
| Security-level | SecurityFilterChain 내부 | Security 관리 하 모든 요청 | http.cors().configurationSource(...) |
| MVC-level | HandlerMapping (DispatcherServlet 이후) | Spring MVC 핸들러만 | WebMvcConfigurer 또는 @CrossOrigin |
CORS 에러가 발생했을 때, ** 어느 레이어에서 막힌 것인지 **를 파악하는 것이 디버깅의 핵심입니다.
- ** 프리플라이트(OPTIONS)가 컨트롤러까지 안 온다면** → Servlet/Security 필터 체인에서 차단 →
CorsFilter또는http.cors()설정 확인 - ** 프리플라이트는 통과하는데 실제 요청에서 에러가 난다면** → MVC 핸들러 매핑 단계 문제 →
WebMvcConfigurer또는@CrossOrigin설정 확인 @CrossOrigin을 여러 곳에 붙였는데 동작이 들쭉날쭉하다면 → 로컬 설정과 전역 설정 충돌 → 전역 설정 하나로 통일
주의할 점
allowedOrigins("*")와 allowCredentials(true)는 동시에 쓸 수 없다
CORS 스펙상 Access-Control-Allow-Origin: *와 Access-Control-Allow-Credentials: true는 함께 사용할 수 없습니다. 쿠키를 보내야 한다면 allowedOriginPatterns를 사용하거나, 구체적인 출처를 명시해야 합니다.
Spring Security CORS와 Spring MVC CORS를 이중으로 설정하면 혼란
@CrossOrigin과 http.cors()를 동시에 설정하면 어떤 설정이 적용되는지 파악하기 어렵습니다. Spring Security를 사용한다면 http.cors()에서 통합 관리하는 것이 명확합니다.
정리
| 항목 | 설명 |
|---|---|
| CORS | 브라우저의 동일 출처 정책을 서버가 응답 헤더로 완화하는 메커니즘 |
| 프리플라이트 | 단순 요청 조건 미충족 시 브라우저가 먼저 보내는 OPTIONS 요청 |
| 스프링 시큐리티 필수 | http.cors() 없으면 프리플라이트가 시큐리티 필터에서 차단 |
| 와일드카드 제약 | allowedOrigins("*")와 allowCredentials(true) 동시 사용 불가 |
| 디버깅 핵심 | CORS 에러 시 Servlet → Security → MVC 순서로 레이어 확인 |