JoyMall 프로젝트를 진행하던 중 상품 조회 쿼리에 대한 성능 개선 3번째입니다.
이번에 적용해볼 방법은 Redis를 이용한 캐시입니다.
(Redis는 로컬 환경에서 서버를 띄워 진행했습니다.)
이전에는 데이터베이스 쿼리 최적화를 통해 조회 성능 개선을 진행했었습니다. 하지만 데이터베이스에 대한 쿼리 최적화도 한계가 있고 대규모 트래픽이 몰리거나 테이블의 구성이 복잡하고 더 이상 개선할 수 없다면 캐시를 이용할 수 있습니다.
캐시란 데이터를 미리 복사해놓은 임시 저장소를 의미합니다. 캐시는 자주 볼 수 있는 용어이며, 컴퓨터 저장장치를 살펴봐도 캐시의 개념이 사용됩니다.
이러한 캐시의 사용 이점은 DB I/O로 인한 성능을 개선할 수 있는 장점이 있습니다. redis는 인메모리 DB로 데이터를 하드디스크가 아닌 메모리에 저장하여 매우 빠른 속도를 가집니다.
캐싱을 사용하는 절차를 간단히 살펴보면
1. 사용자의 첫 요청에 DB에서 데이터를 가져옵니다. 이때 DB에서 가져온 데이터를 캐시 서버에 저장합니다.
2. 만약 똑같은 요청이 온다면 DB에서 데이터를 가져오지 않고 캐시 서버에서 데이터를 가져옵니다.
캐시는 로컬 캐시와 서버 캐시로 존재하는데 그 둘의 차이점은 다음과 같습니다.
우선 SpringBoot에 Redis를 설정하기 위해 관련 설정을 추가하겠습니다.
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yml
spring:
data:
redis:
host: localhost
port: 6379
Spring Boot에서 Redis를 사용하기 위해서는 관련 설정을 해야 합니다.
Spring Boot에서 Redis를 사용하기 위해서는 Java 클라이언트 라이브러리를 선택해야 합니다. 주로 사용되는 라이브러리는 Jedis와 Lettuce입니다.
성능과 사용 편의성을 고려할 때 Lettuce가 더 선호되는 편입니다.
선택한 클라이언트 라이브러리를 통해 Redis 서버에 연결하기 위한 설정이 필요합니다. 이는 호스트, 포트, 비밀번호 등의 연결 정보를 포함합니다.
Spring Data Redis는 RedisTemplate이라는 추상화 레이어를 제공합니다. 이를 통해 Redis 조작을 더 쉽고 일관성 있게 할 수 있습니다.
Configuration 파일에서 Bean을 직접 등록함으로써
Redis에 데이터를 저장하고 불러올 때 사용할 직렬화/역직렬화 방식을 선택할 수 있습니다. 이는 데이터의 형식과 성능에 영향을 미칩니다.
우선 Configuration을 따로 설정하지 않고 진행해보겠습니다. 왜 Cofiguration을 설정하면 좋을지에 대해서도 느껴보겠습니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
public ProductPageResponse search(String keyword, Pageable pageable) {
String cacheKey = "searchProducts::" + keyword + "::" + pageable.getPageNumber() + "::" + pageable.getPageSize();
String cachedData = redisTemplate.opsForValue().get(cacheKey);
if (cachedData != null) {
try {
System.out.println("redis에 캐시된 정보를 가져온다.");
System.out.println(cachedData);
return objectMapper.readValue(cachedData, ProductPageResponse.class);
} catch (Exception e) {
System.err.println("캐시된 데이터 역직렬화 중 오류 발생: " + e.getMessage());
}
}
List<ProductDTO> productDTOS = productRepository.findProductsByNameStartsWith(keyword, pageable.getPageSize(), pageable.getOffset());
long total = productRepository.countProductsByNameRange(keyword);
Page<ProductDTO> productPages = new PageImpl<>(productDTOS, pageable, total);
ProductPageResponse response = ProductPageResponse.from(productPages);
try {
String jsonResponse = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, jsonResponse);
System.out.println("검색 결과를 Redis에 캐시한다.");
} catch (Exception e) {
System.err.println("겸색 결과 캐싱 중 오류 발생" + e.getMessage());
}
return response;
}
}
위 코드는 구현 목적이 아닌 캐시가 작동하는지에 대해서 확인하기 위해 임의로 작성했습니다.
위 코드를 간단하게 살펴보면 다음과 같은 문제점들이 있습니다.
실행 결과를 로그로 확인해보겠습니다.
최초 요청에 redis로 캐시되었습니다.
정해둔 key 규칙대로 redis key가 생성되었습니다.
postman 기준 레이턴시는 0.8s 입니다.
이제 동일한 요청 통해 캐시된 데이터를 반환하는지 확인해보겠습니다.
로그를 보면 redis에서 정상적으로 데이터를 가져온 걸 확인했습니다.
postman을 살펴보면 레이턴시가 0.8s -> 0.3s 으로 개선되었습니다.
위의 비즈니스 코드를 살펴봤을 때 여러가지 문제점이 존재했습니다. 이 문제점들을 AOP와 Cofiguration 설정을 통해 어떻게 풀어낼 수 있는지 확인해보겠습니다.
RedisCacheConfig
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(120))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
}
@EnableCaching
을 통해 스프링의 캐싱 기능을 활성화합니다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Cacheable(value = "products", key = "#keyword + '::' + #pageable.pageNumber + '::' + #pageable.pageSize")
public ProductPageResponse search(String keyword, Pageable pageable) {
List<ProductDTO> productDTOS = productRepository.findProductsByNameStartsWith(keyword, pageable.getPageSize(), pageable.getOffset());
long total = productRepository.countProductsByNameRange(keyword);
Page<ProductDTO> productPages = new PageImpl<>(productDTOS, pageable, total);
return ProductPageResponse.from(productPages);
}
}
@Cacheable
어노테이션 선언을 통해 메서드의 결과를 캐시에 저장하고, 동일한 파라미터로 호출될 때 캐시된 결과를 반환합니다. 이는 AOP를 사용하여 구현되며, 비즈니스 로직과 캐싱 로직을 분리할 수 있게 해줍니다.
이러한 설정을 통해
관심사의 분리: 캐싱 로직과 비즈니스 로직이 분리되어 코드의 가독성과 유지보수성이 향상
선언적 프로그래밍, 설정 중앙화, 코드 간소화, 유연성 등의 이점이 생겼습니다.
하지만 이렇게 코드를 작성하고 테스트를 해보니 에러가 발생했습니다.
Redis를 사용할 때 데이터의 직렬화와 역직렬화는 중요한 고려사항입니다. 여기서 세 가지 주요 Serializer에 대해 알아보겠습니다.
Jackson2JsonRedisSerializer는 특정 Class Type을 지정하여 객체를 JSON 형태로 저장합니다.
StringRedisSerializer는 문자열 값을 그대로 저장합니다.
GenericJackson2JsonRedisSerializer는 모든 Class Type을 JSON 형태로 저장할 수 있는 범용 Serializer입니다.
java.time.LocalDateTime
not supported by defaultcom.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8
date/time type `java.time.LocalDateTime` not supported by default: add Module
"com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling
(through reference chain:
com.mini.joymall.product.dto.response.ProductPageResponse["productDTOS"]-
>java.util.Collections$UnmodifiableRandomAccessList[0]-
>com.mini.joymall.product.dto.ProductDTO["createdDate"])
오류 메시지를 해석하면 다음과 같습니다. Java 8의 날짜/시간 유형은 기본적으로 지원되지 않습니다. 이를 처리하도록 사용하려면 com.fastxml.jackson.datatype:jsr310
모듈을 추가해야 합니다.
Java 8에서 도입된 LocalDate
, LocalTime
, LocalDateTime
과 같은 새로운 날짜/시간 타입을 Jackson 라이브러리로 직렬화/역직렬화할 때 문제가 발생할 수 있습니다.
jackson-datatype-jsr310
모듈이 필요하지만, 이 모듈만으로는 충분하지 않을 수 있습니다.spring-boot-starter-web
을 통해 jackson-datatype-jsr310
이 자동으로 포함됩니다.이 문제를 해결하려면 ObjectMapper에 JavaTimeModule
을 명시적으로 등록해야 합니다.
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
return objectMapper;
}
}
하지만 이 설정을 해도 에러는 해결되지 않았습니다.
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
GenericJackson2JsonRedisSerializer redisSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
RedisCacheConfiguration cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(120))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
}
registerModule()
: 추가하고 싶은 모듈을 추가할 수 있습니다. 여기선 JavaTimeModule을 추가하여 LocalDateTime 역직렬화를 가능하도록 했습니다.
enableDefaultTyping()
: 직렬화 시 type 정보를 저장할 scope를 지정합니다. 여기서는 non-final 클래스들에 대해 타입 정보를 저장할 수 있도록 했습니다.(GenericJackson2JsonRedisSerializer의 기본 동작 방식)
하지만 이 방법은 2.10 버전 이후로 Deprecated 됐으며, activateDefaultTyping()을 사용이 권장되므로 코드를 수정하였습니다.
@EnableCaching
@Configuration
public class RedisCacheConfig {
@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
PolymorphicTypeValidator 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 cacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.disableCachingNullValues()
.entryTtl(Duration.ofSeconds(120))
.computePrefixWith(CacheKeyPrefix.simple())
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(cacheConfiguration)
.build();
}
}
이렇게 적용을 하였고 실행 로그를 확인해보면
최초에 캐시 확인 후 없음 -> DB 조회 -> 캐시 등록
2번 째 요청부터는 캐시 확인 후 존재 -> 캐시 데이터 반환
으로 동작하는 걸 확인할 수 있습니다.
하지만 실제로 구현된 코드에서 GenericJackson2JsonRedisSerializer
를 사용했을 때 캐싱된 데이터의 값을 확인해보면 패키지 정보가 들어있음을 확인할 수 있습니다.
Redis 캐시를 도입하는 과정에 대해서 알아보았습니다. 캐시를 사용하면 무조건 장점만 있는건 아닙니다. 캐시 히트, 캐시 미스의 개념과 최대한 정적인 데이터를 캐싱을 해야 서비스에 대한 효율성을 높이고 Redis 서버에 대한 메모리 관리가 될 겁니다.
지금 코드에서 마음에 안드는 부분은 패키지 정보가 들어있는 점이 마음에 들지 않습니다.
다음 글에서는 이 패키지 정보에 대한 문제를 해결하는 방법을 작성해보겠습니다.
참고