[Webflux] 세션(WebSession)의 생성, 작동 원리( 코드 분석 )

유알·2023년 9월 15일
0

[Spring]

목록 보기
15/17

현재 진행 중인 프로젝트를 진행하다가 조금 특이한 기능 구현이 필요했다. API Gateway를 Spring Webflux로 구현하고 있었는데, 통합된 spring security 필터가 아닌, 모듈화되고 분리된 spring security 구현이 필요하였다.(자세한 내용은 주제에서 벗어나므로 설명하지 않겠다.)

그래서 WebFlux환경에서 Spring Session이 정확히 어떻게 동작하는지 파악이 필요했다.

내용은 공식문서와 javadoc, ide를 이용한 디버깅에 기반하여 분석했다.

1. Intro

Spring Webflux에서는 Servlet 기반의 spring mvc와 다르게 WebSessionStore이라는 커스터마이징 포인트를 제공한다.

따라서 이를 구현하는 것으로 세션이 관리되는 방법을 커스터마이징 할 수 있다.

이 인터페이스는 다음과 같이 구성되어 있다.

public interface WebSessionStore {

	Mono<WebSession> createWebSession();

	Mono<WebSession> retrieveSession(String sessionId);

	Mono<Void> removeSession(String sessionId);

	Mono<WebSession> updateLastAccessTime(WebSession webSession);

}

자세한 주석이나 설명은 JavaDoc을 참고하길 바란다. 이 인터페이스를 구현하여 Spring Bean Container에 등록하면, 자동으로 구성을 진행하고, 세션을 관리해준다는 것이다. 물론, 저 WebSession 또한 인터페이스 이므로, 적절한 구현체를 사용하거나 직접 구현해야한다.

Spring Session의 공식문서의 Integration With WebSession 부분에 가면, 간단하게 설정할 수 있는 @EnableRedisWebSession 어노테이션을 제공한다.

@Configuration(proxyBeanMethods = false)
@EnableRedisWebSession
public class SessionConfig {

이 경우, 우리가 WebSessionStore를 등록할 필요가 없다. 자체 구현체를 만들어주며, 대신 그 안의 Repository를 우리가 등록해주어야한다.

나의 경우는 정확히 이게 어떻게 이루어지는지, 정확히 어느 시점에 세션이 생성되는지 확인할 필요가 있었다.

그 결과를 공유하려고 한다.

2. 상세 동작 분석

내용 추가 : 20230916
아래의 설명이 다소 장황한데, 하루 지난 시점인 지금에서 보니, 이 코드를 한번 보고 들어가면 더 쉬울거라고 생각해서 코드 캡처를 첨부한다.

스프링 세션에서 자동으로 등록하는(webflux 기준) 빈이다. 이 메서드를 보면, 모든 과정이 담겨있다. 우리가 등록한 ReactiveSessionRepository를 주입받아서 어떻게 WebSessionManager를 만드는지, 그리고 프레임워크가 어떻게 우리가 등록한 세션을 호출하는지 알 수 있다.

발견하게 된 계기는 아무리 ApplicationContext를 뒤져도 WebSessionStore타입의 빈을 찾을 수가 없어서 디버그를 돌리던 중 발견하게 되었다.(내부적으로 만들어서 WebSessionManager만 빈을 등록하니, 당연히 찾을 수가 없는 것이었다.)

@RestController
public class TestController {
    @GetMapping("/hello")
    public String hello(){
        return "hello";
    }
}

테스트를 위한 간단 컨트롤러를 정의하였다.

@EnableWebFluxSecurity
@SpringBootApplication
public class ApiGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class, args);
    }

    @Bean
    SecurityWebFilterChain filterChain(ServerHttpSecurity http) throws Exception {
        SecurityWebFilterChain build = http
                .securityMatcher(exchange -> {
                    System.out.println();
                    return exchange.getPrincipal()
                            .log()
                            .defaultIfEmpty(new Principal() {
                                @Override
                                public String getName() {
                                    return "anonymous";
                                }
                            })
                            .doOnNext(principal -> {
                                System.out.println(principal);
                            })
                            .then(exchange.getSession())
                            .doOnNext(session -> {
                                System.out.println(session.getId());
                                System.out.println(session.getCreationTime().atZone(ZoneId.systemDefault()).toLocalDateTime());
                                System.out.println(session.getMaxIdleTime());
                                System.out.println(session.getAttributes());

                            })
                            .doFinally(signalType -> System.out.println("signalType"))
                            .then(
                                    Mono.defer(ServerWebExchangeMatcher.MatchResult::match)
                            );
                })
                .authorizeExchange(opt ->
                        opt.anyExchange().permitAll()
                )
                .build();

        return build;
    }

}

디버그 포인트를 잡기 위해, securityMatcher를 통해 ServerWebExchangeMatcher를 공급받아 디버깅을 진행해 보았다.

기본 환경은 Redis를 도커로 띄우고 간단하게 설정했다.

@Configuration(proxyBeanMethods = false)
@EnableRedisWebSession
public class SessionConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }
}

3. 디버깅 진행

자 핵심적인 부분에 브레이크 포인트를 찍고 디버그 모드로 실행을 시켰다.

1. WebSessionStore 주입

가장 먼저 포인트에 잡힌 부분은 다음과 같다.

Webflux에서 제공하는 WebSessionStore API가 어떻게 사용되는지 알려주고 있다.

프레임 워크 측에서 호출하는 세션 관련 메서드는 DefaultWebSessionManager에 있는 것으로 보인다

ServerWebExchangeMatcher 에서 getSession()을 호출할 시, DefaultWebSessionManager를 프록시하여 호출하는 것을 보고 그렇게 생각했다.

DefaultWebSessionManager의 경우 내부에 WebSessionStore 필드를 가지고 있고, 이를 사용자가 등록할 경우, Spring Bean 생성시 setter로써 주입해주는 방식이다.

우리의 경우 Redis 세션을 활성화 하여 자동으로 생성된, SpringSession 뭐시기가 등록되는게 보인다.

기본값은 보이다 싶이 InMemory로 구현된 구현체가 들어있다.

2. 요청 날리기

다음 터미널로 GET 요청을 날렸다.

>> curl http://localhost:8080/hello

3. 세션은 지연 생성된다.

요청을 날리고 가장 먼저 걸리는 브레이크 포인트는 다음과 같다.

Project Reactor에 익숙하신 분들은 바로 알겠지만, 여기에서 나는 return에 브레이크 포인트를 찍었기 때문에 가장 먼저 걸린 것이다.

Mono.defer()로 선언이 되어 있기 때문에, 당연히 트리거가 되었을 때, 저기에 적힌 로직이 실행되며, 그말은 세션을 바로 생성하지 않는다는 것이다.

로직 부분을 보면, 요청에서 세션을 찾고, 만약 없으면 세션을 만드는 로직이 포함되어 있다.
또한 중요한 것은 Response가 commit되기 전에, 실행될 콜백을 등록하고 있는데,

	private Mono<Void> save(ServerWebExchange exchange, WebSession session) {
		List<String> ids = getSessionIdResolver().resolveSessionIds(exchange);

		if (!session.isStarted() || session.isExpired()) {
			if (!ids.isEmpty()) {
				// Expired on retrieve or while processing request, or invalidated..
				if (logger.isDebugEnabled()) {
					logger.debug("WebSession expired or has been invalidated");
				}
				this.sessionIdResolver.expireSession(exchange);
			}
			return Mono.empty();
		}

		if (ids.isEmpty() || !session.getId().equals(ids.get(0))) {
			this.sessionIdResolver.setSessionId(exchange, session.getId());
		}

		return session.save();
	}

최종적으로는 주어진 WebSession의 save를 호출하여, 정해진 세션 저장 로직을 따르고 있다.

그 다음 일부러 필터체인의 return 전에 브레이크 포인트를 찍었다.

세션 모노의 부분이 전부 비어 있고, supplier로 람다식이 들어 있어서, 누가 봐도 session을 요청할 때까지 최대한 지연하여 세션을 생성하려는 의도가 보인다.

4. 첫 요청시 Principal

첫 요청시 Principal은 어떻게 잡힐지 궁금했다.

결과적으로 exchange.getPrincipal()을 호출하게 되면, Mono.empty가 반환되었다.

5. getSession() 호출시 세션 생성

여기서는 두가지 경우로 나눠서 설명하려고 한다.

5-1. Controller 앞에서 getSession()을 호출한 경우

이전 상황이다


다음 브레이크 포인트로 넘어가기 전, 세션 생성 과정이 진행되게 된다.
그 이유는 exchange.getSession()을 호출했기 때문이다.

아까 처음 봤던,DefaultWebSessionManager 의 getSession이 호출되고, 로직이 진행된다.

그 다음으로는 세션 생성이 진행되는데, 구현체중 하나인 MapSession을 생성하는 것을 볼 수 있다.

그 다음 나의 경우 Redis Session을 활성화 했기 때문에, Redis Session으로 한번 감싼다.

여기서 this.session이 우리가 등록한 WebSessionStore이다

둘다 Session 구현체이니, 내부에 세션을 두고 일부 기능을 더해 프록시처럼 호출하려는 의도이다.

그 다음도 중요한데, 아까 WebFlux에서는 WebSessionStore이라는 인터페이스를 통해 사용자에게 커스터마이징을 제공한다고 했다. 우리는 레디스 세션을 활성화 시켰으므로,

이렇게 생긴 구현체가 들어갔으며, 이 구현체에서는 내부 클래스에 WebSession을 구현하여 이것으로 한번 더 감싼다. 구현체를 보면 알겠지만, 주로 상태를 주어진 세션에 의존하는 것이 아니라 직접 컨트롤 하기 위한 로직이 작성되어있다.

이런 과정을 통해 우리는 세션을 전달 받게 되는거다.
핵심은 다음과 같다.

  • Webflux는 WebSessionStore를 통해 확장 포인트를 제공한다.
  • Spring Session에서 레디스를 활성화시키면, SpringSessionWebSession이라는 WebSessionStoreStore 구현체를 등록해준다.
  • SpringSessionWebSessionStore 는 내부적으로 ReactiveSessionRepository를 주입받는다.
  • 세션은 호출되었을 때 지연 생성된다.

5-2. Controller 앞에서 getSession()을 호출하지 않은 경우

만약 필터단에서 getSession을 호출하지 않는다면 어떻게 될까?

간단하게 정답만 말하면, 필터가 다 진행되고, 컨트롤러 메서드가 호출되기 직전 호출된다.

아마도 컨트롤러에서 매핑시 세션을 받아오는 것 같다

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글