성능 테스트 수행 결과 TPS, Latency 모두 매우 낮은 수치가 측정 되었다. 따라서 개선 가능한 지점에 대해 생각해보았고, JWT 인증 방식의 비효율적인 부분을 개선하고자 했다.
JWT 인증 로직은 서버로 요청이 왔을 때, 별도의 세션 정보가 없고 JWT 토큰의 유효성을 체크한 뒤 토큰 내의 사용자 정보를 꺼내서 DB의 사용자 정보와 대조하는 순서로 이뤄진다. 여기서 요청마다 매번 DB에 접근하여 동일한 사용자 정보를 조회하는 부분이 비 효율적이라는 생각을 하게 되었다.
보통 서비스를 이용할 때 일정 시간 동안 연속적으로 사용하곤 한다. 예를 들어, 쿠팡에서 쇼핑을 하는 경우를 생각해보자. 로그인을 한 뒤 원하는 상품을 탐색하고 구매하는 과정까지 수 많은 요청을 연속적으로 쿠팡 서버에 보내게 된다.
따라서 사용자 정보를 캐싱해둔다면 연속적으로 DB를 조회하는 비 효율을 크게 줄일 수 있을 것이라 판단하고 적용하게 되었다.
https://inpa.tistory.com/entry/REDIS-📚-캐시Cache-설계-전략-지침-총정리
위의 블로그 글에 캐시를 수행하는 전략에 대한 자세한 내용이 담겨있다. 이를 기반으로 프로젝트에 알맞은 캐시 전략을 사용하면 좋을 것 같다.
Spring Cache Abstraction은 Spring에서 제공하는 Cache 인터페이스이다. 캐싱을 위해 다양한 벤더 기술 (Redis, Caffeine, EnCache 등)을 사용할 수 있는데 이를 동일한 사용법으로 활용할 수 있게 만들어준다. 따라서 추후에 캐싱에 활용되는 기술을 변경해야 하는 경우에도 캐싱 부분의 코드 변경을 최소화할 수 있다. 이와 같은 방법으로, 프로그램이 특정 기술에 종속되지 않게 하는 방식을 PSA(Portable Service Abstraction) 라고 한다.
기본적으로 메소드에 어노테이션을 달아서 캐싱을 설정하고, 메소드 인자에 따라서 메소드를 호출할 지 캐싱 정보를 활용할 지 선택한다. 아래는 Cache와 관련된 어노테이션에 대한 공식 홈페이지 설명이다.
Spring Cache Abstraction의 Redis Bean 설정 코드이다.
TTL이나 직렬화 부분은 캐싱하는 내용에 따라 유동적으로 설정하면 좋을 것 같다.
disableCachingNullValues는 value가 null일 경우에 캐싱하지 않겠다는 의미이다.
@EnableCaching
@RequiredArgsConstructor
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofMinutes(1L))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) //redis 캐시 키 값 저장방식 - StringRedisSerializer
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));// redis 캐시 정보값 저장방식 - GenericJackson2JsonRedisSerializer - json 문자열
Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
redisCacheConfigurationMap
.put("memberCacheStore", configuration);
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(connectionFactory)
.cacheDefaults(configuration)
.withInitialCacheConfigurations(redisCacheConfigurationMap).build();
}
}
Spring Security 설정에 등록한 JWT 토큰 검증을 담당하는 Filter의 코드를 살펴보면, UserDetailsService
를 상속받아 직접 Custom하게 구현한 CustomUserDetailsService
의 loadMemberById()
메소드를 통해 사용자의 정보를 불러온다. 그리고 해당 메소드에서 DB에 사용자 정보를 조회하는 로직이 존재하므로 해당 메소드에 @Cacheable
어노테이션으로 캐시 설정을 추가하였다.
JWT Token Filter
@Slf4j
@RequiredArgsConstructor
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
private final TokenProperties tokenProperties;
private final CustomUserDetailsService customUserDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//...
String accessToken = getAccessTokenInRequestHeader(request);
try {
if (StringUtils.hasText(accessToken) && tokenService.validateToken(accessToken, tokenProperties.getAccess().getName())) {
Long id = tokenService.getId(accessToken);
**MemberPrincipal memberPrincipal = customUserDetailsService.loadMemberById(id);**
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(memberPrincipal, null, memberPrincipal.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception e) {
log.info("[Cast Exception] = {} {}", e.getLocalizedMessage(), e.getCause());
throw new AuthenticationException(e.getMessage());
}
filterChain.doFilter(request, response);
}
private String getAccessTokenInRequestHeader(HttpServletRequest request) {
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
CustomUserDetailsService Class에 Cache 적용
@Cacheable(value = "memberCacheStore", key = "#id")
public MemberPrincipal loadMemberById(Long id) throws UsernameNotFoundException {
Member member = memberRepository.findById(id)
.orElseThrow(() -> new CustomRestException(MemberErrorCode.MEMBER_IS_NOT_EXIST));
return MemberPrincipal.builder()
.memberId(member.getId())
.email(member.getEmail())
.build();
}
위의 방식으로 캐시를 설정한 뒤 Redis-CLI에서 캐시 값을 확인하면 memberCacheStore::1
형태로 Key가 생성되고, Value값은 메소드의 Return 객체(MemberPrincipal
)가 직렬화 된 상태로 저장되게 된다.
따라서 메소드의 Return class의 직렬화에 대해서도 신경을 쓸 필요가 있다. 나의 경우에는 해당 클래스가 UserDetails를 상속받은 클래스였기 때문에 직렬화 문제에 대해 코드를 직접 수정할 수 없었다. 하지만 해당 정보를 굳이 캐싱 정보로 저장할 필요는 없었기 때문에 @JsonIgnore
어노테이션으로 간단하게 해결하였다.
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberPrincipal implements UserDetails {
private Long memberId;
private String email;
public Long getMemberId() {
if (memberId == null) {
throw new NoSuchElementException("로그인 한 회원이 존재하지 않습니다.");
}
return memberId;
}
public String getEmail() {
return email;
}
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
}
@JsonIgnore
@Override
public String getPassword() {
return "";
}
@JsonIgnore
@Override
public String getUsername() {
return memberId + "";
}
@JsonIgnore
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonIgnore
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonIgnore
@Override
public boolean isEnabled() {
return true;
}
}
[REDIS] 📚 캐시(Cache) 설계 전략 지침 💯 총정리
spring Expression Language 사용법