Post

스프링 세션 Key가 두 개 생성되는 문제와 인코딩 된 값의 비밀

Spring Session을 이용하면서 세션이 두 개 생성되는 문제를 해결하는 과정을 정리해보았습니다.

🔍 Key가 두 개 생성되는 문제

세션을 저장하는 로직은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component  
class SessionFilter(  
    private val redisSessionRepository: RedisSessionRepository,  
): OncePerRequestFilter() {  
  
    val log = logger()  
  
    override fun doFilterInternal(  
	    request: HttpServletRequest,  
	    response: HttpServletResponse,  
	    filterChain: FilterChain  
	) {  
  
	    val session = getSessionFromCookie(request)          --- 1
	    if (session == null) {  
	        val sessionId = request.getSession(true).id      --- 2
	        myRedisSessionRepository.setSession(sessionId)   --- 3
	    }  
  
	    filterChain.doFilter(request, response)  
	}
}
  1. 쿠키에 세션에 대한 정보가 있는지 확인한다.
  2. 세션이 없다면 새로운 세션을 생성한다.
  3. 새로 발급받은 세션을 레디스에 저장한다.

1번.png

결과를 보면 세션이 두 개가 저장되었음을 확인할 수 있습니다. 첫 번째는 위의 코드에 의해 저장된 것이고 2번 세션은 왜 저장된 것일까요?

이를 해결하기 위해 디버깅 모드로 코드를 하나씩 따라 가 보겠습니다.

  1. getSession()을 호출하면 HttpServletRequestWrapper의 getSession이 호출된다.
    1
    2
    3
    4
    5
    6
    
    class HttpServletRequestWrapper {
     @Override  
     public HttpSession getSession(boolean create) {  
         return this._getHttpServletRequest().getSession(create);  
     }
    }
    
  2. SessionRepositoryFilter가 동작한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    @Override  
    public HttpSessionWrapper getSession(boolean create) {  
      // 1. Session이 있는지 확인합니다.
     HttpSessionWrapper currentSession = getCurrentSession();  
     if (currentSession != null) {  
        return currentSession;  
     }  
    
      // 2. Session을 생성하고 등록합니다.
     S session = SessionRepositoryFilter.sessionRepository.createSession();  
     session.setLastAccessedTime(Instant.now());  
     currentSession = new HttpSessionWrapper(session, getServletContext());  
     setCurrentSession(currentSession);  
     return currentSession;  
    }
    
  3. RedisSessionRepository가 동작한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    public RedisSessionRepository {
     @Override  
     public RedisSession createSession() {  
         MapSession cached = new MapSession();  
         cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval);  
         RedisSession session = new RedisSession(cached, true);  
         session.flushIfRequired();  // 
         return session;  
     }
    }
    

여기서 flush모드가 yes라면 곧바로 캐시에 업데이트 되고 no라면 응답을 보내기 전에 DispatcherServlet에서 save() 메서드를 호출하여 저장하는 작업을 거칩니다. 즉, 세션을 새로 발급하면 SessionRepositoryFilter가 동작하여 Redis에 세션을 저장해줍니다.

따라서 request.getSession(true)를 호출하면 세션을 자동으로 저장해주기 때문에 myRedisSessionRepository.setSession(sessionId) 코드는 필요가 없습니다.

🔍 쿠키와 Base64 인코딩

웹 브라우저에서 확인해보면 SESSION이 인코딩되어서 들어간 것을 확인할 수 있습니다. (세션이 두 개 있는데 이는 다음 글과 관련이 있습니다.) 2번.png

configuration에서 session 생성을 false로 두면 된다고 하던데 저는 잘 동작 되지 않았습니다. (스프링의 세션 생성 기능을 끈다는 의미이니까 아마 제대로 동작했다면 위의 문제도 같이 해결되었을 것 같네요.)
다음 링크에서 힌트를 얻어 DefaultCookieSerializer부터 디버깅 모드로 하나씩 찾아나갔습니다.

DefaultCookieSerializer - StackOverflow

val sessionId = request.getSession(true).id 다음 코드를 호출하는 순간 RedisSessionRepository에서 세션을 생성하고 이를 현재 세션으로 넣은 다음 헤더에 추가해줍니다. 즉, 추가적으로 header에 담는 로직이 필요가 없습니다. 실제로 세션이 2개가 들어왔지만 하나는 base64로 인코딩 한 값입니다.

이를 추적하면서 뽑아낸 중요한 코드 리스트입니다.

  1. Session이 있는지 확인한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    
    @Override  
    public HttpSessionWrapper getSession(boolean create) {  
     HttpSessionWrapper currentSession = getCurrentSession();  
    
     // 현재 세션이 존재하면 return 
     if (currentSession != null) {  
        return currentSession;  
     }  
        
     // 캐시되어 있는 세션이 있는지 확인
     S requestedSession = getRequestedSession();  
     if (requestedSession != null) {  
        if (getAttribute(INVALID_SESSION_ID_ATTR) == null) {  
           requestedSession.setLastAccessedTime(Instant.now());  
           this.requestedSessionIdValid = true;  
           currentSession = new HttpSessionWrapper(requestedSession, getServletContext());  
           currentSession.markNotNew();  
           setCurrentSession(currentSession);  
           return currentSession;  
        }  
     }
    
     // create의 속성이 fasle라면 세션을 생성하지 않는다.
     if (!create) {  
         return null;  
     }
    
     // create = true라면 새로운 세션을 생성한다.
     S session = SessionRepositoryFilter.this.sessionRepository.createSession();  
     session.setLastAccessedTime(Instant.now());  
     currentSession = new HttpSessionWrapper(session, getServletContext());  
     setCurrentSession(currentSession);  
     return currentSession;
    }
    
  2. 지정한 스토리지에서 Session을 생성한다. (flush모드에 따라 저장 시점이 다르다)
    1
    2
    3
    4
    5
    6
    7
    8
    
    @Override  
    public RedisSession createSession() {  
     MapSession cached = new MapSession();  
     cached.setMaxInactiveInterval(this.defaultMaxInactiveInterval);  
     RedisSession session = new RedisSession(cached, true);  
     session.flushIfRequired();  
     return session;  
    }
    
  3. 이후 Response 보내기 전에 세션을 Base64로 인코딩하여 header에 저장한다.
    1
    2
    3
    4
    5
    6
    7
    8
    
    @Override  
    public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {  
     if (sessionId.equals(request.getAttribute(WRITTEN_SESSION_ID_ATTR))) {  
        return;  
     }  
     request.setAttribute(WRITTEN_SESSION_ID_ATTR, sessionId);  
     this.cookieSerializer.writeCookieValue(new CookieValue(request, response, sessionId));  
    }
    
    1
    2
    3
    4
    5
    6
    
    @Override  
    public void writeCookieValue(CookieValue cookieValue) {  
     String value = getValue(cookieValue);   // getValue()에서 인코딩함.
     ...
     response.addHeader("Set-Cookie", sb.toString());  // 헤더에 저장
    }
    

결론

  1. 스프링 세션을 적용하면 session이 생성되는 시점에 데이터를 저장해준다.
  2. 세션을 쿠키로 반환할 때는 Base64로 인코딩하면 반환한다.
This post is licensed under CC BY 4.0 by the author.

© bear-su. Some rights reserved.