🔥스파르타 TIL (Spring)

동시성 제어에 Redisson과 Lua Script 사용해보기

승승장규 2025. 5. 5. 20:33

Redisson은 Java 기반의 Redis 클라이언트 라이브러리로, Redis의 다양한 기능을 Java 객체 형태로 쉽게 사용할 수 있도록 추상화 계층을 제공합니다. 이를 통해 분산 락, 트랜잭션, 캐싱, pub/sub 등 Redis의 고급 기능을 활용하여 분산 시스템을 구축할 수 있으며, Netty 기반의 비동기 방식으로 높은 성능을 자랑합니다.

Lua Script는 경량의 고급 프로그래밍 언어인 Lua를 Redis에서 사용할 수 있도록 해주는 기능입니다. Redis는 기본적으로 한 줄씩 단일 명령어를 처리하는 방식으로 동작하지만, Lua Script를 사용하면 Redis 내에서 여러 명령어를 하나의 원자적 작업으로 처리할 수 있습니다.

 

현재 시스템에서는 선착순 쿠폰 발급 기능을 구현하기 위해 Redis를 공통 객체로 사용하고 있습니다. 하지만, 동시성 제어가 적용되지 않아 여러 요청이 동시에 처리될 때 쿠폰의 수량이 과다하게 감소하는 문제가 발생하고 있습니다. 특히, Kafka를 활용한 메시징 시스템에서는 높은 동시성 문제를 해결하기 위한 별도의 동기화 메커니즘이 필요합니다.

 

public class CouponToUserConsumerServiceImpl implements CouponToUserConsumerService {

    private final CouponRepository couponRepository;
    private final RedisOperationService redisOperationService;

    @Transactional
    @Override
    public KafkaUserInfoMessage producerMessage(KafkaUserInfoMessage message) {
        try {
            Coupon coupon = couponRepository.findByIdAndDeletedAtIsNull(message.getCouponId())
                    .orElseThrow(() -> new CouponException(ResponseCode.COUPON_NOT_FOUND));

            Long updatedQuantity = handleCouponQuantity(message.getCouponId(), coupon);

            handleUserIssuance(message.getCouponId(), message.getUserId());

            coupon.updateQuantity(updatedQuantity, message.getUserId(), coupon);

            return createSuccessMessage(message, coupon);

        } catch (RuntimeException e) {
            return createFailedMessage(message);
        }
    }

10000 건의 요청 단위 테스트 진행 결과 - 총 3번 진행

동시성 제어를 적용하지 않고 확인한 결과, 여러 스레드에서 동시에 재고를 차감하고 있기 때문에 필요한 수량보다 더 많이 차감되는 것을 확인할 수 있었습니다.

이제 Redisson과 Lua Script를 적용해서 동시성 제어를 시도해보겠습니다.

public class CouponToUserConsumerServiceImpl implements CouponToUserConsumerService {

    private final CouponRepository couponRepository;
    private final RedisOperationService redisOperationService;
    private final RedissonClient redissonClient;

     private static final String LUA_SCRIPT = """
            -- KEYS[1] : 쿠폰 수량을 저장하는 Redis 키
            -- KEYS[2] : 이미 쿠폰을 발급받은 유저들을 저장하는 Redis Set 키
            -- ARGV[1] : 발급하려는 유저의 ID
            local qtyKey = KEYS[1]   -- 스크립트가 실행될 때 전달받은 첫 번째 키
            local userKey = KEYS[2]  -- Redis Set의 키
            local userId = ARGV[1]   -- 스크립트 실행 시 전달받은 첫 번째 인수 -> 동적인 값
            
            local current = redis.call('GET', qtyKey)
            
            -- tonumber : 값을 숫자로 변환하는 함수
            if current == nil or tonumber(current) <= 0 then
                return -1  -- -1을 반환하여 쿠폰 수량 부족을 알림
            end
            
            -- SISMEMBER : Redis의 Set 자료형에서 특정 값이 존재하는지 확인
            if redis.call('SISMEMBER', userKey, userId) == 1 then
                return -2  -- -2를 반환하여 이미 발급된 유저임을 알림
            end
            redis.call('DECR', qtyKey)
            redis.call('SADD', userKey, userId)
            
            -- 쿠폰 발급 후, 남은 수량을 반환 (현재 수량에서 하나 감소한 값)
            return tonumber(current) - 1
            """;
            
    @Transactional
    @Override
    public KafkaUserInfoMessage producerMessage(KafkaUserInfoMessage message) {
        UUID couponId = message.getCouponId();
        UUID userId = message.getUserId();
        String redisKey = "couponId:" + couponId;
        String issuedUserKey = "issuedUsers:" + couponId;

        // 초기 수량 확인
        Long currentQuantity = redisOperationService.getCurrentQuantity(redisKey);
        if (currentQuantity == null || currentQuantity <= 0) {
            log.warn("쿠폰이 모두 소진되었습니다. couponId: {}, currentQuantity: {}", couponId, currentQuantity);
            return createFailedMessage(message);
        }

        String lockKey = "lock:coupon:" + couponId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            if (lock.tryLock(1, 3, TimeUnit.SECONDS)) {
                try {
                    Coupon coupon = couponRepository.findByIdAndDeletedAtIsNull(couponId)
                            .orElseThrow(() -> new CouponException(ResponseCode.COUPON_NOT_FOUND));

                    // Redis 키 초기화
                    if (!redisOperationService.hasKey(redisKey)) {
                        redisOperationService.initializeQuantity(redisKey, coupon.getQuantity());
                    }

                    // Lua 스크립트 실행
                    Long updatedQuantity = redisOperationService.evalScript(
                            LUA_SCRIPT, redisKey, issuedUserKey, userId.toString());
                    if (updatedQuantity == -1) {
                        log.warn("쿠폰 수량이 부족합니다. couponId: {}", couponId);
                        throw new CouponException(ResponseCode.COUPON_QUANTITY_EXCEPTION);
                    }
                    if (updatedQuantity == -2) {
                        log.error("이미 발급에 성공한 유저입니다. userId: {}", userId);
                        throw new IllegalArgumentException("중복된 userId 입니다.");
                    }

                    coupon.updateQuantity(updatedQuantity, userId, coupon);
                    return createSuccessMessage(message, coupon);
                } finally {
                    lock.unlock();
                }
            } else {
                log.warn("락 획득 실패: couponId: {}", couponId);
                return createFailedMessage(message);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return createFailedMessage(message);
        } catch (Exception e) {
            return createFailedMessage(message);
        }
    }

 

이제 똑같이 10000건의 요청을 시도해보면

 

동시성 문제를 해결한 것을 볼 수 있다. 추가로 100000건의 요청도 테스트 해본 결과

정상적으로 발급에 성공한 것을 확인할 수 있었습니다.