[쿠러미] Redis 캐싱을 통한 성능 개선
서론
현재 쿠러미 프로젝트의 메인페이지는 다음과 같다.

처음 진입점인 메인페이지에 진입하면 위와 같이 사용자에게 다양한 쿠폰 정보가 노출된다. 예를 들어:
- 인기 쿠폰 조회
- (추후 추가될) 최신 쿠폰, 마감임박 쿠폰 조회
이러한 데이터는 다소 복잡한 쿼리를 계산하는 API를 통해 제공된다. 문제는, 이 모든 API가 메인 페이지 로드 시마다 호출된다면 사용자는 불쾌함을 느끼게 된다.
따라서 Redis의 캐싱을 통해 개선을 해보고자 한다.
또한, 현재 API 요청은 JWT를 이용해 사용자 인증을 처리하는데, 다음과 같은 흐름이 발생한다.
1. 사용자로부터 access token을 받음
2. access token을 복호화하여 유저 정보를 가져옴
3. 복호화한 유저 정보와 db에 저장된 유저 정보를 비교
4. 정보가 일치하면 인가 성공, 일치하지 않거나 중간에 문제가 발생하면 실패
여기서 3번째에 쿼리가 발생하게 된다. 한번의 요청을 하는데 매번 2번의 쿼리가 나가면 매우 비효율적이다.
따라서 이 또한 캐싱을 통해 개선을 하고자 한다.
구현
Redis관련 설정은 따로 다루진 않겠다. 캐싱에 관한 설정만 확인해보자.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-cache'
CacheConfig
@Bean
public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
// ObjectMapper 설정 (LocalDateTime 등 날짜 직렬화 대응)
BasicPolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Object.class)
.build();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(typeValidator, ObjectMapper.DefaultTyping.NON_FINAL);
GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
// 기본적인 캐시 세팅
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer));
return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
// Cache Name마다 ttl을 다르게 하기 위해 커스텀 세팅
return builder -> builder
.withCacheConfiguration(CacheName.USER.name(), RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10)) // JWT 인증 관련 유저 캐시는 10분
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
);
}
캐시에 들어갈 데이터를 json으로 직렬화하고, LocalDateTime같은 날짜 타입도 올바르게 직렬화가 가능하도록 설정했다.
JWT 인증 정보 캐싱
CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
@Cacheable(value = "USER", key = "#memberId")
public UserDetails loadUserByUsername(String memberId) {
Member member = memberRepository.findById(Long.valueOf(memberId)).orElseThrow(
() -> new CustomException(StatusCode.MEMBER_NOT_EXIST));
return new CustomUserDetails(member);
}
}
jwt 토큰을 복호화 해 얻은 정보를 db에 저장된 유저정보를 비교하는 코드이다.
이를 캐싱해서 매 요청마다 쿼리가 2개씩 나가는 것을 요청 쿼리 하나만 실행하는 것으로 바꿨다.
CustomUserDetails
@Getter
@NoArgsConstructor(force = true)
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final Member member;
@JsonIgnore
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(member.getRole().toString()));
}
@JsonIgnore
@Override
public String getPassword() {
return "";
}
@JsonIgnore
@Override
public String getUsername() {
return member.getId().toString();
}
}
Redis에 캐싱을 할 때는 Json으로 직렬화를, 가져올 때는 역직렬화를 하기 때문에 반환 객체인 CustomUserDetails 클래스에 기본생성자와 Getter가 있어야한다. 또한 get이 붙은 메서드 또한 직렬화 대상이기 때문에 member 이외에 get메서드는 @JsonIgnore을 통해 직렬화를 피해줘야한다.
이제 부하테스트를 통해 성능 개선이 얼마나 이뤄지는 지 보자.
총 2000번의 매장 조회 요청을 시행했다.
캐싱 적용 전

캐싱 적용 후

평균 TPS가 194 -> 233으로 20%가 상승했다!
이 개선 효과는 모든 요청에서 동일하게 적용되므로 엄청난 개선효과이다.
메인 페이지 API 캐싱
메인 페이지에서 불러오는 API중 마감 임박 쿠폰 조회만 예시로 구현해보겠다.
// 마감 임박 쿠폰 TOP 조회
@Override
@Cacheable(value = "CLOSINGCOUPONS", key = "#size", unless = "#result.isEmpty()")
public List<ClosingCouponRes> getClosingCouponPage(Integer size) {
return couponRepository.findClosingCouponList(size);
}
List는 null이 될 수 없으므로 결과가 빈칸이 나올 수 있다. 따라서 unless = result.isEmpty() 조건을 넣었다.
빈 결과를 캐싱 하면 다음과 같은 문제점이 생길 수 있다.
빈 결과 캐싱
→ 데이터가 없을 때 캐싱해버리면 나중에 데이터가 생겨도 계속 빈 값 응답될 수 있음.
또한, Coupon 테이블에 변화가 생길 경우 값이 달라질 수 있기 때문에, Get 메서드를 제외한 coupon에 대한 변경이 있는 메서드에는
@CacheEvict(cacheNames = "CLOSINGCOUPONS", allEntries = true)
을 통해 캐시를 비웠다.
캐싱 적용 전

캐싱 적용 후

마무리
Jwt 인증 정보의 캐싱을 통해, 요청마다 불필요한 쿼리를 날리지 않음으로 써 모든 요청의 응답속도를 개선하였다.
또한, 어플의 처음 진입점인 메인페이지에 개인화되지 않은 데이터(최신,마감임박,인기 쿠폰 조회)들을 캐싱해 사용자들에게 쾌적함을 줄 수 있을 것으로 기대된다.
캐싱을 통해 성능을 개선하는 것도 좋지만, 우선 쿼리의 최적화가 먼저 바탕이 되어야한다.
다음엔 쿠폰에 대한 최적화를 해봐야겠다.