ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 기존에 동시성 이슈 문제 해결을 위해 사용한 Redisson 대신 Redis의 script를 사용해서 성능 올리기
    SpringBoot 2024. 3. 24. 00:21

     

    들어가며

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

     

    기존에 위 메소드에서 distributeLockExecutor 부분을 보다시피,

    redisson을 사용하여 동시성 이슈를 해결하고 있었는데

     

    locust를 통해 부하테스트를 시행하니 rps가 낮게 나오는 것을 확인할 수 있었습니다.

     

     

    부하테스트를 수행하기 전, 설정한 조건

     

    왼쪽은 제 local 환경에서 설치한 redis를 통해 수행한 부하테스트, 오른쪽은 AWS 인스턴스에 docker를 통해 올린 redis에 대한 부하테스트

     

    왼쪽과 오른쪽의 RPS가 현저히 차이나고, 오른쪽 사진에서 Failures가 매우 높은 것으로 확인할 수 있었습니다.

     

    왼쪽 스펙

    CPU : Apple M3 (8코어)

    RAM : 16GB

     

    오른쪽 스펙

    CPU : 2코어

    RAM : 2GB

     

    이는 하드웨어적인 이슈로 인해 발생한 복합적인 문제로 파악되고,

    어찌보면 해당 차이는 당연한 현상입니다.

     

     

    부하테스트 수행 전에, 쿠폰 발급 최대 개수 제한을 500개로 걸어놨고

    아래와 같이 레디스에서 500개 만큼 발급 요청을 잘 수행하는 것을 확인할 수 있었습니다.

     

    잘 발급되고 있으니, 문제없는 것 아닌가?

     

     

     

     

    문제

    하지만 문제는 지금부터입니다.

    왼쪽, 오른쪽 스펙에서 모두 lock을 획득하는데 실패했다는 exception이 발생하는 것을 확인할 수 있었습니다.

     

    distributeLockExecutor 부분에서 분산 락을 걸고,

    해제하며 동시성을 처리하는 게 성능 상으로 문제가 있다는 것을 알 수 있었습니다.

    즉, 분산 락에서 병목 현상이 발생한 것입니다.

     

    그래서 기존처럼 동시성 이슈를 해결하면서

    위와 같은 문제를 개선하기 위해 배운 내용들을 기록하려고 합니다.

     

     

     

     

    해결법

    1. totalQuantity > redisRepository.sCard(key); // 쿠폰 발급 수량 제어
    2. !redisRepository.sIsMember(key, String.valueOf(userId)); // 중복 발급 요청 제어
    3. redisRepository.sAdd // 쿠폰 발급 요청 저장
    4. redisRepository.rPush // 쿠폰 발급 큐 적재

     

    기존처럼 위의 과정들을 lock을 걸지 않고, 한번에 처리할 수 있는 방법이 존재했습니다.

     

    하나로 묶어서 Redis에 단일 커맨드로 처리할 수 있고

    이를 통해 맨 위에서 말했던 distributeLockExecutor처럼 lock을 거는 것이 불필요하게 됩니다.

     

     

    그렇다면 어떻게 단일 커맨드로 처리하지??

     

    Redis Script

     

    EVAL

    Executes a server-side Lua script.

    redis.io

     

    Redis에서 EVAL을 사용한다면 script를 전달해 줄 수 있고

    그 뒤에 전달하는 key의 수, 어떤 키가 들어가는지 등등 arg를 전달할 수 있습니다.

     

    스프링부트에서는 RedisScript.of(script, String.class)와 같이 

    제공하는 인터페이스를 활용하여 적용할 수 있습니다.

     

    Redis의 경우 싱글 스레드로 작동하므로

    script 안의 내용들 자체는 하나의 원자성을 띄게 되어,

    실행이 되는 중간에 다른 커맨드들이 실행될 수 없게 됩니다.

    이렇게 되면 자연스레 동시성 이슈도 해결이 가능합니다.

     

     

     

     

    구현

    RedisRepository

    @Repository
    @RequiredArgsConstructor
    public class RedisRepository {
    
        private final RedisTemplate<String, String> redisTemplate;
        private final RedisScript<String> issueScript = issueRequestScript();
        private final String issueRequestQueueKey = getIssueRequestQueueKey();
        private final ObjectMapper objectMapper = new ObjectMapper();
    
    	....
    
        public void issueRequest(long couponId, long userId, int totalIssueQuantity) {
            String issueRequestKey = getIssueRequestKey(couponId);
            RequestCouponIssueDto requestCouponIssueDto = new RequestCouponIssueDto(couponId, userId);
            try {
                String code = redisTemplate.execute(
                        issueScript,
                        List.of(issueRequestKey, issueRequestQueueKey),
                        String.valueOf(userId),
                        String.valueOf(totalIssueQuantity),
                        objectMapper.writeValueAsString(requestCouponIssueDto)
                );
    
                CouponIssueRequestCode.checkRequestResult(CouponIssueRequestCode.find(code));
    
            } catch (JsonProcessingException e) {
                throw new CouponIssueException(FAIL_COUPON_ISSUE_REQUEST, "input: %s".formatted(requestCouponIssueDto));
            }
        }
    
        // Redis Script
        private RedisScript<String> issueRequestScript() {
            String script = """
                            if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
                                return '2'
                            end
                            
                            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
                            
                            return '3'
                            """;
            return RedisScript.of(script, String.class);
        }
    }

     

    CouponIssueRequestCode

    public enum CouponIssueRequestCode {
    
        /**
         * 성공한 응답
         */
        SUCCESS(1),
    
        /**
         * 중복 발급에 대한 응답
         */
        DUPLICATED_COUPON_ISSUE(2),
    
        /**
         * 발급 수량에 문제가 있는 응답
         */
        INVALID_COUPON_ISSUE_QUANTITY(3);
    
        CouponIssueRequestCode(int code) {
    
        }
    
        public static CouponIssueRequestCode find(String code) {
            int codeValue = Integer.parseInt(code);
            if (codeValue == 1) return SUCCESS;
            if (codeValue == 2) return DUPLICATED_COUPON_ISSUE;
            if (codeValue == 3) return INVALID_COUPON_ISSUE_QUANTITY;
            throw new IllegalArgumentException("존재하지 않는 코드입니다. %s".formatted(code));
        }
    
        public static void checkRequestResult(CouponIssueRequestCode code) {
            if (code == INVALID_COUPON_ISSUE_QUANTITY) {
                throw new CouponIssueException(ErrorCode.INVALID_COUPON_ISSUE_QUANTITY, "발급 가능한 수량을 초고합니다.");
            }
            if (code == DUPLICATED_COUPON_ISSUE) {
                throw new CouponIssueException(ErrorCode.DUPLICATED_COUPON_ISSUE, "이미 발급 요청된 쿠폰입니다.");
            }
        }
    }

     

    RedisRepository안에 작성한 script에서 return 하는 값에 따라 enum값을 통해 매칭시켜줍니다.

     

    AsyncCouponIssueServiceV2

    @Service
    @RequiredArgsConstructor
    public class AsyncCouponIssueServiceV2 {
        private final RedisRepository redisRepository;
        private final CouponCacheService couponCacheService;
    
        public void issue(long couponId, long userId) {
    
            // 캐시를 통해 쿠폰에 대한 유효성 검증 수행
            CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
            coupon.checkIssuableCoupon();
    
            // Redis Script 내용 수행
            issueRequest(couponId, userId, coupon.totalQuantity());
        }
    
        /**
         * 쿠폰 발급 요청 추가
         */
        private void issueRequest(long couponId, long userId, Integer totalIssueQuantity) {
            // null인 경우가 존재하므로 MAX_VALUE로 설정하여 검증 우회
            if (totalIssueQuantity == null) {
                redisRepository.issueRequest(couponId, userId, Integer.MAX_VALUE);
            }
            redisRepository.issueRequest(couponId, userId, totalIssueQuantity);
        }
    }

     

    맨 위에서의 issue 메소드와 다르게 distributeLockExecutor 부분을 사용하지 않으므로,

    해당 부분을 지워주고 RedisRepository에서 작성한 script를 사용하도록 적용해줍니다.

     

    마지막으로 해당 서비스로직를 사용하는 컨트롤러를 생성해줍니다.

     

     

     

     

    테스트

    Locust를 실행하여 처음에 주었던 조건과 동일하게 설정을 해주고 테스트를 진행합니다.

     

    local 환경

     

    위에서 local 환경으로 테스트를 진행했던 RPS보다 2배 조금 더 높게 나오는 것을 확인할 수 있습니다.

    또한 가장 문제였던 lock 획득 실패 오류가 발생하지 않고, 정상적으로 로그가 찍히는 것을 확인할 수 있었습니다.

     

    데이터도 위와 같이 최대 발급으로 설정했던 500개에 맞게 쿠폰 발급이 잘되는 것을 보니,

    동시성 이슈도 잘 해결되고 있음을 확인할 수 있었습니다.

     

    추가로 RPS를 더 높이고 싶다면

    저의 경우 locust를 docker를 통해 띄우기 때문에

    version: '3.7'
    services:
      master:
        image: locustio/locust
        ports:
          - "8089:8089"
        volumes:
          - ./:/mnt/locust
        command: -f /mnt/locust/locustfile-issue-asyncV2.py --master -H http://host.docker.internal:8080
    
      worker:
        image: locustio/locust
        volumes:
          - ./:/mnt/locust
        command: -f /mnt/locust/locustfile-issue-asyncV2.py --worker --master-host master

     

    부하 테스트를 위해 마스터와 여러 개의 워커로 구성된 분산 환경을 설정하기 위해

    위와 같이 docker-compose.yml을 작성해줍니다.

    마스터는 실제로 테스트를 실행하고, 워커는 마스터의 지시를 받아 테스트 부하를 생성하는 구조입니다.

     

    그리고 아래와 같이 worker의 수를 늘려주면됩니다. (해당 경우는 worker의 수가 3)

    $ docker-compose up -d --scale worker=3

     

    다시 부하 테스트를 진행해 줍니다.

     

     

    위와 같이 worker의 수를 늘렸을 때 RPS가 더 높게 나온 것을 확인할 수 있습니다.

     

    사실 worker의 수를 늘린이유는

    worker의 수가 1개일 때, CPU 사용률이 100%에 가까워진다면

    워커가 부하를 만드는데 자기가 부하에 걸려 제대로 된 부하를 만들어 내지 못할 수 있습니다.

    그렇게 되면 API 서버 측에서는 요청을 더 받을 수 있는데, worker에서 부하를 잘 못 만들고 있으므로

    측정되는 RPS를 신뢰할 수 없게 되기 때문입니다.

     

    그러므로 worker의 수를 여러 대로 늘려서

    부하를 줄 users의 수도 분산 시켜서 부하를 어느 정도 해소할 수 있습니다.

     

     

     

    끝!

Designed by Tistory.