시냅스

JWT 대신 Session 을 쓰는 이유 (Redis Session Clustering, Spring Security) 본문

Java, Spring

JWT 대신 Session 을 쓰는 이유 (Redis Session Clustering, Spring Security)

ted k 2023. 7. 17. 20:11

Session vs JWT

현재 프로젝트에서는 Redis Session Clustering 을 사용하고 있습니다.

사용자 정보에 대해 JWT 대신 Session 을 사용하기로 하였고 이유는 다음과 같습니다.

 

  1. JWT 를 사용하려는 이유는 세션에서 감당하는 부하를 줄이기 위함이다. (혹은 Stateless 하게 유지하기 위해)
  2. 다만, JWT 는 header, payload, signature 로 이루어진 구조로 모든 정보들을 노출시키고 있다. (HTTPS 를 사용한다고 하더라도...) 또한 대칭키를 알고 있고 토큰이 탈취당한 상황이라면 너무나도 쉽게 보안에 취약해진다.
  3. 이러한 문제를 최소화시키기 위해 Refresh Token 을 사용한다. 
  4. Access Token, Refresh Token 쌍으로 Access Token 의 만료시간을 짧게 설정하고 만료됐을 때 Access Token 을 Refresh Token 을 확인하여 새로 발급하겠다는 것이다.
  5. 그렇다면 서버에서는 Refresh Token 을 개별 사용자마다 갖고 있어야 한다.
    1. 대체로 Access Token 보다 만료 시간이 길다.
  6. 또한 Refresh Token 이 탈취되고 서버에서 Refresh Token 을 갱신하지 않았다면 다시 보안에 취약한 상태가 된다.
  7. 또한 JWT 는 모든 정보를 토큰 안에 담고 있으므로 세션 ID 보다 큰 크기를 갖고 있으므로 매 통신마다 주고 받아야 한다.
    1. Signature 를 통해 토큰이 유효한지 확인하려면 서버에서 다시 hashing 하고 decoding 해줘야한다.
  8. 종합하자면 Refresh Token 을 갖고 있어야 하기 때문에 똑같이 정보를 서버에서 저장하고 있어야 하고 덕분에 딱히 stateless 하지 않게 된다, 또한 JWT 특성상 정보를 지속적으로 노출시키고 있고 네트워크 통신에 cost 가 크다.

위와 같은 이유들로 JWT가 Session 에 비해 갖는 이점이 없는 것 같아 Session 을 사용하기로 하였습니다.

다만 분산 환경에서 Session 을 사용하려면 다시 고민이 필요하였습니다.

 

 

Sticky Session

Sticky Session 은 내가 접근한 서버로만 가겠다는 것입니다.

만약 A 서버에 로그인 했다면 나의 Session 정보는 A 세션에 있을 것이므로 A로만 접근하겠다는 것입니다.

다만 Sticky Session 은 Hotspot 문제를 일으킬 수 있고, Load Balancing 이 되지 않을 수 있습니다.

당연하게도, least connection 이나 static RR 알고리즘을 사용한다고 하더라도

내가 접근한 서버가 아니라면 요청하지 않을 것이기 때문입니다.

 

 

Tomcat Session Clustering

이러한 문제들 때문에 Tomcat 에서도 Session Clustering 을 제공합니다.

  • all-to-all
    • 세션의 모든 정보들을 다른 노드들에게 공유하는 것
    • 모든 정보를 다시 다른 서버에 복사해서 넘겨주기 때문에 부하가 크다.
  • primary-secondary
    • source-replica 와 비슷하고, source 나 replica 가 아니라면 다시 source 에 접근해서 정보를 가져오게 된다.
    • 따라서 세션 정보가 없는 서버에 접근했다면 세션 정보를 얻기 위해 source 와 통신해야 한다.

톰캣에서 제공하는 해법의 주된 것은 session 정보를 다른 서버에도 복제하겠다는 것입니다.

분산 환경을 쓰는 것은 많은 사용자에 대응하기 위함인데,

다수의 사용자 정보를 개별 서버에 다시 복제한다면 의미가 퇴색되는 것 같았습니다.

세션 정보에 대한 개별 서버에서의 저장과 이를 위한 네트워크 IO 가 발생할 수 있기 때문입니다.

 

따라서 분산환경에서 여러 애플리케이션에서 session 에 접근할 수 있는 제품이 필요했고,

DB 는 Disk IO 를 유발하므로 In-Memory Cache 를 사용하는 제품을 고려하였고

그 중 싱글스레드로 동시성 제어가 간편한 Redis 를 사용하기로 하였습니다. 

 

자세한 설정법과 Spring Security와 함께 사용하며 문제가 됐던 사항은 아래에서 설명하겠습니다.

 

 

Spring Security

https://liltdevs.tistory.com/157

 

Spring Security 공식 문서 기반 정리

Spring Security가 framework로 하는 일 ServletContext 내부로 가지 않고 Filter 수준에서 보안을 설정한다. 어플리케이션의 모든 상호작용에 사용자 인증 요구 디폴트 로그인 폼 생성 user 라는 이름과 콘솔

liltdevs.tistory.com

사용자 인증/인가 관련된 작업은 Spring Security 에 위임하고 있습니다.

물론 Spring Security 를 사용하지 않아도 가능하지만 제공해주는 이점이 더 크다고 판단하였습니다.

(권한 관련, 접근 관련, 인증/인가 관련 등등)

Spring Security 를 Session 방식으로 사용할 때 Spring Security 는 SPRING_SECURITY_CONTEXT 라는 것을 저장합니다.

 

 

SPRING_SECURITY_CONTEXT 는 말 그대로 사용자의 security context 입니다.

Spring Security 가 보안 관련 기능을 수행할 때 context 를 기준으로 하게 됩니다.

또한 로그인 할 때에는 인증 정보를 거치게 되면 자동으로 context 가 session 에 저장되기 때문에

(UserDetailsService 를 거칠 때 혹은 SecurityContextHolder.setContext하게 될 때)

redis 를 session 으로 사용할 때에도 마찬가지로 context 를 저장하는 것입니다.

 

 

context 내부에는 authentication 이

다시 authentication 내부에는 principal, credentials, authorities 가 들어있게 됩니다.

이때 principal 에는 다시 UserDetails 들어있습니다.

들어있는 UserDetails 는 AuthenticationManager 가 처리한 객체입니다.

 

 

 

하지만 이상하게도, 프로젝트를 진행하며 직렬화 관련 에러가 발생하여 확인해보니 UserDetails 가 아닌

UserDetailsService 를 커스텀한 CustomUserDetailsService 가 들어있었습니다.

Spring Security 에서 제공하는 객체들은 다른 애플리케이션(redis, mongo...) 에 대응하며 직렬화가 가능하지만

CustomUserDetailsService 에서 사용하는 JPA 관련 객체(Proxy, Interceptor...)들은 직렬화가 불가능하기 때문에

에러가 발생한 것으로 판단하고 원인을 찾아보았습니다.

 

 

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService, Serializable {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findUserByEmail(email)
                .orElseThrow(() -> new ImheroApplicationException(ErrorCode.EMAIL_NOT_FOUND));
        return new CustomUserDetails(user);
    }

    public class CustomUserDetails extends org.springframework.security.core.userdetails.User {
        private User user;

        public CustomUserDetails(User user) {
            super(user.getEmail(), user.getPassword(), new ArrayList<>());
            this.user = user;
        }

        public User getUser() {
            return user;
        }
    }

}
  • CustomUserDetailsService

CustomUserDetailsService 내부에 CustomUserDetails 를 갖고 있는 구조였습니다.

당연히 내부 객체에 대한 참조를 위해 외부 객체를 만들어야 하고 이를 Security 는 외부객체로 context 에 담았던 것입니다...

따라서 당연히 CustomUserDetails 를 외부로 떼내어 해결하였습니다.

 

아래에서 redis와 session 을 설정하는 방법을 알아보겠습니다.

 

 

Redis + Spring Security

session:
	store-type: redis
redis:
	host: localhost
	port: 6379
  • application.yaml

redis 의 설정은 놀랍게도 위가 전부입니다.

위의 설정을 통해 향후 HttpServletSession 이나, request 를 통해 session 을 얻게 되면

이는 redis 에서 관리하는 session 이 됩니다.

 

 

@PostMapping("/api/v1/users/login")
public Response<LoginResponse> login(@RequestBody LoginRequest loginRequest, HttpServletRequest request) {
    Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(loginRequest.getEmail(), loginRequest.getPassword()));
    SecurityContextHolder.getContext().setAuthentication(authentication);
    return Response.success(userService.login(loginRequest));
}

위의 코드는 session 을 사용하는 예시 코드입니다.

단순히 setAuthentication 을 하게 되면, session 에 저장하는 것과 같은 효과입니다.

또한 차후에 session 을 통해 context를 찾을 때에는 

HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY

위의 상수를 사용하면 context 를 찾을 수 있습니다.

 

session 은 말씀드렸던 것처럼 Redis Session 이 됩니다.

따라서 user 를 그저 session 에 넣는 코드처럼 보이지만 실제로는 Redis Session 에 넣어주고 있는 것입니다.

 

 

Redis 에서 확인해본 결과입니다.

session ID 와 SPRING_SECURITY_CONTEXT 를 사용하면 context 가 저장되어 있는 것을 확인할 수 있습니다.

 

 

 

끝!

Comments