[Diary][Spring Security] UserDetails를 어떻게 캐싱할까?
본문 바로가기

Development/Diary

[Diary][Spring Security] UserDetails를 어떻게 캐싱할까?

문제 상황

public class UserDetailsImpl implements UserDetails {

    private final User user;

    public UserDetailsImpl(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority(role.getRoleName()))
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    public User getUser() {
        return user;
    }

}

위 클래스는 UserDetail을 구현한 클래스이다. 이 클래스에는 User를 포함한다.

이렇게 구현한 이유는, Security Filter Chain을 통과한 UserDetail은 SecurityContextHolder에 저장이 되고, 이 User를 다시 fetch 하는 데 DB에서 꺼내오는 대신, SecurityContextHolder에서 가져오기 위함이다.

실제로 이 과정은 User를 fetch 하는데 많은 시간을 단축해준다. (기억상 1800ms 정도) 

 

예를 들어, Security를 사용하면 UserDetailService를 구현하기 위해 loadUserByUsername를 구현해야 하는 상황이 올 것이다.

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(
                        () -> new UsernameNotFoundException(ErrorCode.USER_NOT_FOUND.getMessage()));
        return new UserDetailsImpl(user);
    }

이 메서드에서 이미 한 번의 User를 fetch했기 때문에, 또다시 DB에서 user를 fetch 하는 과정을 없애고자 했다.

 UserDetailsImpl userDetails = (UserDetailsImpl) SecurityContextHolder.getContext()
                .getAuthentication().getPrincipal();
        User user = userDetails.getUser();

그럼 대략 위와 같은 코드로 인증에 성공한 User를 얻어올 수 있다.

근데 이게 뭐가 문제냐면..

    @Override
    @Transactional(readOnly = true)
    @Cacheable(value = "userDetails", key = "#username")
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(
                        () -> new UsernameNotFoundException(ErrorCode.USER_NOT_FOUND.getMessage()));
        return new UserDetailsImpl(user);
    }

다음번 request에도 이 User의 권한 체크를 빠르게 하기 위해서 캐싱을 할 때가 문제이다.

UserDetails를 캐싱을 하면 User와 관계 매핑된(e.g.@OneToMany) 엔티티가 있다면 직렬화 시 순환 참조와 같은 문제가 발생한다.

뭐 직렬화시에 방향을 정해주는 @JsonBackReference, @JsonManagedReference 이런 게 있던데 이를 엔티티에 추가해주어야 하나.. 지저분할 것 같았다.

보통 DTO를 캐싱한다던데?

우리가 뭔가 데이터를 캐싱한다치면 자주 사용되는 데이터들.. 뭐 페이징 정보라던가 User 엔티티 자체가 아니라 User에게 필요한 데이터만 담은 UserResponseDto 라던가? 이런 걸 캐싱하는데, 이 경우에 UserDetail 엔티티를 캐싱하는 거니까..

이런 문제를 겪었다. 

그럼 User와 User 권한은 어떻게 캐싱하고, Security가 캐시에서 확인할까?

튜터님께 질문한 바로는, 보통 User 데이터를 캐싱하진 않는다고 한다. 왜냐면 자주 바뀔 수 있다. 회원 정보 수정, 회원가입, 회원 탈퇴 등등등... 그리고 사용자 수라는 게 사실 서비스의 규모에 비해 더 커질 수도 있다(예를 들어, 쇼핑몰에서 상품 수에 비해 가입한 사용자 수가 훨씬 더 많을 것). 이 때문에 캐싱 정책이라던가 정하는 게 더 까다로울 것.

 

따라서 User을 DB에 저장하지만, 샤딩을 한다던가 NoSQL과 같은 곳에서 저장하는 게 더 좋을 수 있다고 한다.

사용자 수가 많아지면 단일 DB 서버로는 부하를 감당하기 어려워질 수 있고, 이때 샤딩을 통해 데이터를 여러 DB에 분산하거나, NoSQL 데이터베이스(예: MongoDB, Cassandra)를 활용해 확장성을 확보하는 것이 유리하다. NoSQL은 특히 수평 확장에 강점을 가지고 있으며, 사용자와 같은 대규모 데이터를 처리하는 데 적합하다.

 

그럼에도 굳이 UserDetail을 캐싱하자면 연관 관계를 @JsonIgnore (사실 이 방법도 썩 좋진 않음)을 추가해 준다던가, 연관 관계를 끊는다던가 해서 직렬화 시 순환 참조를 해결하고, Jdk 어쩌고 시리얼라이저를 사용하는 게 좋다. (Jackson은 디시리얼라이즈 시 에러 마주칠 확률이 빈번함)