배포환경에서 로그인 시스템에 문제가 생겼다. access-token 만료시 refresh-token 재발급 로직이 비정상적으로 동작하고 있었던 것. 즉 액세스토큰 재발급 API가 매번 런타임 에러를 뱉고 있는것이였다.
간단해보였던 이 문제는 우리팀을 꽤나 고생시켰는데... 크게 두가지 문제가 있어서 그랬던거였다.
1. 액세스토큰 재발급 로직에서 액세스토큰을 검증하는 불필요한 로직이 있었음
2. 리프레쉬토큰이 정상적으로 발급되지 않고 있었음
여기서 두번째 문제가 오늘의 주제, '다른 도메인간 쿠키 문제 해결하기!' 이다.
또한 이 문제를 해결하면서 마주친 다양한 에러들에 대해 좀 더 기술해보도록 하겠다.
(CF) 1번 문제 해결
첫번째 문제는 오늘의 주제와는 동떨어진 휴먼에러이기 때문에 넘어갈까 했지만, 혹시나 같은 문제를 겪을분들을 위해 적어둔다. 리프레쉬토큰을 통해 액세스토큰을 재발급 받는 로직엔 JWT 검증 로직이 필요하지 않다.
처음엔 액세스토큰이 만료되었는지 검사하고, 만료되었다면 재발급 받는 형식으로 진행 하려고 했지만... 기본적으로 내가 작성했던 검증로직에선 Expired된 토큰을 찾아 401을 응답하도록 구조화 되어있었다.
public Claims getTokenClaims() {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
} catch (SecurityException e) {
log.info("Invalid JWT signature.");
} catch (MalformedJwtException e) {
log.info("Invalid JWT token.");
} catch (ExpiredJwtException e) {
log.info("Expired JWT token.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token.");
} catch (IllegalArgumentException e) {
log.info("JWT token compact of handler are invalid.");
}
return null;
}
따라서 해당 검증로직을 없애줘야한다. 사실상 refresh토큰을 검증하고 있기 때문에 위 검증로직이 필요없다 .^-^.
SameSite 쿠키 셋팅하기
Refresh-token을 통한 Access-token 재발급 로직을 실행하면, 클라이언트 쿠키에 저장된 refresh-roken이라는 쿠키값을 읽어 검증한 후, JWT 재발급을 진행한다. 그러나 문제는 요청 그자체에서 생겼다.
Cookie Util에서 "쿠키를 읽을수 없다"는 에러가 발생한것이다.
네트워크 탭에서 요청을 확인해보니, 요청 자체에 쿠키가 존재하지 않는데, 백엔드에서 쿠키를 읽으려고 하니 발생한 런타임 에러였다.
왜 쿠키가 없었을까?
로그인을 진행하면 액세스토큰과 리프레쉬 토큰을 동시에 발급하며, 이 로직엔 에러도 존재하지 않았다.
그런데 왜 클라이언트에겐 리프레쉬 토큰이 전달되지 않았을까?
찾아보니, IETF 쿠키 정책 변경으로 인해 다른 도메인 간 쿠키 전송을 위해서는 samesite 쿠키를 사용해야 한다고 한다.
sameSite는 서로 다른 도메인간 쿠키전달에 보안을 설정하는 옵션으로, 기본 설정이 Lax이다. 즉, 다른 사이트에 쿠키 전달을 허용하지 않는다. 따라서 개발자가 직접 "None"으로 설정해야 쿠키를 정상적으로 전달 할 수 있는것이다(단, Secure 옵션을 true로 설정해야한다)
즉, 빼곡 프로젝트의 경우 백엔드와 프론트엔드의 도메인이 상이했기에 이 설정을 필수적으로 넣어줘야하는 것이였다.
코드는 다음과같다.
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
ResponseCookie cookie = ResponseCookie.from(name, value)
.path("/")
.domain(domain) // 도메인 설정
.sameSite("None") // SameSite 속성 추가
.httpOnly(true)
.maxAge(maxAge)
.secure(true) // Secure 속성 추가 (HTTPS 필요)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
이렇게 셋팅 한 후, POSTMAN과 배포환경에서 실행했을때 정상적으로 쿠키가 셋팅되는것을 확인할 수 있었다.
로컬환경과 시크릿모드에서 쿠키가 셋팅되지 않는 문제
이렇게 해결되나 했지만, 프론트 팀원이 에러가 난다며 노티를 계속했다...
분명 내가 테스트했을땐 정상 동작했는데, 왜 팀원은 접근하지 못했을까?
생각하며 몇가지 시도해본결과, 두가지 문제가 있었음을 깨달았다.
1. SameSite는 Https위에서만 동작하므로, 프론트엔드 로컬환경도 SSL인증을 받아야 한다
2. 크롬 시크릿모드는 서드파티쿠키를 강제로 막는다.
자세히 알아보자.
1. SameSite는 Https위에서만 동작하므로, 프론트엔드 로컬환경도 SSL인증을 받아야 한다.
팀원은 리액트 로컬환경에서 테스트중이었고, 나는 배포에서만 테스트해서 발생했던 차이였다. 로컬환경은 HTTP를 사용하고있어 Secure()설정이 필수인 Same-Site쿠키는 셋팅이 불가능하다. 즉, Secure정책에 위배되어 브라우저가 쿠키 전송을 비허용 하고 있었다. 따라서 로컬환경에서 SSL인증서를 받는 방식으로 이 문제를 해결할 수 있었다.
2. 크롬 시크릿모드는 서드파티쿠키를 강제로 막는다.
사실 로컬에 Https 설정을 했음에도 불구, 에러는 계속되었다. 네트워크탭의 요청을 확인했을때, Cookie탭이 존재하지 않았으며, application탭에서도 쿠키가 필터링되어 존재하지 않았다.
몇가지 시도를 해본결과, 팀원이 사용하고있던 '시크릿모드'에서만 문제가 발생하는것을 확인할 수 있었다. 나또한 시크릿모드로 시도해보니 쿠키가 정상 셋팅되지 않는 문제를 확인했다. 구글링을 해보니, 크롬 시크릿모드에서는 강제로 서드파티 쿠키를 막는다는 스택오버플로 게시글을 찾을 수 있었다.
결국 시크릿모드의 보안정책으로 인해 다른 도메인의 쿠키를 셋팅하지 못하므로 에러가 발생한것이다.
쿠키 삭제가 제대로 안돼요! : addHeader와 addCookie방식의 차이점
이제 전부 해결됐나? 했더니 발생했던 문제. refresh-token을 통해 access-token을 반복적으로 재발급 할 경우, refersh-token의 셋팅, 즉 쿠키 셋팅이 비정상적으로 동작하는것을 확인했다. 정확히 말하자면, 홀수번째 시도엔 정상동작하고 짝수번째 시도엔 비정상 동작하고 있는것이었다ㅜㅋㅋ
개발자도구로 요청을 분석해보니 명확히 알 수 있었다..
왼쪽이 정상적으로 동작한 쿠키 셋팅이고, 오른쪽이 비정상적으로 동작한 쿠키 셋팅이다.
문제상황을 정의하자면, "쿠키가 여러번 셋팅된다" 였다.
짝수번째에 발생하는 문제였으므로, 가장 가능성있는 원인은 "쿠키 DELETE 처리가 제대로 되지 않음"일것이라 예상했고, 셋팅로직을 다시 살펴보았다.
CookieUtil.deleteCookie(request, response, REFRESH_TOKEN);
CookieUtil.addCookie(response, REFRESH_TOKEN, refreshToken.getToken(), cookieMaxAge);
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
ResponseCookie cookie = ResponseCookie.from(name, value)
.path("/")
.domain(domain) // 도메인 설정
.sameSite("None") // SameSite 속성 추가
.httpOnly(true)
.maxAge(maxAge)
.secure(true) // Secure 속성 추가 (HTTPS 필요)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
if (name.equals(cookie.getName())) {
cookie.setValue("");
cookie.setPath("/");
cookie.setMaxAge(0);
cookie.setHttpOnly(true);
cookie.setSecure(true);
response.addCookie(cookie);
}
}
}
}
쿠키를 셋팅하는 방법엔 addCookie와 addHeader가 있는것을 볼 수 있었다. 그리고 바로 여기에 포인트가 있었다!
addHeader 방식은 최신속성, 즉 Samesite와 Secure같은 옵션들을 필요에 맞게 설정하는데 적합하다(직접 문자열을 관리하므로 실수가 발생할 수 있음에 주의).
그에 반해 addCookie 방식은 메소드를 통해 셋팅하므로 실수가 줄어들지만, 최신속성 지원에 한계가 있어 이러한 문제를 일으킬 수 있다.
따라서 samesite 옵션을 사용해 쿠키를 삭제하고 싶다면, Header 방식을 추천하는것이다!
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
모바일 접속시 쿠키 전송 불가 (feat. 삼성인터넷)
samesite 쿠키를 사용한다고 방심하지 말자. 모바일에서 안될수도 있다.
내경우 안드로이드 폰을 사용하고있고, 핸드폰으로 들어가면 access-token이 만료되면 재발급이 안되고 그대로 멈춰버리는 트러블이 발생했다. 그래서 크롬으로 모바일을 디버깅해본결과....
쿠키를 또 자기맘대로 필터링 해버리고 안보내준다. 또 왜...???
여러가지 경우의수가 있을수 있어, 시험삼아 모바일 크롬앱에서 다시한번 테스트해본 결과, 정상작동 하는것을 확인했다.
그렇다면 삼성인터넷 브라우저 자체의 문제일수도 있다는 생각이 들었다!
그리고 검색해본 결과,
공식 답변을 발견 할 수 있었다. 삼성 인터넷 브라우저는 크롬 시크릿모드와 마찬가지로 서드파티 쿠키를 차단할 가능성이 있었다. 확인해보니 내폰도 트래킹 방지 모드가 활성화 되어있었다..!! 그래서 안됐던것.
로그인은 앱의 가장 기본이지만, 늘 새로운 오류를 마주치는것 같다.
나는 늘 똑같은 도메인 위에 프론트/백엔드를 배포했기에 이러한 에러를 마주칠 일이 없었는데, 도메인이 달라지면 신경써야할 설정이 정~말 많은걸 깨닫게 되었다. 특히 쿠키와 관련한 브라우저의 보안정책이 이렇게 상세한것은 처음 알았다. 이에대한 깊은 이해는 물론, addHeader 방식처럼 다양한 메소드들을 직접 사용해보며 습득할 수 있어 좋은 트러블 슈팅이였다
앗 그리고 프론트엔드 팀원과 협업할때, 서로 테스트환경에 대한 충분한 공유가 필요한것도 깨닫게 되었다. "난 되는데 넌 왜 안돼?"의 8할은 테스트환경의 차이였다(시크릿/일반모드, 배포/로컬환경 등..)ㅋㅋㅋㅋㅋ
개발자도구를 활용해 각자의 관점에서 문제를 찾아내고 원활하게 의견을 나누며 해결하는 과정또한 새로운 학습의 기회였던것같다. 협업에 대해 좀더 알아갈 수 있었다ㅎㅎ
'프로젝트 > 빼곡' 카테고리의 다른 글
Spring 명시적 Null값으로 부분 업데이트(PATCH) 구현하기 (3) | 2025.01.02 |
---|---|
여러기기에서 로그인, 다중 로그인 Spring에서 구현하기 (4) | 2024.12.20 |
Redis로 최근검색어 구현하기 (1) | 2024.11.25 |
서버이전을 고려한 Jenkins기반 아키텍처 개선(Feat. GithubActions) (3) | 2024.11.22 |
라즈베리파이를 이용한 홈서버 구축 (1) | 2024.11.21 |