본문 바로가기
Backend/Spring

[Spring] 세션 ID 값이 계속 바뀌는 현상

by 모닥불꽃 2022. 10. 26.
반응형

정말 오랜만에 블로그에 글을 포스팅하게 되었네요.

맨날 올려야지 올려야지 하면서 시간 없다는 핑계로 안 올렸던 것 같습니다.

문제점

업무를 하던 도중, 현재 근무하고 있는 회사에서 세션 관련 에러를 해결해달라는 문의가 들어왔습니다.

현재 근무 중인 회사는 이커머스 A 회사이고, B사인 PG사와 연동하여 결제 수단을 등록하는 상황에서 발생하는 문제였습니다.

 

  1. A 회사의 결제 수단 관리 탭 클릭
  2. B 회사의 인증 창으로 리다이릭팅 되며 B 회사 결제 수단 등록 창 로드
  3. 카드 정보 입력 후 A 회사의 결제수단 관리 창으로 복귀
  4. 세션 만료, 에러 발생

세션 만료 에러는 세션의 정보가 바뀌거나, 실제로 세션이 만료되어 발생하는 에러로 파악하여, 크롬 개발자 도구를 통해 확인해보니 A 회사에서 발급하는 쿠키의 세션 ID인 JSESSIONID가 B 회사 창에서 다시 A 회사 창으로 복귀하면 지속적으로 바뀌는 것을 확인했습니다.

(확인 방법: 크롬 개발자 도구 > 애플리케이션 > 쿠키 부분에서 세션 ID 확인)

 

첫 번째 도전

해당 프로젝스 백엔드 소스코드를 보니, A회사의 결제 수단 관리 서버는 여러대인데, 세션 ID를 모든 서버들이 공유할 수 있는 방법이 구현되어있지 않았습니다.

Redis를 사용해서 서버들끼리 쿠키를 공유하지 않고, Tomcat에서 세션 클러스터링도 구현되어있지 않았습니다.

설정 파일인 yml파일에서아래와 같이 확인했고, 수정해주었습니다.

# 수정 전
spring:
  session:
    store-type: none

해당 프로젝트가 이미 Redis와 연동되어 있었고, 세션 저장도 Redis로 하려 세션을 공유할 수 있도록 수정해주었습니다.

# 수정 후
spring:
  session:
    store-type: redis

이러면 됐겠다 싶어서 세션ID가세션 ID가 공유가 잘 되고, 세션 ID가 계속 바뀌지 않겠다 싶어서 테스트를 해봤지만,

SESSION 너는 누구니?

갑자기 SESSION이라는 세션이 하나 더 생기며, SESSION의 값도 계속 바뀌는 현상이 나타났습니다.

(에러가 두배로 늘었습니다)

spring.session.store-type: redis 로 변경해서 이렇게 2개의 세션이 생성된 이유는 아래를 참고하시기 바랍니다.

spring-session은 SessionRepositoryFilter를 사용하는데, 여기서 HttpServletRequest로부터 getSession() 메서드로 세션 정보를 가져오거나, 세션이 없으면 새로 생성합니다.

이때 spring-session에서 관리하는 곳에서 세션을 가져올 수 있도록 wrapping 하는데, SessionRepositoryRequestWrapper가 wrapping 하고 SessionRepository로 저장하고 사용합니다.

참고) SessionRepository는 인터페이스, 구현체로 Redis, JDBC, MongoDB, Hazelcast 등 사용 가능합니다.

Spring 필터는 서로 재귀적으로 호출하며, 더이상 호출할 필터가 없으면 서비스 로직 수행을 합니다.
서비스 로직 수행하는 것 종료 후 재귀적으로 호출했던 필터들 종료하면서 가장 처음에 호출된 필터의 commitSesssion() 메서드로 session을 최종적으로 저장합니다.

spring-session 관련 필터가 제일 앞에 없고, 그 앞에 session을 참조하는 로직이 있으면 spring-session이 session을 생성하기 전에 톰캣이 자동으로 JSESSIONID이라는 session 생성하게 되고, spring-session 필터에 진입하면 SESSION 또 생성되는 것입니다.

결국 yml파일 수정으로 간단하게 해결되기를 기대했지만, 결국 해결하지 못했습니다.

 

두 번째 도전

조금 더 오류에 대해 찾아본 결과, 쿠키 설정을 아래와 같이 해줘야 한다는 글을 발견해서 cookie-config XML을 작성해주었습니다.

<cookie-config>
	<http-only>true</http-only>
	<secure>true</secure>
</cookie-config>

http-only 옵션

  • document.cookie 같은 자바스크립트 코드로 쿠키 조회 불가능하게 함
  • 서버로 HTTP 요청을 보낼 때만 쿠키 전송
  • XSS 공격 차단 가능

secure 옵션

  • HTTPS으로 통신하는 경우에만 브라우저가 쿠키를 서버로 전송

해당 설정으로 쿠키 설정 변경을 해도 오류는 해결하지 못했습니다.

근본적인 이유를 알지도 못하고 시도만 했기 때문입니다.

 

세 번째 도전 (해결 방법)

조금 더 구글링 해본 결과, 저와 동일하게 PG사 연동을 하면서 동일한 문제를 겪는 글을 발견하였습니다.

정답은 이 문제가 발생한 원인에서 찾을 수 있었습니다.

세션 관련 에러는 PC이며, 크롬 브라우저를 사용할 때 발생했습니다.

원인: PC 버전의 크롬은 80 버전 이상부터 samesite 설정이 없는 쿠키에 대해서는 default로 samesite=LAX 옵션

LAX 옵션으로 설정되면, 도메인이 다른 사이트 간에 쿠키 전송이 안돼서, PG사 측으로 결제 수단 등록 요청이 갔다 오면 도메인이 바뀌어 세션이 새로 발급돼서 세션 만료 오류 발생한 것이었습니다.

 

그럼 여기서 Samesite 옵션에 대해 좀 더 알아보겠습니다.

자사 쿠키 vs 타사 쿠키?

자사 쿠키는 내 웹 페이지의 쿠키, 타사 쿠키는 도메인이 다른 다른 웹 페이지의 쿠키입니다.

자사 쿠키와 타사 쿠키는 포함하는 정보가 다를 것입니다. 동일하게 사용된다면 각 회사에서는 쓸데없는 정보가 들어있을 것이고 오버헤드만 발생됩니다.

쿠키의 Samesite 옵션은 쿠키를 자사, 동일 도메인 사이트로 제한하는지 여부를 선언하는 옵션입니다.

3가지 옵션 가능

  • Strict: 쿠키가 자사 콘텍스트에서만 전송된다. 타사 사이트로 이동시, 쿠키 같이 전달 안 함
  • Lax: 일부 허용 (링크 클릭 등), 상태 변경하는 POST 요청 같은 건 불허
  • None: 모든 콘텍스트에서 쿠키가 전송되기를 원함

주의) SameSite = None으로 설정하면 쿠키가 다른 도메인으로 전송되므로 Secure도 지정해야 합니다.

 

해결 방법

해결 방법으로는 Spring-session에서 제공하는 DefaultCookieSerializer를 작성해서 쿠키를 원하는 대로 사용자화 하였습니다.

https://github.com/spring-projects/spring-session/blob/main/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java

 

GitHub - spring-projects/spring-session: Spring Session

Spring Session. Contribute to spring-projects/spring-session development by creating an account on GitHub.

github.com

 

먼저 build.gradle에 아래와 같이 의존성을 추가하면, spring-session-core.jar 모듈을 사용할 수 있습니다.

implementation('org.springframework.session:spring-session-data-redis')

spring-session-core에 있는 CookieSerializer를 구현하는 CustomCookieSerializer를 생성합니다.

public class CustomCookieSerializer implements CookieSerializer

 

CookieSerializer 소스코드는 spring-session에서 제공하는 그대로 DefaultCookieSerialzer를 사용하였고, 쿠키의 samesite 옵션을 커스텀하기 위해 writeCookieValue 메서드에 아래 코드를 추가해주었습니다.

if (this.sameSite == null) {
  String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
  if(isChromiumSpecificVersion(userAgent)) {
    sb.append("; SameSite=").append("None");
  }
}

 

아래는 크롬 버전이 80 이상인지 판별하는 isChromiumSpecificVersion 메서드입니다.

private boolean isChromiumSpecificVersion(String userAgent) {
  String regex = "Chrom[^ ]+/(\\d+)";
  Pattern pattern = Pattern.compile(regex);
  Matcher matcher = pattern.matcher(userAgent);

  int version = 0;
  if(matcher.find()) {
    version = Integer.parseInt(matcher.group().replaceAll("\\D", ""));
  }

  return version >= 80;
}

 

그리고 Spring Boot 프로젝트의 BeanConfig에 해당 CookieSerializer를 추가해주었습니다.

@Bean
public CookieSerializer cookieSerializer() {
  CustomCookieSerializer cookieSerializer = new CustomCookieSerializer();
  cookieSerializer.setUseSecureCookie(true);
  cookieSerializer.setUseHttpOnlyCookie(true);
  cookieSerializer.setSameSite(null);
  return cookieSerializer;
}

이렇게 CustomCookieSerializer를 사용하도록 Bean 설정을 해주면, 해당 프로젝트에서 CustomCookieSerializer를 사용하게 됩니다.

CookieSerializer는 HttpServletResponse에 쿠키 정보를 작성할 때 사용됩니다.

 

이렇게 작성 후 테스트 결과, 80 버전 이상의 크롬에서 samesite 옵션을 확인한 결과 none으로 잘 설정되어 더 이상 세션 ID가 변하는 현상을 제거할 수 있었습니다.

 

별첨

이런 해결책 말고 또 세션 클러스터링이라는 대안책도 있습니다.

WAS가 2대 이상 설치된 경우, 로드밸런서가 서로 다른 WAS로 유도하게 되면, 세션 불일치 문제가 발생할 수 있기 때문에, 여러 대의 서버를 한 가지 업무를 수행하도록 만드는 것이 클러스터링입니다.

단 프로그램 병렬화, 관리의 어려움이 있고, 제가 업무를 하는 상황에서 서버 클러스터링은 제가 아닌 인프라 팀으로 요청해야 하는 복잡함이 있어서 Java 코드로 CookieSerializer를 사용하는 방법을 택했습니다.

 

세션 클러스터링 종류

  1. WAS 구성 - 데이터별 primary/backup 등 지정해서 구성하며 별도의 서버 인프라 필요 없음
  2. 세션 서버 구성 - 별도의 세션 서버 두는 것 (Redis 등) 단, SPOF가 될 수 있음
  3. 세션 데이터 그리드 구성 - tomcat의 세션 복제 기능 사용하면 됨 - 이중화된 tomcat 간 multicast로 세션 공유
반응형

댓글