ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Springboot Redis 세션 만료 감지하기
    개발/Spring(boot) 2024. 11. 24. 22:29

    업무를 하던 도중 사용자의 동선을 파악하고 싶다는 요구사항이 있었다.

    그리고 이 글은 그 과정에서 redis가 지원해주는 기능에 대해 적어보고자 한다.

     

    먼저 Springboot의 AOP를 사용해 유저 정보/호출 API를 저장하도록 했고,

    FE분께도 동일한 DB에 URL을 저장할 수 있도록 하는 API를 하나 만들어서 어느정도(?) 해결했다.


    해당 기능 구현을 간단히 설명하자면 아래와 같다.

     

    1. FE측에서 직접 API를 호출해 데이터를 저장

     

    sample 페이지에 유저가 접근했다면 FE측에서는 BE에 아래와 같이 요청을 보낸다.

    Requst url: https://sample.com/api/logging

    Type: POST

    {
       url: https://sample.com/sample
    }

    그러면 url과 유저 정보를 가지고 아래와 같이 데이터를 쌓게 된다.

    {

       user : admin

       requestUrl : https://sample.com/sample

    }


    2. DB측에서 AOP를 이용해 데이터를 저장(로그인 요청)

     

    Request url: https://sample.com/login

    Type: Post

    {

       id : test1
       pw: test2

    }

     

    이후 백엔드에서 AOP를 이용해 아래와 같이 데이터를 저장한다.

    {

       user: admin

       requestApi: /login

    }

     

    그런데, 요구사항 중 유저의 서비스 이용 시간을 파악하고 싶다는 아래와 같은 내용이 있었다.

    - 서비스 이용 시간 파악 : 최초 로그인 시각부터 서비스 종료 시각(ex. 마지막 세션 만료 시각)

     

    먼저 우리 서비스는 redis를 통해 세션을 관리하고 있으므로 쿠키/세션이 만료되기 전 까지는

    유저가 언제 로그인하고 로그아웃 했는지 정확하게 파악하기는 힘들어보였지만 최대한 파악해보려고 했다.

     

    이제 springboot에서 redis의 정보를 가져오는 방법에 대해서 작성해보겠다.


    1. 세션 만료 감지하기.

    처음에는 Springboot에서 세션을 발급해주니 찾아보면 세션 만료 역시 감지할 수 있으리라 생각되어 GPT가 아래와 같은 코드를 알려줬다.

    import org.springframework.context.ApplicationListener;
    import org.springframework.session.events.SessionDestroyedEvent;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SessionDestroyedListener implements ApplicationListener<SessionDestroyedEvent> {
    
        @Override
        public void onApplicationEvent(SessionDestroyedEvent event) {
            // 세션 만료 시 로직
            System.out.println("세션 만료됨: " + event.getId());
        }
    }

    하지만 이 코드는 동작하지 않았고 원인을 파악해보니 redis를 사용해 세션을 관리한다면 설정이 필요하다는 것을 깨닳았다.

     

    2. 세션 만료 감지를 위해 필요한 설정

    원인 파악을 위해 GPT에게 물어봤고 중 아래와 같은 내용이 나왔다.

     

    Redis 세션 삭제 트리거가 작동하지 않는 문제

    Redis에 세션이 만료될 때, Spring Session이 이를 인지하여 이벤트를 트리거해야 합니다. 그러나 Redis 자체에서 세션 만료가 감지되지 않으면 SessionDestroyedEvent가 발생하지 않을 수 있습니다.

    Redis 설정에 문제가 없다면, 다음을 확인해 보세요:

    • Redis 만료 알림 설정: Redis에서 만료된 키에 대한 이벤트가 제대로 작동하려면 notify-keyspace-events가 설정되어 있어야 합니다. 이 설정이 되어 있지 않으면 세션이 만료되더라도 이벤트가 트리거되지 않을 수 있습니다.
    redis-cli config set notify-keyspace-events Ex

    이 설정은 Redis에서 세션이 만료될 때, Spring Session이 이를 감지할 수 있도록 해줍니다.


    결론부터 말하자면 redis-cli에서 위 설정을 해주었더니 잘 동작하게 되었다.

    그리고 이 글은 redis에서 지원해주는 notify-keyspace-events에 대해 작성하기 위한 글 이다.

     

    redis는 keyspace notification이라는 기능을 제공하고 docs는 다음과 같다.

    https://redis.io/docs/latest/develop/use/keyspace-notifications/

     

    Redis keyspace notifications

    Monitor changes to Redis keys and values in real time

    redis.io

    즉 redis의 keyspace notification을 사용하면 Pub/Sub 방식으로 redis에 data set 관련 이벤트를 수신할 수 있다.

    설정할 수 있는 데이터는 다음과 같다.

     

    • A: 모든 이벤트 활성화 (기본적으로 비활성화)
    • g: generic 명령어에 대한 이벤트 (DEL, EXPIRE, RENAME 등)
    • $: string 타입 키에 대한 이벤트
    • l: list 타입 키에 대한 이벤트
    • s: set 타입 키에 대한 이벤트
    • h: hash 타입 키에 대한 이벤트
    • z: sorted set 타입 키에 대한 이벤트
    • x: 만료된 키 이벤트 활성화
    • e: 만료 키 이벤트 활성화 (expire)
    • K: 키 공간 이벤트 활성화
    • E: 키 이벤트 활성화 (일반적인 이벤트)
    • t: stream 타입 키에 대한 이벤트
    • m: module 타입 키에 대한 이벤트
    • d: keyspace 이벤트 비활성화

     

    x와 e가 비슷해보여 찾아보니 e는 키가 만료되어 유효하지 않을 때, x는 키카 메모리에서 삭제될 때 이벤트를 발생시킨다고 한다.

    그리고 e와 x를 설정해 세션 만료 알림 이벤트를 받을 수 있게 되었다.


    하지만 한 가지 의문점이 남아있다.

    springboot에서 아래 코드를 작성하고 redis-cli에서 ex 설정을 해서 세션 만료 시점을 알 수 있게 되었다.

    import org.springframework.context.ApplicationListener;
    import org.springframework.session.events.SessionDestroyedEvent;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SessionDestroyedListener implements ApplicationListener<SessionDestroyedEvent> {
    
        @Override
        public void onApplicationEvent(SessionDestroyedEvent event) {
            // 세션 만료 시 로직
            System.out.println("세션 만료됨: " + event.getId());
        }
    }

    이제부터 내부 구현을 조금 알아보고자 한다.

    위에 작성된 다양한 이벤트들은 어떻게 springboot에서 받을 수 있을까?

    - 스프링 부트 자체적으로 구현된 KeyExpirationEventMessageListener를 상속받는 방법

    - MessageListener를 상속받아 직접 구현하는 방법

    두 가지가 있다고 한다.

     

    1. KeyExpirationEventMessageListener 내부 구현

    결론부터 말하자면 MessageListener를 상속받는 것을 확인할 수 있다.

     

    EA라는 redis keyspace notification에서 봤던 익숙한 내용도 보인다.

    실제로 init() 코드에서 아래와 같이 이벤트를 등록하는 것으로 보인다.

    그리고 메시지는 아래와 같이 ApplicationEvnetPublisher를 통해 publish되는 걸 볼 수 있다.


    2. SessionDestoryedEvent 내부 구현

    역시 MessageListener를 상속받는다.

    이를 호출하는 코드를 찾아보니 RedisIndexedSessionRepository가 보인다.

    RedisIndexedSessionRepository는 MessageListener를 상속받는다.

    그리고 세션 관련된 설정으로 보이는 코드가 있다.

    전부 이해하기는 힘들지만 저장하는 데이터 형식들이 보이는 것 같다.

    Redis에 저장된 데이터를 보니 익숙한 글씨들이 configure에 보이는 것을 확인할 수 있다.

     

    RedisIndexedSessionRepository 내부에서 메시지 처리하는 과정을 더 자세히 볼 수 있었다.

    @Override
    public void onMessage(Message message, byte[] pattern) {
        byte[] messageChannel = message.getChannel();
    
        if (ByteUtils.startsWith(messageChannel, this.sessionCreatedChannelPrefixBytes)) {
            // TODO: is this thread safe?
            @SuppressWarnings("unchecked")
            Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer.deserialize(message.getBody());
            handleCreated(loaded, new String(messageChannel));
            return;
        }
    
        byte[] messageBody = message.getBody();
    
        if (!ByteUtils.startsWith(messageBody, this.expiredKeyPrefixBytes)) {
            return;
        }
    
        boolean isDeleted = Arrays.equals(messageChannel, this.sessionDeletedChannelBytes);
        if (isDeleted || Arrays.equals(messageChannel, this.sessionExpiredChannelBytes)) {
            String body = new String(messageBody);
            int beginIndex = body.lastIndexOf(":") + 1;
            int endIndex = body.length();
            String sessionId = body.substring(beginIndex, endIndex);
    
            RedisSession session = getSession(sessionId, true);
    
            if (session == null) {
                logger.warn("Unable to publish SessionDestroyedEvent for session " + sessionId);
                return;
            }
    
            if (logger.isDebugEnabled()) {
                logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
            }
    
            cleanupPrincipalIndex(session);
    
            if (isDeleted) {
                handleDeleted(session);
            }
            else {
                handleExpired(session);
            }
        }
    }

    역시 전부 이해하긴 힘들지만 생각보다 읽히는 코드가 나왔고

    메시지를 받았을 때 만료/삭제 메시지인 경우 이벤트를 처리하는 것으로 보인다.

    그리고 여기서도 메시지는 eventpublisher를 사용해 publish되는 걸 볼 수 있다.


    Springboot에서 Redis를 어떤 방식으로 다루는지 조금 알아보았고, 생각보다 읽을만한 코드가 나와 신기했다.

    Redis에서 keyspace-event는 과하게 발생할 수 있으므로 조심해야겠지만 현재 서비스는 우려할만한 트래픽이 없기에 사용해도 괜찮아 보인다.

    반응형

    '개발 > Spring(boot)' 카테고리의 다른 글

    조직도를 만들어보자  (1) 2024.12.08
    ApplicationEventPublisher와 EventListener  (0) 2024.12.01

    댓글

Designed by Tistory.