팀 프로젝트 진행중 팀원으로부터 이런 내용을 전달받았다.

CORS 정책으로 인해 요청이 차단되는 문제가 있다고 한다.
CORS 개발을 하면서 여러번 들어본 단어인데 직접 이를 해결해 본적이 없고 이게 뭔지 정확하게 잘 몰랐다. 이번 기회에 학습하며 글을 작성해 보려한다.
교차 출처 리소스 공유(CORS)란, 웹 브라우저가 사용하는 보안 방식 중 하나로, 다른 도메인에서 자원을 공유할 수 있도록 허용하는 규약이다.
이전에는 동일 출처 정책(Same Origin Policy)이 적용되어 한 도메인에서 로드한 문서나 스크립트에서 다른 도메인의 자원을 요청할 경우, 보안상의 이유로 브라우저에서 차단되었지만, CORS는 서로 다른 도메인 간의 자원 공유를 허용하여 이러한 문제를 해결하였다.
CORS의 구체적인 동작 방식은 HTTP 헤더를 통해 이루어진다. 웹 애플리케이션에서 다른 도메인의 자원을 요청할 때, 브라우저는 먼저 해당 도메인의 서버에 preflight 요청을 보내고, 서버는 이 요청에 대한 응답으로 Access-Control-Allow-Origin과 같은 헤더를 보내주어 요청이 허용되는지 여부를 판단한다.
하지만, CORS는 보안적인 이유에서 브라우저에서 동작하며, 서버 측에서는 따로 처리해주어야 한다. 이를 위해 서버에서는 다양한 설정을 해주어야 하며, 이를 잘못 구성하면 보안상의 문제가 발생할 수 있다.
CORS에 대해 간단히 정리했는데 또 모르는 단어가 있다. Origin? SOP? preflight?
각 용어에 대해 간단히 알아보자.
Origin = [프로토콜]://[Host의 IP 주소 또는 URL]:[포트번호]
포트번호는 생략이 가능하다. 포트 번호를 생략하면 HTTP 프로토콜이면 80, HTTPS 프로토콜이면 443을 생략했다고 생각하면 된다.
대부분의 웹 브라우저는 Same Origin Policy(SOP)라는 보안 정책을 준수한다.
SOP에서 우리는 두 URL이 동일한 프로토콜, 도메인 및 포트번호를 가지면 동일한 발신지인 것으로 판단한다.
예시 1)
아래 두 주소는 Same Origin이다.
e.g. https://www.abcmart.com
https://www.abcmart.com/nike
프로토콜 : https
도메인 : www.abcmart.com
포트 : https 프로토콜에서 생략된 경우 443예시 2)
다음 두 URL 주소는 다른 Origin으로 판단
e.g. https://www.abcmart.com
http://www.abcmart.com
두 URL이 다른 Origin인 이유는 프로토콜이 서로 다르기 때문이다.
Same Origin Policy를 구현한 브라우저들은 XMLHttpRequest나 Fetch API와 같은 방법들을 사용하여 한 origin에서 다른 origin으로 리소스 또는 리소스 처리 요청들을 하는 웹 사이트 스크립트들을 제한한다.
csrf (cross-site request forgery, 크로스 사이트 요청 위조) 공격으로부터 보호하기 위한 첫번째 방법으로 Same Origin Policy를 수행한다. 그러나 개발자들은 서로 다른 도메인의 API를 활용할 필요성을 느끼게 되었고, 이에 따라 특정 조건에서 교차 출처 리소스 공유를 허용하는 CORS(Cross-Origin Resource Sharing) 정책이 도입되었다.
CORS 정책에 의해 브라우저가 실제 요청 전에 OPTIONS 요청을 보내 사전 확인하는 과정이다.
Preflight 요청이 필요한 이유는
클라이언트가 대량의 데이터를 요청 본문(Body)에 담아 전송하기 전에,
서버가 해당 요청을 허용하는지 먼저 확인하여 불필요한 전송을 줄임
클라이언트가 POST, PUT, DELETE 등의 요청을 보낼 때
서버가 해당 메서드를 지원하는지, 그리고
특정 헤더(ex. Authorization, Content-Type 등)를 허용하는지 확인
위와 같이 Preflight 은 더 효율적인 통신을 위함과 관련이 깊다.
자세한 예시를 들자면 만약 우리가 엄청난 데이터를 서버로 보낸다고 가정하자.
Front 에서 여러 처리를 한 뒤, 열심히 Body 에 데이터를 파싱하고 담았다.
하지만 서버가 POST 요청을 허용하지 않는다면, 통신을 위한 여러 과정들이 허무하게 사라져 버리게 된다.
이를 방지하기 위해서 서버에서 어떤 메서드와 어떤 header 를 허용하는지 확인하는 과정이 필요하고, 그 과정을 바로 Preflight 에서 수행한다.
우리가 특정 Http Method 로 요청을 보내게 된다면 해당 서버로 OPTIONS 를 미리 보내보고 해당 응답을 확인한 후, 우리가 보낸 Http Method 가 지원하면 실제 요청이 이뤄지게 되는 것이다.
간단한 요청(simple requests)은 브라우저가 preflight 없이 바로 보내며, 다음 조건을 모두 만족하는 요청들을 의미한다.
HTTP 메서드 방식이 GET, HEAD, POST인 요청
User-Agent 헤더만 보내거나 Accept, Accept-Languate, Content-Languate, Content-Type과 같은 CORS 세이프 목록 헤더만 보내는 요청
Content-Type 헤더가 오직 “application/x-www-form-urlencoded”, “multipart/form-data”, “text/plain”만을 가지고 있는 요청
ReadableStream object를 사용하지 않는 요청
XMLHttpRequest.upload에 이벤트 리스너 연결되어 있지 않은 요청
단순한 요청(simple request)가 아닌 모든 요청은 단순하지 않은 요청(non-simple request)이거나 미리 준비된 요청(preflighted request)으로 간주한다. 브라우저는 이러한 종류의 요청들을 조금 다르게 취급한다. 실제 요청을 전송하기 전에 서버가 이러한 종류의 요청을 허용할 수 있는지 확인하기 위해서 브라우저는 preflight request을 보낸다. preflight request는 다음 헤더들을 포함하는 OPTIONS HTTP 요청을 전송한다.
origin : 요청이 어디에서 요청되었는지 서버에게 알려주기 위한 도메인 값
access-control-request-method : 요청하는 HTTP의 Http Method를 서버에 알려준다.
access-control-request-headers : HTTP 요청에 포함되는 헤더들을 서버에 알려준다.
preflight request 요청하였을때 서버는 다음 헤더들을 응답해서 브라우저는 요청한 도메인(origin)에서 이러한 종류의 요청을 수락할지 여부를 결정할 수 있다.
access-control-allow-origin : 서버가 허용하는 도메인
access-control-allow-methods : 서버가 허용하는 HTTP Method 목록
(HTTP Method 목록은 콤마로 구분)
access-control-allow-headers : 서버가 허용하는 HTTP Header 목록
(HTTP Header 목록은 콤마로 구분)
aceess-control-max-age : 브라우저에게 prelight request에 대한 응답을 얼마나 길게(초 단위) 캐시할 것인지
간단한 요청(Simple Requests)과 비슷하게 만약 서버가 몇몇 위의 CORS 헤더들을 포함하지 않는다면, 브라우저는 서버가 이 요청을 허용하지 않는다고 판단하고 실제 요청을 진행하지 않는다.
Credentials란 의미는 자격 정보라는 의미로 이 정보는 쿠키(cookies)가 될수도 있고, authorization 헤더가 될수도 있고, TLS client certificate(TLS 클라이언트 인증서)가 될 수 있다. 기본적으로 CORS 정책은 credential을 포함할 플래그가 포함되어 있고, 서버가 access-control-allow-credentials=true로 응답하지 않는 한 cross-origin request에서 credential 정보가 포함되는 것을 허용하지 않는다.
TLS(Transport Layer Secure)
TLS는 인터넷 통신의 보안을 담당하는 프로토콜입니다. 주로 데이터를 암호화하고 인증서를 통해서 통신 당사자들을 확인하여 무결성과 기밀성을 보장합니다.
만약 서버가 access-control-allow-origin 헤더의 도메인이 요청하고자 하는 도메인과 같지만
요청에 credentials가 포함되었을 때 (credentials: 'include' 또는 withCredentials: true)
access-control-allow-credentials 헤더의 값이 true로 포함되어 있지 않으면, 브라우저는 요청을 막는다.
추가로 credentials에 해당하는 것들은 아래와 같다.
-> 쿠키, Authorization 헤더, TLS 클라이언트 인증서 등
지금까지 CORS와 관련된 내용을 정리했다.
그래서 문제가 뭐라고?
다시 문제를 해결하기 위해 내용을 확인해보자

요약하자면 위에서 공부했던
access-control-allow-origin
access-control-allow-methods
access-control-allow-headers
access-control-allow-credentials
이런 내용을 헤더에 추가해달라는 요청이다.
그렇다면 위 내용을 어떻게 헤더에 추가를 할 수 있을까?
방법으로 두 가지 방식을 사용할 수 있다.
나는 CORS에 대해 직접적으로 경험한적이 없다. 이번 프로젝트도 팀원이 아래와 같이 설정해놔서 별 문제가 없을거라 생각했었다...
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* CORS 설정
* */
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5174, http://localhost:5173")
.allowedMethods("*")
.allowedHeaders("*")
.maxAge(3600)
.allowCredentials(true);
}
}
먼저 스프링부트에 설정되어있는 cors 설정 부분을 확인해 보았다.
잘못된 부분이 없어 보였는데 allowedOrigins() 메소드 인자로 가변인자로 들어가야하는데 문자열로 잘못 설정되어있어 아래와 같이 수정하고 확인해 달라고 요청드렸다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* CORS 설정
* */
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:5174", "http://localhost:5173")
.allowedMethods("*")
.allowedHeaders("*")
.maxAge(3600)
.allowCredentials(true);
}
}
하지만 얼마 후 해결이 되지않았다는 답변을 들었다.

access-control-allow-origin에 관한 문제로 알고 있었는데 디버그 내용을 보니 preflight 요청이 실패한다는 내용이였다.
직접 테스트해보니 아래와 같은 결과가 나왔다.
Access to fetch at 'https://danjitalk.duckdns.org/api/member/find-id' from origin 'http://localhost:5173' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

Preflight 요청이 200번대 또는 300번대 응답을 받아야 실제 요청(예: POST 요청)이 서버로 전송되는데, 왜 401 응답이 반환되었는지 확인해보았다.
서버 로그를 살펴보니 OPTIONS /api/member/find-id 요청이
내가 만들고 시큐리티 필터에 추가한 JwtAuthorizationFilter의 doFilterInternal 메소드를 지나고 있었고 해당 응답을 지나면서 401 응답을 반환해, 이후 실제 요청인 POST는 요청이 서버에 도착하지 못해 처리하지 않았다는 점이다.
JwtAuthorizationFilter 클래스의 doFilterInternal 메소드 길이가 길어 요약하자면
해당 필터를 지나는 경우 엑세스 쿠키와 리프레시 토큰을 검사한 뒤 둘 다 없으면 response.setStatus(HttpStatus.UNAUTHORIZED); 설정하는 코드가 있어서 해당 오류가 발생했던것이다.
로컬에서 테스트 할 때 이러한 경우를 방지하기 위해 나는 아래와 같은 코드를 작성해 놓았었다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { // 시큐리티와 관련 없는(인증/인가 필요 없는) 필터를 타면 안되는 경로
return web -> {
web.ignoring()
.requestMatchers(HttpMethod.POST, "/api/member/signup")
.requestMatchers(HttpMethod.POST, "/api/member/check-email-duplication")
.requestMatchers(HttpMethod.POST, "/api/mail/certification-code/send")
.requestMatchers(HttpMethod.GET, "/api/mail/certification-code/verify")
.requestMatchers(HttpMethod.POST, "/api/mail/certification-code/verify")
.requestMatchers(HttpMethod.POST, "/api/member/find-id")
.requestMatchers(HttpMethod.POST, "/api/member/reset-password")
.requestMatchers(HttpMethod.GET,"/social-login")
.requestMatchers(HttpMethod.GET, "/favicon.ico")
.requestMatchers("/error");
};
}
하지만 해당 코드는 CORS를 만나면서 내가 의도한 방향과 다르게 흘러갔다.
POST가 아닌 OPTIONS에 대해서는 생각하지 못했었다.
그래서 아래와 같이 수정하고 테스트 했다.
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { // 시큐리티와 관련 없는(인증/인가 필요 없는) 필터를 타면 안되는 경로
return web -> {
web.ignoring()
.requestMatchers("/api/member/signup")
.requestMatchers("/api/member/check-email-duplication")
.requestMatchers("/api/mail/certification-code/send")
.requestMatchers("/api/mail/certification-code/verify")
.requestMatchers("/api/mail/certification-code/verify")
.requestMatchers("/api/member/find-id")
.requestMatchers("/api/member/reset-password")
.requestMatchers("/social-login")
.requestMatchers("/favicon.ico")
.requestMatchers("/error");
};
}

정상적으로 동작했고 문제를 해결했다고 생각했다.
하지만 다른 곳에서 또 다른 문제가 발생하였다...

이번엔 피드 글을 조회하는 메소드였다.
GET /api/community/feeds/1 API였는데,
OPTIONS /api/community/feeds/1가 JwtAuthorizationFilter에 doFilterInternal를 거치면서 preflight가 401코드가 반환되어 이후 GET 요청을 받지 못했다.
/api/member/find-id와 같은 해결방식을 사용하려니 시큐리티 필터의 인증, 인가가 필요 없는 POST /api/member/find-id와는 다르게 GET /api/community/feeds/1는 게시글을 상세 조회하는 메소드로 로그인 여부에 따라 추가 정보를 포함하는 응답을 제공해야 했다. (ex. 북마크/좋아요 여부 포함)
따라서 webSecurityCustomizer() 메소드에 추가 할 수 없었고,
또한 JwtAuthorizationFilter에 doFilterInternal에서 필터를 거치지 않도록 하는 방식을 사용하려해도 사용자의 정보가 필요했기에 해당 방식도 사용할 수 없었다.
방법을 찾아보던 중 OPTIONS /api/community/feeds/1 서버 로그에서 OPTIONS가 시큐리티 필터를 모두 거치는게 맞는지 한번 생각해 보게되었고 뭔가 cors 설정에 뭔가 문제가 있는게 아닐까 생각하며 Spring Boot의 CORS 처리를 찾아보게 되었다.
그러던 중 아래 글들을 읽고 문제가 있음을 알게되었다.
https://velog.io/@yevini118/SpringBoot-CORS-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0
공식문서에 따르면 "springsecurity사용시 security 수준에서 cors 설정을 해줘야한다"라고 작성되어있다.
-> spring security 사용시 추가 설정이 필요하다. (필터단에서 처리필요)
추가로
"Spring MVC의 CORS 설정을 사용하고 있다면, 굳이 CorsConfigurationSource를 따로 지정할 필요가 없다. Spring Security는 Spring MVC에 제공된 CORS 설정을 그대로 사용한다."고 작성되어있다.
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// if Spring MVC is on classpath and no CorsConfigurationSource is provided,
// Spring Security will use CORS configuration provided to Spring MVC
.cors(withDefaults())
...
return http.build();
}
}
.cors(withDefaults()) 를 사용해 Spring MVC에 제공된 CORS 설정을 사용할 수 있다고 예시가 작성되어있다.
공식문서
https://docs.spring.io/spring-security/reference/servlet/integrations/cors.html#page-title
추가로 적용이 정상적으로 안된 원인을 간단히 작성하자면
Spring Boot 프로젝트에서 spring-security 의존성을 추가한 경우,
모든 HTTP 요청은 Spring Security의 필터 체인(Filter Chain) 을 통해 먼저 처리된다.
즉,Spring MVC에서 설정한 CORS는 컨트롤러 앞단 인터셉터 수준에서만 작동하는데,
Spring Security 필터에서 이미 요청을 막아버리면 MVC까지 도달하지 못한다.
그래서 WebMvcConfigurer에서 설정한 CORS는 무시된 것처럼 보이게 된다.
학습한 내용을 바탕으로 다시 적용을 해보았다.
시큐리티 필터에서 제외한 API
POST /api/member/find-id
시큐리티 필터에서 처리되는 API
GET /api/community/feeds/1
security에 WebMvcConfigurer의 CORS를 적용해도 security 필터에서 그리고 security 필터를 거치지 않도록 설정한 API들에 대해서도 정상적으로 WebMvcConfigurer에 설정한 CORS의 기능을 한다.
사실 여러 과정이 생략되었다 CORS 설정을 하며 Nginx로 해결을 하려했다.

처음엔 스프링 부트에서는 헤더설정을 안해주는 줄 알았고 그래서 Nginx로 하나하나 다 추가해야 하는 줄 알았다.
그래서 always 를 사용해 Access-Control-Allow-Origin 가 중복으로 헤더에 설정되는 문제도 만났고 여러 문제가 있었다.
결과적으로 스프링 부트만으로 CORS 설정을 했고 해당 설정들은 사용하지 않게 되었다..
1 . Nginx
map은 http{} 안에 있어야 한다
map $http_origin $allowed_origin {
default "";
"http://localhost:3000" $http_origin;
"https://devridge-client.vercel.app" $http_origin;
}
요청의 Origin 헤더 값 ($http_origin)을 보고, 조건에 따라 $allowed_origin 이라는 새 변수를 설정
그 외 (default)는 "" (빈 문자열)로 설정
스프링 부트에 가기전 origin으로 미리 걸러내는 역할을 함
2 .
스프링 부트에서 CORS 설정을 하면 기본적으로 preflight에서는 아래 5개의 응답을 헤더에 담아주었고
access-control-allow-credentials,
access-control-allow-headers,
access-control-allow-methods,
access-control-allow-origin,
access-control-max-age
이후 진짜 응답에서는
access-control-allow-credentials,
access-control-allow-origin
두 개를 헤더에 설정하여 반환하도록 설정되어있었다.
(Allow-Headers, Allow-Methods, Max-Age)는 OPTIONS 응답에만 필요!
추가
OPTIONS 메서드
OPTIONS는 본문을 포함하지 않는 것이 일반적이며 표준적인 사용
스펙상 OPTIONS에 body가 있을 수도 있다고 하지만, 대부분의 서버와 클라이언트 구현은 body 없이 동작Preflight 요청
브라우저가 CORS 정책 위반 여부를 확인하기 위해 보내는 사전 요청
반드시 OPTIONS 메서드를 사용
body를 넣어도 무시한다.