[JoyMall] 상품 조회 성능 개선기(3) - Redis를 이용한 캐시 적용

청포도봉봉이·2024년 10월 15일
1

JoyMall

목록 보기
13/13
post-thumbnail

JoyMall 프로젝트를 진행하던 중 상품 조회 쿼리에 대한 성능 개선 3번째입니다.

이번에 적용해볼 방법은 Redis를 이용한 캐시입니다.

(Redis는 로컬 환경에서 서버를 띄워 진행했습니다.)



들어가기

이전에는 데이터베이스 쿼리 최적화를 통해 조회 성능 개선을 진행했었습니다. 하지만 데이터베이스에 대한 쿼리 최적화도 한계가 있고 대규모 트래픽이 몰리거나 테이블의 구성이 복잡하고 더 이상 개선할 수 없다면 캐시를 이용할 수 있습니다.

캐시란 무엇일까?

캐시란 데이터를 미리 복사해놓은 임시 저장소를 의미합니다. 캐시는 자주 볼 수 있는 용어이며, 컴퓨터 저장장치를 살펴봐도 캐시의 개념이 사용됩니다.

이러한 캐시의 사용 이점은 DB I/O로 인한 성능을 개선할 수 있는 장점이 있습니다. redis는 인메모리 DB로 데이터를 하드디스크가 아닌 메모리에 저장하여 매우 빠른 속도를 가집니다.

캐싱을 사용하는 절차를 간단히 살펴보면
1. 사용자의 첫 요청에 DB에서 데이터를 가져옵니다. 이때 DB에서 가져온 데이터를 캐시 서버에 저장합니다.
2. 만약 똑같은 요청이 온다면 DB에서 데이터를 가져오지 않고 캐시 서버에서 데이터를 가져옵니다.

로컬 캐시 vs 서버 캐시

캐시는 로컬 캐시와 서버 캐시로 존재하는데 그 둘의 차이점은 다음과 같습니다.

  1. 위치
  • 로컬 캐시: 애플리케이션 서버의 메모리 내에 존재
  • 서버 캐시: 별도의 캐시 서버(Redis, Memcached)에 존재
  1. 접근 속도
  • 로컬 캐시: 같은 서버 내 메모리에 있어 매우 빠르게 접근 가능
  • 서버 캐시: 네트워크를 통해 접속하므로 로컬 캐시보단 느리지만, 데이터베이스 보단 빠르다.
  1. 데이터 공유
  • 로컬 캐시: 각 애플리케이션 서버마다 독립적인 캐시를 가진다. 서버 간 데이터 공유가 어렵다.
  • 서버 캐시: 여러 애플리케이션 서버가 동일한 캐시 서버를 사용하기 때문에 데이터 일관성 유지에 용이하다.
  1. 확장성
  • 로컬 캐시: 서버 자원(메모리)에 의존하므로 확장성에 제한이 있다.
  • 서버 캐시: 독립적으로 확장이 가능하며, 필요에 따라 캐시 서버를 추가할 수 있다.
  1. 장애 대응
  • 로컬 캐시: 서버가 재시작되면 캐시 데이터가 손실된다.
  • 서버 캐시: 애플리케이션과 독립적이므로 서버 재시작과 관계없이 데이터를 유지



설정하기

우선 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를 사용하기 위해서는 관련 설정을 해야 합니다.

1. Redis 클라이언트 라이브러리 선택

Spring Boot에서 Redis를 사용하기 위해서는 Java 클라이언트 라이브러리를 선택해야 합니다. 주로 사용되는 라이브러리는 Jedis와 Lettuce입니다.

  • Lettuce: Spring Boot 기본 의존성만으로 사용 가능하며, 성능이 우수하여 널리 사용됩니다.
  • Jedis: 별도의 의존성 설정이 필요합니다.

성능과 사용 편의성을 고려할 때 Lettuce가 더 선호되는 편입니다.

2. Redis 연결 설정

선택한 클라이언트 라이브러리를 통해 Redis 서버에 연결하기 위한 설정이 필요합니다. 이는 호스트, 포트, 비밀번호 등의 연결 정보를 포함합니다.

3. RedisTemplate 설정

Spring Data Redis는 RedisTemplate이라는 추상화 레이어를 제공합니다. 이를 통해 Redis 조작을 더 쉽고 일관성 있게 할 수 있습니다.

  • Spring Boot 2.0 이상에서는 RedisTemplate과 StringRedisTemplate이 자동으로 생성됩니다.
  • 그러나 세부적인 설정을 위해 직접 Bean으로 등록하는 것이 좋습니다.

4. 커스텀 설정의 유연성

Configuration 파일에서 Bean을 직접 등록함으로써

  • 세부적인 설정을 더 자세히 제어할 수 있습니다.
  • 프로젝트의 요구사항에 맞는 커스텀 설정이 가능합니다.
  • 성능 최적화나 특정 기능 활성화 등을 위한 설정을 추가할 수 있습니다.

5. 직렬화/역직렬화 방식 선택

Redis에 데이터를 저장하고 불러올 때 사용할 직렬화/역직렬화 방식을 선택할 수 있습니다. 이는 데이터의 형식과 성능에 영향을 미칩니다.



우선 Configuration을 따로 설정하지 않고 진행해보겠습니다. 왜 Cofiguration을 설정하면 좋을지에 대해서도 느껴보겠습니다.


캐시 적용

configuration 적용 전

@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;
    }
}

위 코드는 구현 목적이 아닌 캐시가 작동하는지에 대해서 확인하기 위해 임의로 작성했습니다.

위 코드를 간단하게 살펴보면 다음과 같은 문제점들이 있습니다.

  1. ObjectMapper를 통한 역직렬화 코드가 서비스 코드에 존재합니다.
  2. RedisTemplate를 주입받아 사용합니다.
  3. 따라서 Redis를 사용하는 모든 코드에 의존성 주입이 필요합니다.

실행 결과를 로그로 확인해보겠습니다.

최초 요청에 redis로 캐시되었습니다.

정해둔 key 규칙대로 redis key가 생성되었습니다.

postman 기준 레이턴시는 0.8s 입니다.


이제 동일한 요청 통해 캐시된 데이터를 반환하는지 확인해보겠습니다.

로그를 보면 redis에서 정상적으로 데이터를 가져온 걸 확인했습니다.

postman을 살펴보면 레이턴시가 0.8s -> 0.3s 으로 개선되었습니다.




Configuration + AOP를 통한 캐시 설정

위의 비즈니스 코드를 살펴봤을 때 여러가지 문제점이 존재했습니다. 이 문제점들을 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 Serializers - 장단점 및 사용 시 고려사항

Redis를 사용할 때 데이터의 직렬화와 역직렬화는 중요한 고려사항입니다. 여기서 세 가지 주요 Serializer에 대해 알아보겠습니다.

1. Jackson2JsonRedisSerializer

Jackson2JsonRedisSerializer는 특정 Class Type을 지정하여 객체를 JSON 형태로 저장합니다.

장점:

  • Class Type 값을 JSON 형태로 저장하여 패키지 정보 일치를 고려할 필요가 없습니다.
  • 특정 타입에 최적화된 성능을 제공할 수 있습니다.

단점:

  • Class Type을 지정해야 하므로 특정 클래스에 종속적입니다.
  • 여러 스레드에서 RedisTemplate에 접근할 때 Serializer 타입 문제가 발생할 수 있습니다.
  • 다양한 타입의 객체를 저장해야 할 경우 여러 Serializer를 설정해야 할 수 있습니다.

2. StringRedisSerializer

StringRedisSerializer는 문자열 값을 그대로 저장합니다.

장점:

  • 단순하고 직관적입니다.
  • 스레드 간 문제가 발생하지 않습니다.
  • Class 타입을 지정할 필요가 없습니다.

단점:

  • JSON 형태로 직접 인코딩/디코딩을 해야 합니다.
  • 객체를 저장할 때 추가적인 변환 작업이 필요합니다.

추가 고려사항:

  • 문자열 이외의 데이터를 저장할 때는 별도의 변환 로직이 필요합니다.
  • 성능상 이점이 있어 키(key)의 직렬화에 주로 사용됩니다.

3. GenericJackson2JsonRedisSerializer

GenericJackson2JsonRedisSerializer는 모든 Class Type을 JSON 형태로 저장할 수 있는 범용 Serializer입니다.

장점:

  • Class Type에 상관없이 모든 객체를 직렬화할 수 있습니다.
  • 별도의 타입 지정 없이 다양한 객체를 저장할 수 있어 유연합니다.

단점:

  • 객체의 Class 및 package 정보를 함께 저장하므로 저장 공간을 더 사용합니다.
  • 다른 프로젝트에서 저장된 값을 사용하려면 package까지 일치시켜야 합니다.
  • MSA(Microservice Architecture) 구조의 프로젝트에서는 package 불일치로 인한 문제가 발생할 수 있습니다.

추가 고려사항:

  • 역직렬화 시 클래스 정보를 사용하므로, 클래스 구조가 변경되면 저장된 데이터의 역직렬화에 문제가 발생할 수 있습니다.
  • 보안 측면에서 클래스 정보가 노출될 수 있으므로 주의가 필요합니다.




에러 1. Java 8 date/time type java.time.LocalDateTime not supported by default

com.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 라이브러리로 직렬화/역직렬화할 때 문제가 발생할 수 있습니다.

  1. Jackson 라이브러리는 기본적으로 Java 8의 새로운 날짜/시간 API를 지원하지 않습니다.
  2. jackson-datatype-jsr310 모듈이 필요하지만, 이 모듈만으로는 충분하지 않을 수 있습니다.
  3. Spring Boot 프로젝트에서는 spring-boot-starter-web을 통해 jackson-datatype-jsr310이 자동으로 포함됩니다.

이 문제를 해결하려면 ObjectMapper에 JavaTimeModule을 명시적으로 등록해야 합니다.

ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());

ObjectMapper와 JavaTimeModule의 역할

  • ObjectMapper: JSON과 Java 객체 간의 변환을 담당하는 Jackson의 핵심 클래스입니다.
  • JavaTimeModule: Java 8의 날짜/시간 타입을 Jackson이 처리할 수 있게 해주는 모듈입니다.

왜 기본으로 지원하지 않는가?

  1. 하위 호환성 유지: 기존 애플리케이션의 동작을 보존하기 위함입니다.
  2. 유연성 제공: 개발자가 필요에 따라 모듈을 선택적으로 사용할 수 있게 합니다.
  3. 성능 최적화: 필요한 기능만 로드하여 메모리 사용을 최적화합니다.

적용 예시

@Configuration
public class JacksonConfig {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());
        return objectMapper;
    }
}

하지만 이 설정을 해도 에러는 해결되지 않았습니다.

해결 방법 - Custom ObejctMapper 생성하기

  1. Custom ObjectMapper를 생성하고, JavaTimeModule을 등록한다.
  2. GenericJackson2JsonRedisSerializer의 파라미터로 해당 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 서버에 대한 메모리 관리가 될 겁니다.

지금 코드에서 마음에 안드는 부분은 패키지 정보가 들어있는 점이 마음에 들지 않습니다.

다음 글에서는 이 패키지 정보에 대한 문제를 해결하는 방법을 작성해보겠습니다.




참고

https://velog.io/@bagt/Redis-%EC%97%AD%EC%A7%81%EB%A0%AC%ED%99%94-%EC%82%BD%EC%A7%88%EA%B8%B0-feat.-RedisSerializer

https://woo-chang.tistory.com/75

https://khdscor.tistory.com/100

profile
서버 백엔드 개발자

0개의 댓글