ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 쿠폰 발급 요청 시, 확인하는 쿠폰 정보를 Redis Cache에 담아 개선하기
    SpringBoot 2024. 3. 19. 23:48

     

    들어가며

    선착순 쿠폰 발급 기능 개발을 진행하고 있는데, 현재 쿠폰 발급 요청에 대한 서비스 로직에

    쿠폰 발급 전에 확인하는 것 중 하나인 해당 쿠폰의 유효성 검증 부분에 개선점이 필요했습니다.

     

    Coupon coupon = couponIssueService.findCoupon(couponId);

     

    클라이언트에서 쿠폰 발급 요청 API를 호출할 때마다,

    위와 같은 방식으로 데이터베이스에 있는 couponId에 대한 쿠폰 정보를 가져와서, 쿠폰의 유효성 검증을 수행했습니다.

     

    여기서 유효성 검증은

    사용자가 쿠폰 발급을 요청한 해당 쿠폰이 발급 가능 기간에 속하는지 검증하는 단계라고 보면 됩니다.

     

    하지만 이러한 방식은 사용자가 요청할 때마다, 데이터베이스에서 값을 조회하기 때문에

    순식간에 많은 사용자가 쿠폰 발급을 요청한다면 데이터베이스에 부담이 갈 수도 있습니다.

     

    그러므로 레디스 캐시를 적용하여 해당 문제점을 개선하고자 합니다.

    캐시를 사용하기 때문에 속도 측면에서도 더 우위를 점할 수도 있습니다.

     

     

     

     

    Cache란?

    대학교 컴퓨터 구조 수업을 수강한 분들이면 캐시에 대해서 익히 들어보셨을 겁니다.

    캐시는 자주 사용되는 데이터를 저장하는 공간으로, 자주 사용되는 데이터를 매번 요청 때마다

    생성하여 응답하는 것보다 생성된 데이터를 저장해놓고

    동일한 요청이 왔을 때, 로직을 거치지 않고 데이터를 반환해주는 것이

    서버 상 리소스 사용을 줄일 수 있기 때문에 성능을 향상할 수 있습니다.

     

    스프링부트 상에서 캐시 동작 방식

     

    그렇다면 캐시는 어디에 사용하는 것이 좋을까요?

     

    캐시를 적용하기 좋은 상황

    • 클라이언트에게 전달되는 값이 동일할 때
    • 빈번하게 호출될 때
    • 한 번 처리할 때 많은 서버 리소스를 요구할 때

     

    캐시를 적용하기 까다로운 상황

    • 실시간으로 정확성을 요구하는 경우
    • 빈번하게 데이터 변경이 일어나는 경우

     

    저의 경우, 쿠폰 발급 요청을 할 때마다

    쿠폰 정보에서 쿠폰 발급 유효기간, 쿠폰 총 수량, 쿠폰 id, 쿠폰 type과 같은 정적인 정보를 통해

    쿠폰 유효성 검증을 진행하므로, 매번 데이터베이스에서 조회하는 방식보다는

    위와 같이 캐시를 적용하기 좋은 상황에 해당합니다.

     

     

     

     

    설정

    1. Dependency

    // redis
    implementation("org.springframework.boot:spring-boot-starter-data-redis")

     

    redis를 사용하는 이유 중 하나는, spring-boot-starter에서 제공하는 캐시는

    인-메모리 방식이기 때문에 서버를 끌 때 데이터(캐시)가 날아가므로

    계속 유지하기 위해서 외부 저장소인 redis를 사용합니다!

     

    참고로 캐시에 담을 쿠폰 정보에 LocalDateTime 형식의 필드값들이 존재하므로

    아래와 같이 특정 필드 직렬화 방식 변경을 위한 jackson 의존성 주입을 추가해주었습니다.

     

    // jackson
    implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
    implementation("com.fasterxml.jackson.core:jackson-databind")

     

    2. Redis Configuration

    application.yml

     spring
       data:
         redis:
           host: {host}
           port: {port}

     

    RedisConfiguration.java

    @Configuration
    public class RedisConfiguration {
        @Value("${spring.data.redis.host}")
        private String host;
    
        @Value("${spring.data.redis.port}")
        private int port;
    
        @Bean
        RedissonClient redissonClient() {
            Config config = new Config();
            String address = "redis://" + host + ":" + port;
            config.useSingleServer().setAddress(address);
            return Redisson.create(config);
        }
    }

     

    3. Redis Cache Configuration

    @EnableCaching 추가

    CouponCoreConfiguration

    @EnableCaching
    @EnableJpaAuditing
    @ComponentScan
    @EnableAutoConfiguration
    public class CouponCoreConfiguration {
    }

     

    CacheConfiguration

    package com.project.couponcore.configuration;
    
    import lombok.RequiredArgsConstructor;
    import org.springframework.cache.CacheManager;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.data.redis.cache.RedisCacheConfiguration;
    import org.springframework.data.redis.cache.RedisCacheManager;
    import org.springframework.data.redis.connection.RedisConnectionFactory;
    import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
    import org.springframework.data.redis.serializer.RedisSerializationContext;
    import org.springframework.data.redis.serializer.StringRedisSerializer;
    
    import java.time.Duration;
    
    @Configuration
    @RequiredArgsConstructor
    public class CacheConfiguration {
    
        private final RedisConnectionFactory redisConnectionFactory;
    
        @Bean
        public CacheManager redisCacheManager() {
            RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
                    .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                    .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
                    .entryTtl(Duration.ofMinutes(30));
            return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory)
                    .cacheDefaults(redisCacheConfiguration)
                    .build();
        }
    }

     

    참고로 스프링 부트의 기본 캐시는 인-메모리 기반의 캐시를 사용합니다.

    스프링 부트는 기본적으로 캐시 추상화를 지원하며,

    캐시 매니저로는 기본적으로 ConcurrentMapCacheManager를 사용하는데

    ConcurrentHashMap을 기반으로 한 간단한 메모리 캐시를 제공한다고 합니다.

     

    CacheManager는 Spring 프레임워크에서 캐시 관리를 추상화한 인터페이스입니다.

    즉, 이 인터페이스를 구현한 구현체는 다양한 종류의 캐시 스토어를 지원할 수 있으므로

    위의 코드에서 반환된 CacheManager 구현체는 Redis를 기반으로 하는 캐시 매니저입니다.

    그 말은 즉슨 RedisCacheManager라는 클래스로 구현되어 있으며, Redis를 캐시 스토어로 사용한다는 것을 의미하게 됩니다.

     

    정리하면 위와 같이 RedisCacheManager를 구성하여, 기본 캐시가 Redis Cache로 적용됩니다.

     

     

     

     

    구현

    아래 CouponRedisEntity를 보시면 자바의 record를 통해 캐시에 담을 쿠폰 정보 DTO를 생성해줍니다.

    Java 14부터 도입된 Record 클래스에 대한 자세한 내용은 아래 링크를 참고해주세요.

     

    자바의 Record로 DTO를 만들어보자

    최근에 유튜브를 보다가 흥미로운 영상을 보게 되었습니다. DTO를 자바의 record라는 것을 이용해서 구현하는 영상이었는데요, DTO를 많이 만드는 저에게는 매우 흥미로운 영상이었습니다. (저는

    s7won.tistory.com

     

    CouponRedisEntity

    package com.project.couponcore.model;
    
    import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
    import com.fasterxml.jackson.databind.annotation.JsonSerialize;
    import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
    import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
    import com.project.couponcore.exception.CouponIssueException;
    
    import java.time.LocalDateTime;
    
    import static com.project.couponcore.exception.ErrorCode.INVALID_COUPON_ISSUE_DATE;
    
    public record CouponRedisEntity (
        Long id,
        CouponType couponType,
        Integer totalQuantity,
    
        @JsonSerialize(using = LocalDateTimeSerializer.class)
        @JsonDeserialize(using = LocalDateTimeDeserializer.class)
        LocalDateTime dateIssueStart,
    
        @JsonSerialize(using = LocalDateTimeSerializer.class)
        @JsonDeserialize(using = LocalDateTimeDeserializer.class)
        LocalDateTime dateIssueEnd
    
    ) {
        public CouponRedisEntity(Coupon coupon) {
            this(
                    coupon.getId(),
                    coupon.getCouponType(),
                    coupon.getTotalQuantity(),
                    coupon.getDateIssueStart(),
                    coupon.getDateIssueEnd()
            );
        }
    
        private boolean availableIssueDate() {
            LocalDateTime now = LocalDateTime.now();
            return dateIssueStart.isBefore(now) && dateIssueEnd.isAfter(now);
        }
    
        public void checkIssuableCoupon() {
            if (!availableIssueDate()) {
                throw new CouponIssueException(INVALID_COUPON_ISSUE_DATE, "발급 가능한 일자가 아닙니다. couponId: %s, issueStart: %s, issueEnd: %s".formatted(id, dateIssueStart, dateIssueEnd));
            }
        }
    }

     

     

    CouponCacheService

    package com.project.couponcore.service;
    
    import com.project.couponcore.model.Coupon;
    import com.project.couponcore.model.CouponRedisEntity;
    import lombok.RequiredArgsConstructor;
    import org.springframework.cache.annotation.Cacheable;
    import org.springframework.stereotype.Service;
    
    @Service
    @RequiredArgsConstructor
    public class CouponCacheService {
    
        private final CouponIssueService couponIssueService;
    
        @Cacheable(cacheNames = "coupon")
        public CouponRedisEntity getCouponCache(long couponId) {
            Coupon coupon = couponIssueService.findCoupon(couponId);
            return new CouponRedisEntity(coupon);
        }
    }

     

     

    AsyncCouponIssueServiceV1

    @Service
    @RequiredArgsConstructor
    public class AsyncCouponIssueServiceV1 {
        private final RedisRepository redisRepository;
        private final CouponIssueRedisService couponIssueRedisService;
        private final CouponIssueService couponIssueService;
        private final CouponCacheService couponCacheService;
    
        private final DistributeLockExecutor distributeLockExecutor;
        private final ObjectMapper objectMapper = new ObjectMapper();
    
        public void issue(long couponId, long userId) {
    
            CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
            coupon.checkIssuableCoupon();
    
            // 레디스 동시성 이슈 해결하기 위함
            distributeLockExecutor.execute("lock %s".formatted(couponId), 3000, 3000, () -> {
                if (!couponIssueRedisService.availableTotalIssueQuantity(coupon.totalQuantity(), couponId)) {
                    throw new CouponIssueException(INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초과합니다. couponId: %s, userId: %s".formatted(couponId, userId));
                }
    
                if (!couponIssueRedisService.availableUserIssueQuantity(couponId, userId)) {
                    throw new CouponIssueException(DUPLICATED_COUPON_ISSUE, "이미 발급 요청이 처리됐습니다. couponId: %s, userId: %s".formatted(couponId, userId));
                }
    
                issueRequest(couponId, userId);
            });
        }
    
        /**
         * 쿠폰 발급 요청 추가
         */
        private void issueRequest(long couponId, long userId) {
            RequestCouponIssueDto requestCouponIssueDto = new RequestCouponIssueDto(couponId, userId);
            // String 타입으로 직렬화
            try {
                String value = objectMapper.writeValueAsString(requestCouponIssueDto);
    
                redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
                // 쿠폰 발급 큐에 적재
                redisRepository.rPush(getIssueRequestQueueKey(), value);
            } catch (JsonProcessingException e) {
                throw new CouponIssueException(FAIL_COUPON_ISSUE_REQUEST, "input: %s".formatted(requestCouponIssueDto));
            }
        }
    }

     

    issue 메소드의 두, 세번째 줄의 코드를 보다시피 위에서 구현한 캐시 서비스 로직을 통해

    쿠폰 유효성 검사를 수행하는 것을 확인할 수 있습니다.

     

     

     

     

    Redis에서 캐시 확인

    이제 Redis에 캐시가 잘 들어가 있는지 확인을 해보겠습니다.

     

    쿠폰 발급 요청 API를 호출한 후,

    redis-cli에 접속하면 아래와 같이 coupon 정보가 담긴 캐시가 잘 들어있는 것을 확인할 수 있습니다.

     

     

     

     

     

    마무리

    그러면 이제 쿠폰 발급 요청이 들어올 때마다, 매번 데이터베이스에 있는 쿠폰 정보를 가져오지 않고

    캐시에 한번 저장한 후, 설정한 캐시의 유효기간 동안

    캐시에 접근하여 값을 가져오게 되므로 성능이 이전보다 개선될 수 있습니다.

     

     

     

     

     

    출처

     

    Spring Cache 캐시 추상화 기본적인 사용법 @Cacheable @CachePut @CacheEvict

    'Spring Boot 프로젝트 Cache 캐시 추상화 기본적인 사용법' 먼저 캐시(Cache)란, 캐시란 서버의 부담을 줄이고, 성능을 높이기 위해 사용되는 기술입니다. 예를 들어서 어떤 요청을 처리하는데 DB에서

    wildeveloperetrain.tistory.com

     

     

     

    [Spring] 스프링 캐시 알아보기 (@Cacheable, @CachePut, @CacheEvict)

    스프링 캐시 캐시란? 자주 사용되는 데이터를 저장하는 공간을 의미합니다. 자주 사용되는 데이터를 매번 요청 때마다 생성하여 응답하는 것 보다는 생성된 데이터를 저장해놓고 똑같은 요청이

    hyeri0903.tistory.com

     

     

     

    Spring Boot 에서 Redis Cache 사용하기

    Overview 베이스 코드로 Spring Boot Cache 적용에 있던 코드들을 재활용할 예정이라 앞의 글을 먼저 읽어보는걸 추천합니다. 단순하게 Redis Cache 설정만 알고 싶다면 상관 없습니다. 코드를 직접 실행해

    bcp0109.tistory.com

     

Designed by Tistory.