레디스 캐시 적용하기

최명진·2024년 6월 19일
0
@RequiredArgsConstructor
@Configuration
public class CacheConfiguration {

private final RedisConnectionFactory redisConnectionFactory;

@Bean
public CacheManager cacheManager() {
    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();
	}
}

레디스 캐시를 사용하기 위한 설정, Ttl(Time to live)는 30분으로 key는 string, value는 generic2json 형식으로 저장되게 설정했다.

 public void issue(long couponId, long userId) {
        CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
        coupon.checkIssuableCoupon();
        distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
            couponIssueRedisService.checkCouponIssueQuantity(coupon, userId);
            issueRequest(couponId, userId);
        });
    }

DB에서 CouponId로 가져오는 Coupon에 캐시를 적용, 이전에 만들었던 distributeLockExecutor을 통해 lock까지 적용시켰다.

@RequiredArgsConstructor
@Service
public class CouponCacheService {

    private final CouponIssueService couponIssueService;

    @Cacheable(cacheNames = "coupon")
    public CouponRedisEntity getCouponCache(long couponId) {
        Coupon coupon = couponIssueService.findCoupon(couponId);
        return new CouponRedisEntity(coupon);
    }
}

간단하게 @Cacheable 어노테이션을 통해 캐시에 저장되게 했다.

이제 Locust를 통해 부하 테스트를 진행 해보자

생각보다 RPS가 잘 나오지 않는 모습이다.


위에는 우리가 저장한 CouponRedisEntity라는 캐시가 정상적으로 저장된 것을 확인할 수 있다.


Set에도 500개의 row가 저장되었음을 확인


쿠폰 발급 Queue에도 500개의 row가 저장되었음을 확인했다.


로그를 확인하니 distributeLockExecutor의 lock 획득을 실패한다는 에러를 확인했다. 그래서 레디스를 통해 동시성을 제어하도록 수정할 것이다.
일단 RPS가 너무 낮게 나오는 문제를 발견했고, issue의 lock이 문제라고 생각하여 지우고 다시 부하테스트를 진행했다.

RPS가 훨씬 빠르게 증가한 것을 보아 distributeLockExecutor을 걸어주는 것이 문제인 것 같다.

현재 issue의 로직은 이러하다
1. 쿠폰 발급 수량 제어
2. 중복 발급 요청 제어
3. 쿠폰 발급 요청 저장
4. 쿠폰 발급 큐 적재
이것을 레디스를 통해 제어하는데, 이 4개를 한번에 묶어서 하는 방법이 있다. 그것이 바로 Redis의 script이다. script에 위 4개의 과정을 담아서 실행하면 하나의 원자성을 갖고, 레디스는 싱글 스레드이기 때문에 다른 커맨드가 끼어들지 못하기 때문에 동시성 문제를 해결할 수 있다.

public void issueRequest(long couponId, long userId, int totalIssueQuantity) {
    String issueRequestKey = getIssueRequestKey(couponId);
    CouponIssueRequest couponIssueRequest = new CouponIssueRequest(couponId, userId);
    try {
        String code = redisTemplate.execute(
                issueScript,
                List.of(issueRequestKey, issueRequestQueueKey),
                String.valueOf(userId),
                String.valueOf(totalIssueQuantity),
                objectMapper.writeValueAsString(couponIssueRequest)
        );
        CouponIssueRequestCode.checkRequestResult(CouponIssueRequestCode.find(code));
    } catch (JsonProcessingException e) {
        throw new CouponIssueException(FAIL_COUPON_ISSUE_REQUEST, "input: %s".formatted(couponIssueRequest));
    }
}

private RedisScript<String> issueRequestScript() {
    String script = """
             if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
                 return '2'
             end
                            \s
             if tonumber(ARGV[2]) > redis.call('SCARD', KEYS[1]) then
                 redis.call('SADD', KEYS[1], ARGV[1])
                 redis.call('RPUSH', KEYS[2], ARGV[3])
                 return '1'
             end
                            \s
             return '3'
            \s""";
    return RedisScript.of(script, String.class);
}
    public void issue(long couponId, long userId) {
        CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
        coupon.checkIssuableCoupon();
        issueRequest(couponId, userId, coupon.totalQuantity());
    }

    private void issueRequest(long couponId, long userId, Integer totalIssueQuantity) {
        if(totalIssueQuantity == null) totalIssueQuantity = Integer.MAX_VALUE;
        redisRepository.issueRequest(couponId, userId, totalIssueQuantity);
    }

위와 같이 레디스 스크립트를 통해 동시성 제어를 개선한 후에 RPS를 다시 측정해보았다.

RPS 속도가 매우 빨라진 모습을 확인할 수 있다. 들어온 데이터는 어떨까?



캐시, 셋, 큐까지 문제 없이 동시성 제어가 잘 이뤄진 것을 확인 할 수 있었고, 콘솔에서 락에 관련된 Exception도 발생되지 않음을 확인 할 수 있었다.

profile
주니어 백엔드 개발자 Choi입니다.

0개의 댓글