문제 상황
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은 디시리얼라이즈 시 에러 마주칠 확률이 빈번함)
'Development > Diary' 카테고리의 다른 글
[Diary][Spring] Spring Webflux의 WebFilter는 자동으로 등록됩니다. (Security Filter Chain에 등록하면 발생할 수 있는 문제) (0) | 2024.09.17 |
---|---|
[Diary][Spring] N + 1 문제 해결을 위한 @EntityGraph를 사용할 때 주의할 점 (0) | 2024.09.05 |
[Diary] Spring ORM에서는 N + 1을 항상 신경쓰자. (0) | 2024.08.30 |
[Diary][Spring] Spring Security에서 Role과 Authority의 차이가 뭘까? (0) | 2024.08.29 |
[Diary][Spring] 식별,비식별 관계? 관계의 방향? 외래 키의 주인? (1) | 2024.08.27 |