🎯 왜? Jwt 를 Redis 에서 관리할까?
기존에 Jwt 를 이용해 구현한 인증 기능에서 RefreshToken 을 사용자 DB 에 넣어 관리했습니다. 하지만 보안성을 높이기 위해 RefreshToken 재발급 주기를 짧게 하고 싶었지만 DB 에 보내는 쿼리가 많아져 서비스가 커지면 분명히 성능적으로 문제가 생길거라 생각했습니다.
때문에 다음과 같은 이유로 Jwt 를 Redis 에서 관리하게끔 변경했습니다.
1. Jwt 는 자주 엑세스 되는 데이터이기 때문에 캐싱을 활용하면 시스템 성능을 높일 수 있다 판단.
2. 자주 엑세스 되는 데이터인 만큼 빠른 속도가 중요하기에 In-Memory 기반의 Redis 에 저장.
3. Jwt 뿐만 아니라 Jwt 인증시 필요한 사용자 정보도 Redis에서 관리하면 DB 의 부하를 줄일 수 있다 판단.
🎯 사용자 인증 정보를 Redis 에서 관리해도 괜찮을까?
다음은 Jwt 인증 필터 코드입니다.
✅ JwtAuthenticationFilter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login";
private final JwtService jwtService;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
public Optional<User> findByAccessToken(HttpServletRequest request) {
return jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.flatMap(jwtService::extractEmail)
.flatMap(userRepository::findByEmail);
}
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
password = "1233"; //PasswordUtil.generateRandomPassword();
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
checkRefreshTokenAndReIssueAccessToken() 와 checkAccessTokenAndAuthentication() 는 각각 토큰정보를 검증하는 기능을 포함합니다.
각각의 메서드 내부에선 userRepository.xxx 와 같이 DB 에 직접적으로 접근해 필요한 데이터를 조회합니다.
여기서 문제가 발생합니다.
Jwt 인증 필터는 권한이 필요한 모든 요청이 들어올때 실행됩니다. 즉 서비스의 모든 요청이 만약 Jwt 토큰이 필요하다고 가정하면 모든 요청이 사용자 DB 에 접근하기 때문에 서버비용은 물론 서비스 성능이 너무 떨어질거라 생각이 이 부분에 대한 개선이 필요하다 생각했고 개선 방인이 바로 캐시 였습니다.
saveAuthentication() 내부 코드
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
saveAuthentication() 메서드는 사용자 인증 정보를 생성하는 기능을 합니다. 이때 필요한 정보는 email, password, role 총 3개의 정보를 필요로 합니다. 이 정보 역시 사용자 DB 에 있는 정보기 때문에 캐싱을 이용해야겠다 생각했습니다.
하지만 Redis 에서 password 와 같은 민감한 정보를 가지고 있어도 괜찮을지 고민했지만, password 는 암호화되어 있는 값이 Redis 에 올라가기때문에 괜찮다고 판단해 Redis 에서 Authentication 인증 객체를 만들때 필요한 정보 역시 관리하기로 결정했습니다.
(이게 보안적으로 맞는 방법인지는 좀 더 공부해야 확실해 질거 같습니다.)
🎯 캐싱 적용해보기
✅ RedisConfig
Redis 설정 Config 클래스 입니다.
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
✅ RedisCacheConfig
캐시 관련 설정을 구현한 코드입니다.
@Configuration
@RequiredArgsConstructor
@EnableCaching
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(3L)); // 캐쉬 저장 시간 3분 설정
return RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(cf)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
✅ UserRedisService
사용자 정보에 대해 캐싱이 필요한 정보를 호출, 조회할때 사용하는 기능들을 모아둔 클래스 입니다.
@Transactional
@Service
@RequiredArgsConstructor
public class UserRedisService {
private static final String REFRESH_TOKEN_KEY = "RefreshToken::";
private final UserRepository userRepository;
private final RedisTemplate<String, Object> redisTemplate;
public void saveUserCaching(String email, String refreshToken) {
saveRefreshToken(email, refreshToken);
findUserByEmail(email);
}
@CachePut(value = "RefreshToken", key = "#email")
public String saveRefreshToken(String email, String refreshToken) {
return refreshToken;
}
@Cacheable(value = "UserPrinciple", key = "#email")
public Optional<User> findUserByEmail(String email) {
Optional<User> user = userRepository.findByEmail(email);
return user;
}
public void validateRefreshToken(String email, String refreshToken) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
String rf = (String) values.get(REFRESH_TOKEN_KEY + email);
String trimmedRF = rf.substring(1, rf.length() - 1);
if (rf == null || !trimmedRF.equals(refreshToken)) {
throw new IllegalArgumentException("RefreshToken 검증 실패");
}
}
}
@CachePut 애노테이션을 사용해 RefreshToken 을 저장하는 메서드 입니다. key 는 사용자 email 을 기준으로 구분했습니다.
@CachePut(value = "RefreshToken", key = "#email")
public String saveRefreshToken(String email, String refreshToken) {
return refreshToken;
}
@Cacheable 애노테이션을 이용해 캐싱을 적용했습니다.
email 을 기준으로 사용자 User 객체를 Redis 에 저장합니다. 이때 만약 Redis 에 존재한다면 userRepository 에 접근하지 않고 Redis 에서 조회한 데이터를 반환합니다. (성능 향상)
(하지만 여기서 User 객체를 그대로 Redis 에 올려버리는데 이 부분은 Dto 또는 새로운 도메인 클래스를 만들어 변경해야 할거 같습니다.)
@Cacheable(value = "UserPrinciple", key = "#email")
public Optional<User> findUserByEmail(String email) {
Optional<User> user = userRepository.findByEmail(email);
return user;
}
Redis에 저장되어 있는 refreshToken 과 사용자가 보낸 refreshToken 정보가 일치하는지 검증하는 메서드입니다. 검증에 실패하면 에러를 던집니다.
public void validateRefreshToken(String email, String refreshToken) {
ValueOperations<String, Object> values = redisTemplate.opsForValue();
String rf = (String) values.get(REFRESH_TOKEN_KEY + email);
String trimmedRF = rf.substring(1, rf.length() - 1);
if (rf == null || !trimmedRF.equals(refreshToken)) {
throw new IllegalArgumentException("RefreshToken 검증 실패");
}
}
여기까지 Redis 관련 설정, 메서드들을 정의했습니다. 다음 코드부턴 구현한 캐싱 메서드들을 Spring Security 에 적용합니다.
✅ (캐싱 적용 전) LoginSuccessHandler
기존의 LoginSuccessHandler 코드입니다. 참고로 LoginSuccessHandler 는 로그인 성공시 실행되는 Handler 입니다.
(Spring Security 관련해서는 깊게 설명하지 않겠습니다.)
userRepository 에 접근해 email 로 사용자 정보를 찾고 DB 에 접근해 RefreshToken 관련 필드값을 업데이트 하는 코드를 확인할 수 있습니다. 이 부분을 UserRedisService 에서 구현한 캐싱 메서드를 이용해 성능을 개선해보겠습니다.
@Slf4j
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
private final UserRepository userRepository;
@Value("${jwt.access.expiration}")
private String accessTokenExpiration;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
String email = extractUsername(authentication); // 인증 정보에서 Username(email) 추출
String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); // 응답 헤더에 AccessToken, RefreshToken 실어서 응답
userRepository.findByEmail(email)
.ifPresent(user -> {
user.updateRefreshToken(refreshToken);
userRepository.saveAndFlush(user);
});
log.info("로그인에 성공하였습니다. 이메일 : {}", email);
log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
log.info("발급된 AccessToken 만료 기간 : {}", accessTokenExpiration);
}
private String extractUsername(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return userDetails.getUsername();
}
}
✅ (캐싱 적용 후) LoginSuccessHandler
@Slf4j
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
private final UserRedisService userRedisService;
@Value("${jwt.access.expiration}")
private String accessTokenExpiration;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
String email = extractUsername(authentication);
String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); // 응답 헤더에 AccessToken, RefreshToken 실어서 응답
userRedisService.saveUserCaching(email, refreshToken);
printSuccessMessage(email, accessToken);
}
private void printSuccessMessage(String email, String accessToken){
log.info("로그인에 성공하였습니다. 이메일 : {}", email);
log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
log.info("발급된 AccessToken 만료 기간 : {}", accessTokenExpiration);
}
private String extractUsername(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return userDetails.getUsername();
}
}
userRepository 에 직접적으로 접근하지 않고 userRedisService.saveUserCaching(email, refreshToken) 메서드를 호출해 RefreshToken 을 Redis 에 저장하고 사용자 정보 또한 Redis 에 저장합니다.(단 Redis 에 사용자 정보가 없으면 DB 에 접근 후 Redis 에 넣어줌)
✅ (캐싱 적용 전) JwtAuthenticationFilter
코드를 보면 userRepository 에 의존하는 메서드들이 많은걸 확인할 수 있습니다. 이 부분을 이전에 구현한 캐싱 메서드들로 성능을 개선해보겠습니다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login";
private final JwtService jwtService;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
public Optional<User> findByAccessToken(HttpServletRequest request) {
return jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.flatMap(jwtService::extractEmail)
.flatMap(userRepository::findByEmail);
}
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
if (password == null) { // 소셜 로그인 유저의 비밀번호 임의로 설정 하여 소셜 로그인 유저도 인증 되도록 설정
password = "1233"; //PasswordUtil.generateRandomPassword();
}
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
✅ (캐싱 적용 후) JwtAuthenticationFilter
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login";
private final JwtService jwtService;
private final UserRedisService userRedisService;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(refreshToken, request, response);
return;
}
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
// refreshToken 이 있을때 refreshToken, accessToken 둘다 재발급
public void checkRefreshTokenAndReIssueAccessToken(String refreshToken, HttpServletRequest request, HttpServletResponse response) {
jwtService.extractAccessToken(request)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> {
// refresh 토큰 검증
try {
userRedisService.validateRefreshToken(email, refreshToken);
// refreshToken 재발급
String reIssuedRefreshToken = reIssueRefreshToken(email);
// accessToken, refreshToken 재발급
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(email), reIssuedRefreshToken);
} catch (Exception e) {
e.printStackTrace();
}
}));
}
private String reIssueRefreshToken(String email) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
userRedisService.saveUserCaching(email, reIssuedRefreshToken);
return reIssuedRefreshToken;
}
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRedisService.findUserByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
public void saveAuthentication(User myUser) {
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(myUser.getPassword())
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
먼저 doFilterInternal 메서드 내부를 보면 refreshToken 유무에 따라 accessToken 으로만 인증을 할지, refreToken 으로 인증할지 구분합니다.
checkRefreshTokenAndReIssueAccessToken() 는 RefreshToken 이 들어왔을때 RefreshToken 을 재발급하며 인증 처리를 하고
checkAccessTokenAndAuthentication() 는 AccessToken만 들어왔을때 사용자 인증처리를 합니다.
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(refreshToken, request, response);
return;
}
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
checkRefreshTokenAndReIssueAccessToken() 에서 userRedisService.findByEmail() 로 사용자 정보를 가져옵니다. 이때 사용된 캐싱 기능으로 성능을 향상시킬 수 있었습니다.
가져온 User 정보를 기반으로 saveAuthentication() 에서 인증 객체를 만듭니다.
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRedisService.findUserByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
//..
public void saveAuthentication(User myUser) {
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(myUser.getPassword())
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
checkRefreshTokenAndReIssueAccessToken() 메서드는 refreshToken 을 검증하고 재발급해 Response 에 담아 보내주는 기능을 합니다. 이때 Redis 에 있는 RefreshToken 과 비교해 토큰을 검증하고 재발급해 Redis 저장소에 재발급 받은 RefreshToken 을 저장합니다.
// refreshToken 이 있을때 refreshToken, accessToken 둘다 재발급
public void checkRefreshTokenAndReIssueAccessToken(String refreshToken, HttpServletRequest request, HttpServletResponse response) {
jwtService.extractAccessToken(request)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> {
// refresh 토큰 검증
try {
userRedisService.validateRefreshToken(email, refreshToken);
// refreshToken 재발급
String reIssuedRefreshToken = reIssueRefreshToken(email);
// accessToken, refreshToken 재발급
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(email), reIssuedRefreshToken);
} catch (Exception e) {
e.printStackTrace();
}
}));
}
private String reIssueRefreshToken(String email) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
userRedisService.saveUserCaching(email, reIssuedRefreshToken);
return reIssuedRefreshToken;
}
자, 이렇게 구현한 캐싱 메서들을 이용해 Spring Security 에 적용해봤습니다. 실제로 진행중인 프로젝트에서 대부분의 요청이 Jwt 토큰이 필요했기에 매번 쿼리를 날리며 인증했던 방식이 Redis 에 접근해 정보를 가져옴으로써 성능적으로 많은 개선이 되었습니다.
하지만 Redis 를 사용할때 가장 주의해야할점이 바로 데이터 정합성 문제입니다. DB 에 있는 값과 Redis 에 있는 값이 달라져 인증 처리에 문제가 생기면 시스템 전체적인 문제로 확산될 수 있에 이에따른 추가적인 리팩토링이 필요할거 같습니다.
'Spring > Redis' 카테고리의 다른 글
Cache(캐시) 설계 전략 (0) | 2024.12.17 |
---|---|
[Spring] Spring 캐싱 애노테이션 정리 (@Cacheable, @CachePut, @CacheEvict) (0) | 2024.04.21 |