최근에 Spring으로 API를 완성하고 프론트엔드와 협업하는 과정에서, Postman에서는 잘 작동하는 API가 여러가지 문제에 직면했다.
아무래도 다들 많이 겪는 문제이기도 하고, 관련 내용으로 포스팅을 하면 좋을 것 같다는 생각이 들었다.
이번 포스팅에서 다룰 문제는 크게 두 가지로 구분할 수 있다.
Postman에서는 서버 대 서버 환경이라 모든게 문제없이 작동하나 실제로 리액트와 같은 프론트에서 요청을 날려보면 에러가 잔뜩 나온다. 이러한 문제들을 하나씩 해결해보자. 이 포스팅에서는 해당 문제의 개념을 간략하게 다루고 있다.
이것은 Cross-Origin-Resource-Sharing 줄임말이다.
즉, 쉽게 말하면 A 도메인에서 B 도메인으로 어떤 리소스를 요청하는 것을 말한다. 이때, A 도메인과 B 도메인이 다르면 Cross가 되는 것이다.
보안을 위해서 브라우저에서는 이런 경우에 아예 리소스 접근을 차단해버린다. 그래서 백엔드는 원활한 개발을 위해 CORS를 특정 도메인에서는 리소스 접근을 허용을 해줘야 할 필요가 있다.
일단 현재 나의 프로젝트는 Spring Security가 적용되어 있기 때문에, Spring security에서 설정한다. 왜냐하면 모든 요청의 시작은 Security에서 처리되기 때문이다. 그리고 Spring MVC에서는 모든 Origin에 대해서 허용을 해두었다. (이건 필요 없을 수 있다!)
코드는 아주 간단하다. 먼저, Security 설정이 적혀있는 Configuration 클래스에 빈을 하나 등록한다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("http://localhost:5173");
configuration.addAllowedOrigin("https://localhost:5173");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
먼저, addAllowedOrigin()
은 허용할 도메인을 등록할 수 있다. 현재는 http와 https 모두 허용이 되어있다. 이것은 추후 쿠키를 발급할 때 아주 중요한데, 프론트 엔드 개발을 임시 https 환경에서 개발을 하도록 설정하고 https 도메인을 등록해주자. 그렇게 되면 http 도메인 주소는 필요가 없다.
근데 왜 "*"를 쓰지 않았냐고 물을 수 있다.
이건, 아래의 setAllowCredentials(true)
때문이다. 이것은 세션 ID 또는 jwt 토큰과 같은 인증 정보를 담아서 보낼 수 있도록 하는 것인데, 이 옵션을 허용하지 않으면 세션 쿠키가 발급이 되지 않는다. 또한 이 옵션이 true일 경우에 setAllowedOrigin()
에서 "*"를 사용할 수 없다.
나머지 옵셛들은 어떤 헤더를 허용할 것인지 어떤 요청 (Get, Post 등) 을 허용할 것인지에 대한 설정이고, 맨 마지막은 어떤 url에 대해서 해당 CORS 정책을 등록할 것인지에 대한 설정이다.
자 이제 이 CorsConfiguration을 Security에 등록해주자.
@Bean
public SecurityFilterChain filterChain2(HttpSecurity http) throws Exception {
http
...
.anyRequest().authenticated()
.and()
.csrf().disable()
.cors().configurationSource(corsConfigurationSource()).and()
.headers().disable()
.httpBasic().disable()
.formLogin().disable()
.rememberMe().disable()
...
;
return http.build();
}
이렇게 설정하면 CORS관련 설정은 Security에서 끝이다.
나는 Security는 통과되었지만 MVC에서 문제가 생길까봐 컨트롤러 클래스 위에 @CrossOrigin(origins="*")
어노테이션을 붙였다.
나같은 경우 컨트롤러가 하나밖에 없기 때문에 이렇게 각각 설정해주었지만
좀 더 볼륨이 큰 프로젝트일 경우 프로젝트 자체 설정에서 CORS를 설정하는 방법이 존재한다.
좀 더 뒷부분에서 CORS 설정을 백엔드도 테스트 해볼 수 있는 방법을 설명한다. 이것은 쿠키가 잘 날라오는지 테스트하는 것도
가능하다. 물론 https가 아니기 때문에, 간접적으로 엿볼 수 있다.
정말 지독한게 보안 때문에 신경쓸게 너무 많다.
CORS를 금방 해결하고 배포를 했는데, 이번에는 쿠키가 발급이 안된다는 연락을 받았다!!!
나는 당연히 엥? 왜?? 라고 생각을 했는데 그 이유는 Postman에서 아무 문제가 없었기 때문이다.
근데 조금 찾아보니 Same-site 때문에 도메인이 서로 다른 경우 쿠키가 아예 발급이 안되는 문제가 있다는 것을 알았다.
Same-site에는 세 가지의 전략이 있는데 아래와 같다.
1) Lax: 도메인이 다르더라도 크게 문제가 없다고 생각되는 요청에는 쿠키를 발급해줌. 예를 들면, Get과 같은 요청.
2) Strict: 아주 엄격, 도메인이 다르면 아예 쿠키가 발급이 안됨
3) None: 그런걸 신경쓰지 않음. 하지만 Chrome에서 Same-site가 None으로 설정된 쿠키를 받기 위해서는 Secure 설정이 무조건 되어 있어야 됨.
따라서 지금 개발 환경이 완전히 다르고 API만 배포된 상황이기 때문에 Same-site를 None로 하고 Secure 설정을 True로 해주었다.
여기서 Secure설정은 https 환경에서만 쿠키를 주고받고 하겠다라는 설정이다.
근데, 또 의문점이 생길 수 있다. 근데 내 로컬은 https가 아니잖아? 그럼 어떻게 받아?
다행히도 Origin이 Localhost인 경우, 쿠키가 Secure 설정이 되어있더라도 쿠키를 받을 수 있다고 한다.
그러나 내가 테스트 해본 결과 되는 것 같았다. 테스트는 아래에서 상세하게 설명하고 있다.
아니면 프론트 작업자도 임시로 자신의 프로젝트를 https로 만들어줄 수 있는 설정이 있다. 관련해서는 아래의 게시물을 참고할 수 있다
리액트에서 localhost를 https로 바꾸기
그렇다면 Spring에서 쿠키를 어떻게 설정하냐~
나 같은 경우에는 Spring-session-redis를 사용하고 있다. 그렇기 때문에 간단하게 설정할 수 있었다.
만약 Spring session을 사용하지 않는다 하더라도 쿠키를 발급하는 클래스를 오버라이딩하거나 뭔가 쿠키 발급 과정을 인터셉트하는 식으로 설정할 수 있지 않을까..
@EnableRedisHttpSession 또는 @EnableSpringHttpSession 클래스에서 아래의 빈 클래스를 등록하면 된다.
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 쿠키 설정
cookieSerializer.setCookiePath("/");
cookieSerializer.setCookieName("SESSION");
cookieSerializer.setUseSecureCookie(true);
cookieSerializer.setUseHttpOnlyCookie(true);
cookieSerializer.setSameSite("None");
return cookieSerializer;
}
setCookiePath()의 경우 쿠키가 전송될 주소를 지정해주면 된다. 서버에서 프론트로의 주소 말고, 프론트에서 특정 주소로 요청을 할 때 자동으로 담기도록 하기 위해서는 서버로 전송될 path를 적어야한다.
아까 얘기했듯이 SameSite설정을 None로 하는 대신 Secure 설정을 true로 한 모습이다.
너무 간단하게 설정은 끝이났다.
설정을 해서 배포를 했는데 확인을 하려면 프론트엔드가 해야하는가?
당연히 아니다. 우리가 배포를 했는데 우리가 책임지고 테스트 해야 되는게 맞다.
그렇다고 Postman에서는 정확하게 테스트 되지 않는다. 마찬가지로 브라우저 환경이 아니기 때문이다.
그렇다면 테스트를 위해 임시적으로 브라우저 환경을 만들면 된다.
원래는 html파일 하나 해당 파일에서 동작할 js파일 하나만 있으면 되는데
그걸 구성하기 귀찮은 사람들은 아래의 깃에서 다운받을 수 있다.
다운받으면 index.html을 크롬으로 실행시키고, 개발자 도구에서 네트워크 탭에 들어가면 된다.
초기에는 main.js에 ajax가 요청을 보내는 경로가 깃헙 주소로 되어있으니, 해당 파일을 메모장 또는 Visual Studio Code와 같은 편집기를 이용해서 바꿔줄 수 있다. 변경한 뒤 저장하고, 해당 페이지를 새로고침하면 된다.
html에서 body의 onload에 main()가 설정되어 있기 때문에 초기에 페이지가 로딩될 때 따로 설정하지 않아도 요청이 한번 서버로 날라간다.
아래와 같이 서버 주소로 변경해주었다. 실제로는 배포되고 있는 서버 주소로 했다.
function main()
{
console.log("nicks-cors-test");
$.ajax
({
url: "http://localhost:8080/",
success: function(data)
{
console.log(data);
}
});
}
저장하고 실행시킨 뒤, 개발자 도구의 네트워크 탭으로 들어가보면 여전히 오류가 떠있다.
왜냐하면 file시스템에서 실행시킨 html이므로 file:// 도메인으로 설정되어 있고, 이 경우에 Origin이 null값이기 때문에
CORS 정책에 위배된다.
그래서 나는 파이썬 환경에서 가상으로 http 서버를 열고, 해당 html파일을 실행시켰다. 이때, 포트는 초기에 백엔드에서 허용해주었던 포트로 열어주면 된다. 나는 5173이었으므로 http 서버도 5173으로 열었다. 아나콘다가 깔려있다면 Anaconda Prompt에서 해당 폴더로 진입한 뒤, 아래의 명령어를 실행하면 된다.
C:\...\nicks-cors-test> python -m http.server 5173
그러면 이렇게 실행이 된다.
그런 뒤에 localhost:3000으로 접속하면 index.html이 실행되고, 네트워크에서 확인을 해보면 CORS 에러가 뜨지 않는다.
근데 이건 GET 요청이고 POST 요청으로 어떤 json 데이터도 담고 싶으면 main.js파일을 아래와 같이 수정하면 된다.
나는 쿠키 값도 받아야 되므로 앞서 설명했던 withCredentials도 True로 설정한 상태이다.
function main()
{
console.log("nicks-cors-test");
let data = {
"key": "value"
}
$.ajax
({
type: "POST",
url: "https://trinity.dobby.kr/trinity/login",
data: JSON.stringify(data),
contentType: "application/json; charset=utf-8",
dataType: "json",
xhrFields: {
withCredentials: true // 클라이언트와 서버가 통신할때 쿠키 값을 공유하겠다는 설정
},
success: function(data)
{
console.log(data);
}
});
}
응답으로 쿠키도 잘 넘어온다.
자 그러면 다음 요청에서 이 쿠키가 서버로 잘 넘어가는지 확인하면 된다.
그러기 위해서는 html에 버튼을 하나 만들고, js에 메소드를 하나 추가해서 액션에 따라 해당 메소드를 수행하도록 하자
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>nicks-cors-test</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script type='text/javascript' src="main.js"></script>
</head>
<body onLoad='main();'>
<button id="get" onclick="getTest()">Test</button>
</body>
</html>
// main.js
function getTest()
{
console.log("nicks-cors-test");
$.ajax
({
type: "GET",
url: "http://localhost:8080",
contentType: "application/json; charset=utf-8",
dataType: "json",
xhrFields: {
withCredentials: true
},
success: function(data)
{
console.log(data);
}
});
}
그러면 이렇게 버튼이 생겼을 것이다.
나는 페이지가 로딩될 때 쿠키를 가져왔고, Test 버튼을 누를 때 요청에 쿠키를 담아서 보내는지 테스트를 확인할 수 있다.
이런 오류가 뜨긴 하지만
실제로는 쿠키가 브라우저에 잘 저장된다.
버튼을 누른 다음 발생하는 요청은 쿠키 값이 꼭 있어야 사용자 인증이 되는데, 응답이 200으로 잘 오는 걸 볼 수 있고
내가 보낸 request 헤더에 Cookie값이 잘 들어있는 것도 확인할 수 있다.
이로써 알 수 있는 건, Same-site가 None이고 Secure가 설정되어있는 쿠키지만 localhost에서는 http이더라도 테스트가 가능하다는 것을 알 수 있다.
아무래도 긴 줄글 느낌이라 읽는게 많이 힘들었을 것이라고 생각한다.
하지만 실제로 설정하는 부분은 어려운 게 없다. 그리고 테스트 부분이 조금 이해가 안될수는 있는데, 우리는 협업을 해야하는 개발자의 입장에서 이렇게 디테일하게 체킹하지 않는다면 프론트엔드 개발자는 자신의 문제인 줄 알고 아까운 시간을 더 소모할 수도 있다.
많은 사람들에게 이 글이 도움이 되었으면 좋겠다...
다음 글은 nginx에서 L4 로드 밸런싱에 대해서 쓰게될 것 같다.