프런트는 React, 백엔드는 Spring Boot로 나눠 놓은 프로젝트에서 거의 매번 한 번은 밟는 지뢰가 CORS다. 로컬에서 포트만 다르게 띄워도, 운영에서 API를 서브도메인으로 빼도 똑같이 걸린다. 그때마다 어디를 고쳐야 하는지 헷갈려서 컨트롤러, 시큐리티 설정, Nginx를 돌아가며 건드리다 오히려 꼬이는 경우를 자주 본다.
먼저 답부터 적자면, CORS는 응답에 Access-Control-Allow-Origin 헤더를 붙여 주는 작업이고 Spring Boot라면 그 지점은 애플리케이션 한 곳이어야 한다. Spring Security를 쓰면 http.cors() + CorsConfigurationSource 빈, 안 쓰면 WebMvcConfigurer의 전역 설정으로 끝내는 게 가장 깔끔하다. Nginx add_header로 푸는 방식은 애플리케이션과 겹쳐 헤더가 중복되는 순간 브라우저가 거부하므로 기본 선택지로 두지 않는다.
CORS가 막는 건 서버가 아니라 브라우저다
CORS(Cross-Origin Resource Sharing)는 브라우저가 적용하는 규칙이다. 스크립트가 자신을 띄운 출처(origin, 스킴+호스트+포트 조합)와 다른 출처로 요청을 보내면, 브라우저는 서버 응답에 그 출처를 허용한다는 헤더가 있는지 확인하고 없으면 응답을 자바스크립트에 넘겨주지 않는다. 서버는 멀쩡히 200을 돌려줬는데도 콘솔에는 에러만 뜨는 이유가 이것이다.
콘솔에 찍히는 전형적인 메시지는 이렇다.
Access to fetch at 'https://api.example.com/accounts/1' from origin 'https://app.example.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
여기서 핵심 단어는 preflight다. PUT, DELETE거나 Content-Type: application/json, Authorization 같은 헤더가 붙은 요청은 "단순 요청(simple request)"이 아니라서, 브라우저가 본 요청을 보내기 전에 OPTIONS 메서드로 사전 확인 요청을 한 번 먼저 던진다. 이게 preflight다. 서버가 이 OPTIONS에 허용 헤더를 제대로 못 돌려주면 본 요청은 아예 나가지도 않는다.
그래서 진단은 OPTIONS를 직접 흉내 내는 데서 시작한다. 브라우저 없이 curl로 preflight를 그대로 쏘면 서버가 뭘 돌려주는지 바로 보인다.
$ curl -i -X OPTIONS https://api.example.com/accounts/1 \ -H "Origin: https://app.example.com" \ -H "Access-Control-Request-Method: PUT" \ -H "Access-Control-Request-Headers: content-type" HTTP/1.1 403 Forbidden Content-Length: 0
응답에 Access-Control-Allow-Origin, Access-Control-Allow-Methods가 없으면 그 서버는 CORS 설정이 안 된 것이다. 설정이 제대로 됐다면 이렇게 나와야 한다.
HTTP/1.1 200 Access-Control-Allow-Origin: https://app.example.com Access-Control-Allow-Methods: GET,PUT Access-Control-Allow-Headers: content-type Access-Control-Max-Age: 1800 Content-Length: 0
막상 돌려보면 OPTIONS에서 403이나 401이 돌아오는 경우가 제일 많다. 이건 CORS 설정 자체보다 Spring Security가 인증 안 된 요청으로 보고 먼저 쳐낸 것일 때가 잦다. 뒤에서 다시 짚는다.
@CrossOrigin은 가장 좁은 선택지다
컨트롤러나 특정 메서드에만 @CrossOrigin을 붙이는 방식이 제일 먼저 눈에 들어온다. 애너테이션 하나로 끝나니까.
@CrossOrigin(origins = "https://app.example.com", maxAge = 3600)
@RestController
@RequestMapping("/accounts")
public class AccountController {
@GetMapping("/{id}")
public Account retrieve(@PathVariable Long id) { ... }
}
인자 없이 @CrossOrigin만 붙이면 기본값으로 모든 출처, 모든 헤더, 그 메서드에 매핑된 모든 HTTP 메서드를 허용하고 maxAge는 30분이다. allowCredentials는 기본으로 켜지지 않는다. 편하지만 모든 출처를 열어 두는 건 운영에서 쓸 설정이 아니다. 반드시 origins로 출처를 못박아라.
@CrossOrigin은 엔드포인트가 몇 개뿐이고 Spring Security도 안 쓰는 소규모 API에서만 권한다. 컨트롤러가 늘어나면 애너테이션을 빠뜨린 메서드에서만 CORS가 깨지고, 그 추적이 생각보다 성가시다. 그래서 현장에서는 처음부터 전역 설정으로 가는 쪽을 선호한다.
Spring Security가 없으면 WebMvcConfigurer
인증 계층이 없는 API라면 WebMvcConfigurer의 addCorsMappings로 한 곳에서 전역 규칙을 정의하는 게 정답이다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
전역 설정의 기본 허용 메서드는 GET, HEAD, POST 셋뿐이라는 점을 놓치기 쉽다. PUT이나 DELETE를 쓰면서 allowedMethods를 지정하지 않으면 그 메서드의 preflight에서 막힌다. OPTIONS는 preflight 처리용이라 따로 나열하지 않아도 Spring이 알아서 응답한다.
여기서 한 번 걸리는 지점이 쿠키나 인증 정보를 함께 보내는 경우다. allowCredentials(true)를 켜면 allowedOrigins에 와일드카드 "*"를 쓸 수 없다. 자격 증명을 실어 보내면서 모든 출처를 허용하는 건 금지돼 있어서, 애플리케이션이 뜰 때 다음 예외로 죽는다.
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.
출처를 명시적으로 나열하거나, 서브도메인처럼 패턴이 필요하면 allowedOriginPatterns("https://*.example.com")를 쓰면 된다.
Spring Security를 쓰면 http.cors()가 먼저다
인증을 붙인 API라면 이야기가 달라진다. preflight로 날아오는 OPTIONS 요청에는 쿠키(JSESSIONID)도 Authorization 헤더도 실리지 않는다. 브라우저가 명세대로 자격 증명을 빼고 보내기 때문이다. 그런데 Spring Security 필터가 CORS 처리보다 먼저 돌면, 자격 증명 없는 요청으로 보고 인증 실패로 쳐낸다. 앞에서 본 OPTIONS 403/401의 정체가 이것이다.
그래서 Spring Security는 CORS를 보안 필터보다 앞서 처리하도록 http.cors()를 제공한다. CorsConfigurationSource 빈을 같이 등록하면 그 설정을 끌어다 쓴다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated());
return http.build();
}
@Bean
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("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
http.cors(Customizer.withDefaults())는 CORS 처리를 보안 필터 체인 앞쪽에 끼워 넣고, 등록된 CorsConfigurationSource 빈을 자동으로 찾아 쓴다. 이 빈을 안 주고 Spring MVC가 클래스패스에 있으면 MVC 쪽 CORS 설정을 대신 가져온다. 두 방식을 섞지 말고 하나로 통일하는 게 혼란이 적다.
인증 API에서 CORS가 안 풀릴 때 열에 아홉은 http.cors()를 빼먹었거나 CorsConfigurationSource 빈을 등록하지 않은 경우다. 전역 WebMvcConfigurer만 두고 시큐리티에 cors()를 안 걸면, MVC 설정이 적용되기 전에 보안 필터가 OPTIONS를 먼저 막아 버린다.
Nginx add_header는 왜 함정이 되나
리버스 프록시로 Nginx를 두고 있으면 거기서 CORS 헤더를 붙이고 싶은 유혹이 생긴다. 설정은 짧다.
location /api/ {
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
if ($request_method = OPTIONS) {
return 204;
}
proxy_pass http://127.0.0.1:8080;
}
문제는 Spring Boot가 이미 같은 헤더를 붙여 보낼 때다. 그러면 응답에 Access-Control-Allow-Origin이 두 번 실리고, 브라우저는 값이 하나여야 할 헤더가 중복됐다며 거부한다. 콘솔 메시지도 미묘하게 달라진다.
The 'Access-Control-Allow-Origin' header contains multiple values 'https://app.example.com, https://app.example.com', but only one is allowed.
게다가 Nginx add_header는 기본적으로 2xx, 3xx 응답에만 붙는다. 백엔드가 4xx나 5xx를 돌려주면 CORS 헤더가 사라져서, 에러 응답만 CORS로 막히는 더 헷갈리는 상황이 된다. always 플래그를 붙여야 상태 코드와 무관하게 헤더가 나간다.
그래서 Nginx에서 CORS를 푸는 건 백엔드가 여러 개라 한곳에서 몰아 처리해야 하거나, 애플리케이션을 건드릴 수 없는 상황으로 한정한다. 이때는 반드시 애플리케이션 쪽 CORS 설정을 완전히 끄고 Nginx 한 곳에서만 헤더를 붙여야 한다. 두 곳에서 동시에 손대는 순간 중복 헤더로 돌아온다.
어디서 풀지 한 줄로 정리하면
Spring Security가 없는 API면 WebMvcConfigurer 전역 설정, 있으면 http.cors()와 CorsConfigurationSource 빈 조합으로 애플리케이션 안에서 끝낸다. @CrossOrigin은 엔드포인트가 몇 개 안 되는 작은 API에서만, Nginx는 백엔드를 여러 개 묶어야 할 때만 쓰고 그때는 애플리케이션 설정을 꺼라. 원칙은 하나다. CORS 헤더를 붙이는 지점은 한 곳이어야 한다.
설정을 바꿨는데도 그대로 막히면 preflight 응답이 캐시됐을 수 있다. Access-Control-Max-Age 때문에 브라우저가 이전 OPTIONS 결과를 들고 있는 것이라, 개발자 도구에서 캐시를 비우고 다시 확인해 보면 된다.