[Session] Spring Session 톺아보기

2022. 6. 29. 00:59Spring/Session

1. 개요

Spring Session은 Spring 애플리케이션에서 세션을 더 통합적이고 체계적으로 관리하기 위해 개발된 프로젝트이다. Spring Session은 HTTP, WebSocket, WebFlux 환경에서 언제든지 세션과 관련된 구현체를 변경할 수 있도록 제공된다.

2. 모듈

Spring Session을 활용하기 위해 제공되는 주요 모듈을 살펴보자.

  • Spring Session Core - 핵심 Spring Session 기능 및 API 제공
  • Spring Session Data Redis - Redis 구성을 지원하는 SessionRepositoryReactiveSesionRepository 구현체 제공
  • Spring Session JDBC - RDB 및 구성을 지원하는 SessionRepository 구현체 제공

3. Spring Session Core 톺아보기

Spring Session Core 라이브러리를 한 번 살펴보도록 하자.

EnableSpringHttpSession

이 어노테이션을 추가하면 사용자가 만든 SessionRepoisitory 구현체에서 지원하는springSessionRepositoryFilter라는 이름의 스프링 빈으로 SessionRepositoryFilter가 생성된다. 따라서 이 어노테이션을 사용하려면 SessionRepository 빈을 반드시 만들어야 한다.

@Configuration
@EnableSpringHttpSession
public class SpringHttpSessionConfig {

  @Bean
  public MapSessionRepository sessionRepository() {
    return new MapSessionRepository(new ConcurrentHashMap<>());
  }
}

그렇다면 SessionRepositoryFilterSessionRepository는 뭘까? 먼저 SessionRepositoryFilter부터 알아보자.

SessionRepositoryFilter

이 필터는 SessionRepository에서 지원하는 HttpSession의 구현체를 사용하여 HttpServletRequest를 래핑하는 책임을 수행한다. 글로만 보면 와닿지 않을 것이니 코드를 살펴보도록 하자.

@Order(SessionRepositoryFilter.DEFAULT_ORDER)
public class SessionRepositoryFilter<S extends Session> extends OncePerRequestFilter {

    @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);

            SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response);
            SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, response);

            try {
                filterChain.doFilter(wrappedRequest, wrappedResponse);

            finally {
                wrappedRequest.commitSession();
            }
    // ...
}

SessionRepositoryFilter는 위와 같이 HTTP 요청과 응답을 각각 SessionRepositoryRequestWrapperSessionRepositoryResponseWrapper로 한 번 감싼 후 filterChain에 태워보낸다. 이렇게 함으로써 필터를 거친 후 우리가 사용하는 인터셉터, 컨트롤러 등에서 부가적인 기능을 사용할 수 있게 되는 것이다. 로직을 수행한 후에 다시 이 필터로 돌아왔을 때 warppedRequest.commitSession()을 통해 세션을 저장한다.

SessionRepositoryRequestWrapper 클래스의 핵심 부분을 살펴보면 다음과 같다.

private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper {

    private void commitSession() {
        HttpSessionWrapper wrappedSession = getCurrentSession();
        if (wrappedSession == null) {
            if (isInvalidateClientSession()) {
                SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this, this.response);
            }
        }
        else {
            S session = wrappedSession.getSession();
            clearRequestedSessionCache();
            SessionRepositoryFilter.this.sessionRepository.save(session);
            String sessionId = session.getId();
            if (!isRequestedSessionIdValid() || !sessionId.equals(getRequestedSessionId())) {
                SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this, this.response, sessionId);
            }
        }
    }
    // ...
}

commitSession()은 현재 세션을 검증하고 세션 저장소에 저장하는 역할을 하는 메서드이다. 여기서 반환값이 HttpSessionWrapper인데 조금 있다가 알아보기로 하자.

이 메서드의 처리 과정은 간략히 다음과 같다.

  1. getCurrentSession()을 통해 현재 세션을 가져온다.
  2. 값이 없다면 클라이언트 세션의 유효성 검증을 수행한다.
  3. 값이 들어있다면 세션 저장소에 세션을 저장한다. 그 후에 응답으로 내려줄 세션 ID를 SessionRepositoryFilterhttpSessionIdResolver를 통해 저장한다.
💡 `HttpServletRequestWrapper`는 `HttpServletRequest`를 최소한으로 구현한 구현체로 개발자들이 이를 상속해서 쉽게 사용하도록 서블릿 표준으로 지원하는 클래스다.

 

위 과정을 정리하면 다음과 같다.

  1. EnableSpringHttpSession 어노테이션을 사용하여 SessionRepositoryFilter를 생성한다. 이때 SessionRepository 빈을 꼭 함께 생성해야 한다.
  2. 생성한 후에 요청들은 필터에서 래퍼 클래스에 의해 한 번 감싸진다. SessionRepositoryFilter의 Order는 Integer.MIN_VALUE + 50이다. 이렇게 필터의 앞쪽에 배치되는 이유는 이후의 필터에서도 래핑된 request, response를 사용할 수 있도록 하기 위함이다.
  3. 웹 서비스 로직이 모두 수행되고 다시 이 필터로 돌아왔을 때 warppedRequest.commitSession()을 통해 세션을 저장하고 응답에 세션 ID를 세팅한다.

SessionRepository

이름에서 알 수 있듯이 세션 저장소에 대한 인터페이스이다. Spring Session에서는 세션 저장소를 이 인터페이스의 구현체를 통해 세션을 저장 및 관리한다. MapSessionRepository는 Spring Session Core 라이브러리에서 제공하는 기본적인 SessionRepository 구현체다. 다시 말하면 SessionRepository 인터페이스를 구현하여 빈으로 등록하기만 하면 해당 구현체로 Spring Session Core를 사용할 수 있는 것이다. Redis, Mongo 등과 같은 것도 마찬가지이다.

CookieSerializer

EnableSpringHttpSession을 살펴보면 @Import(SpringHttpSessionConfiguration.class) 구문을 볼 수 있다. SpringHttpSessionConfiguration은 웹 환경에서 Spring Session의 기본적인 세팅을 설정하는 클래스이다. 이 클래스에서 살펴볼 것은 CookieSerializer이다.

기존에 톰캣에서 세션을 관리할 때는 세션 쿠키의 키는 JSESSIONID로 세팅되어 내려왔지만 Spring Session을 적용하고 나면 키가 SESSION으로 바뀐다. Spring Session에서는 CookieSerializer 인터페이스를 두고 구현체를 통해 세션 쿠키를 처리한다. Spring Session Core에서는DefaultCookieSerializer라는 클래스로 기본 구현체를 제공한다. 핵심 코드를 잠깐 훑어보고 가자.

public class DefaultCookieSerializer implements CookieSerializer {

    private String cookieName = "SESSION";
    private boolean useHttpOnlyCookie = true;
    private String sameSite = "Lax";
    // 기타 다른 필드들

    @Override
    public List<String> readCookieValues(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        List<String> matchingCookieValues = new ArrayList<>();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if (this.cookieName.equals(cookie.getName())) {
                    String sessionId = (this.useBase64Encoding ? base64Decode(cookie.getValue()) : cookie.getValue());
                    if (sessionId == null) {
                        continue;
                    }
                    if (this.jvmRoute != null && sessionId.endsWith(this.jvmRoute)) {
                        sessionId = sessionId.substring(0, sessionId.length() - this.jvmRoute.length());
                    }
                    matchingCookieValues.add(sessionId);
                }
            }
        }
        return matchingCookieValues;
    }

    @Override
    public void writeCookieValue(CookieValue cookieValue) {
        HttpServletRequest request = cookieValue.getRequest();
        HttpServletResponse response = cookieValue.getResponse();
        StringBuilder sb = new StringBuilder();
        sb.append(this.cookieName).append('=');
        String value = getValue(cookieValue);
        if (value != null && value.length() > 0) {
            validateValue(value);
            sb.append(value);
        }
        // 쿠키 옵션 설정 로직 생략
        response.addHeader("Set-Cookie", sb.toString());
    }

    // ...
}

HttpSessionWrapper

이 클래스는 HttpSessionAdapter를 상속하고 invalidate()만 오버라이딩한 클래스다. 실제로 HttpSession을 구현한 클래스는 HttpSessionAdapter이다.

HttpSessionAdapter

이 클래스가 실제로 Spring Session의 SessionHttpSession으로 변환해주는 역할을 한다. Spring Session에서는 세션을 Session 인터페이스의 구현체로 처리한다. SessionRepositoryFilter의 선언부의 제네릭을 보면 <S extends Session>을 볼 수 있다. Spring Session Core에서는 Session의 구현체로 MapSession을 제공한다.

public class SessionRepositoryFilter**<S extends Session>** extends OncePerRequestFilter {
💡 Adapter로 끝나는 클래스들은 디자인 패턴 중 Adapter 패턴을 따른 클래스로 유추할 수 있다. 이 글에서는 디자인 패턴을 다루지 않으므로 필자의 다른 글을 참고하면 좋겠다.

Spring Session 핵심 인터페이스 정리

  1. 세션을 Session 인터페이스로 다루며 HttpSession을 구현하는 HttpSessionAdapter를 통해 변환한다. Core에서는 MapSession을 제공한다.
  2. 세션 저장소는 SessionRepository 인터페이스로 다루며 Core에서는 MapSessionRepository를 제공한다.
  3. 세션 쿠키는 CookieSerializer 인터페이스로 다루며 Core에서는 DefaultCookieSerializer를 제공한다. 이 클래스에서 쿠키 이름을 SESSION으로 지정하기 때문에 세션 ID 키가 JSESSIONID에서 SESSION으로 바뀐다.

이벤트

Spring Session에서는 세션의 생성, 삭제, 제거, 만료 이벤트를 제공한다. 따라서 Spring Session을 이용할 때 해당 이벤트 발생 시 처리해야 하는 로직이 있다면 상황에 맞는 이벤트를 사용하자.

  • abstract AbstractSessionEvent extends ApplicationEvent
    • 세션 ID와 세션을 가지고 있다.
  • SessionCreatedEvent extends AbstractSessionEvent
    • 세션이 생성되었을 때 발생하는 이벤트
  • SessionDeletedEvent extends AbstractSessionEvent
    • 세션이 삭제를 통해 파괴되었을 때 발생하는 이벤트
  • SessionDestroyedEvent extends AbstractSessionEvent
    • 세션이 파괴되었을 때 발생하는 이벤트
  • SessionExpiredEvent extends AbstractSessionEvent
    • 세션이 만료되었을 때 발생하는 이벤트

정리

  1. Spring Session는 @EnableXXXHttpSession 어노테이션을 적용하고 SessionRepository 인터페이스를 구현한 빈을 생성하여 사용할 수 있다.
  2. Spring Session에서는 세션을 Session 인터페이스로 다루며 세션 저장소는 SessionRepository로 다룬다.
  3. SessionRepositoryFilterspringSessionRepositoryFilter라는 이름의 빈으로 등록되며 순서는 필터의 앞단에 배치되어 이후의 필터 및 서비스 로직에서 부가 기능을 사용할 수 있다.

다시 돌아보면 Spring Session이 제공하는 것은 심플하다.

  1. 세션과 관련된 것들을 인터페이스화하여 다형성을 제공한다.
  2. 필터에서 요청과 응답을 감싸 부가 기능을 제공한다.

Spring Session Core를 이해했다면 Spring Session Data Redis나 Mongo, JDBC와 같은 라이브러리도 쉽게 이해할 수 있을 것이다. 이 글을 쓰게된 이유도 프로젝트에서 Redis로 세션 관리를 하려다 내부에서 도저히 무슨 일이 일어나는지 모르겠어서였다. 공식 문서만으로는 융통성이 부족한 내 머리로 이해할 수 없었다… 이 글이 나같이 코드를 직접 뜯어봐야 이해가 되는 사람들에게 도움이 되었으면 한다.