레디스 쿼리로 서버 RPS 개선

2024. 4. 29. 00:22프로젝트

이전 포스팅에서 이어집니다 (:

https://matt1235.tistory.com/73

https://matt1235.tistory.com/74

 

 

Spring Boot + Redis 로 쿠폰 발급 요청을 적재하고, 발급 내역을 저장하는 서버를 구현했습니다.

쿠폰 발급 요청이 들어왔을 때, 처리 순서는 다음과 같습니다.

 

 

1. 쿠폰 엔티티 캐싱

  • @Cacheable 어노테이션을 통해 스프링에서 쿠폰 엔티티 조회 결과를 캐싱한다.
  • 단기간에 쿠폰 발급을 요청하는 트래픽이 몰릴 때, 캐싱으로 성능을 향상시킬 수 있다.
@Service
@RequiredArgsConstructor
public class CouponCacheService {

    private final CouponIssueService couponIssueService;

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

 

 

2. 대기열 큐에 발급 요청 적재 (list), 쿠폰 발급 내역 저장 (set)

private void issueCoupon(long couponId, long userId) {
    CouponIssueRequest issueRequest = new CouponIssueRequest(couponId, userId);
    try {
        String value = objectMapper.writeValueAsString(issueRequest);
        redisRepository.rPush(getQueueKey(), value);
        redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
    } catch (JsonProcessingException e) {
        throw new BaseException(400, "쿠폰 발급 실패");
    }
}

 

 

레디스의 RPUSH, SADD 메서드를 통해 상수 시간: O(1) 만에 작업을 수행했습니다.

 

 

 

부하 테스트

부하 테스트를 진행해, 서버의 성능을 측정해 보겠습니다.

  • 사용 툴 : Locust

  • 선착순 쿠폰 개수를 2000개라고 가정하고, 테스트를 진행합니다.
  • 초당 100명의 유저 생성, 최대치는 1000 명으로 설정

 

결과

 

평균 RPS : 1000 (서버가 초당 몇 개의 요청을 처리할 수 있는지를 나타내는 지표)

 

레디스 List : 대기열 큐

  • 정확히 500개의 요청이 적재

레디스 Set : 중복 발급 검증 (중복 x)

  • 정확히 500개의 유저 Id 가 들어가 있습니다.

 

이와 같이 분산 락을 걸었으므로 동시성 이슈는 발생하지 않습니다.

다만 RPS = 1000 정도로, 생각만큼 RPS 성능이 나오지는 않는 것 같습니다.

 

 

RPS를 끌어올리기 위해 병목 지점을 찾고, 개선해보겠습니다.


병목 지점 찾기

 

현재 비동기로 쿠폰을 발급하는 AsyncCouponIssueService 의 메서드입니다.

 

AsyncCouponIssueService

private final DistributeLockExecutor distributeLockExecutor;
private final CouponCacheService couponCacheService;
private final RedisRepository redisRepository;
private final RedisCouponIssueService redisCouponIssueService;


public void issue(long couponId, long userId) {
    CouponRedisEntity coupon = couponCacheService.getCachedCoupon(couponId);
    coupon.checkIfIssuable();

    distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
        redisCouponIssueService.verifyCouponIssuance(coupon, userId);
        issueCoupon(couponId, userId);
    });
}

private void issueCoupon(long couponId, long userId) {
    CouponIssueRequest issueRequest = new CouponIssueRequest(couponId, userId);
    try {
        String value = objectMapper.writeValueAsString(issueRequest);
        redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
        redisRepository.rPush(getQueueKey(), value);
    } catch (JsonProcessingException e) {
        throw new BaseException(400, "쿠폰 발급 실패");
    }
}

 

지난 포스팅에서, issue() 메서드에서 분산 락을 적용해 동시성 문제를 해결했었습니다.

 

 

분산 락 - DistributeLockExecutor

  • Runnable 을 인자로 넘겨 해당 로직을 실행하게 만듬
  • 락 이름과 획득 시간(ms)을 인자로 넘겨준다.
@RequiredArgsConstructor
@Component
public class DistributeLockExecutor {

    private final RedissonClient redissonClient;
    private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName());

    public void execute(String lockName, long waitMilliSecond, long releaseMilliSecond, Runnable logic) {
        RLock lock = redissonClient.getLock(lockName);
        try {
            boolean result = lock.tryLock(waitMilliSecond, releaseMilliSecond, TimeUnit.MILLISECONDS);
            if (!result) {
                throw new BaseException(400, "["+lockName+"] lock 획득 실패");
            }
            logic.run();
        } catch (InterruptedException e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

 

앞서 Service 단의 issue() 메서드에는 다음 코드가 있습니다.

redisRepository.sAdd(getIssueRequestKey(couponId), String.valueOf(userId));
redisRepository.rPush(getQueueKey(), value);

 

RedisRepository

public Long sAdd(String key, String value) {
    return redisTemplate.opsForSet().add(key, value);
}

public Long rPush(String key, String value) {
    return redisTemplate.opsForList().rightPush(key, value);
}

 

sAdd

  • 쿠폰 발급 내역을 Set에 저장.
    • Key : issue.request.couponId=%s,
    • Value : userId

rPush

  • 대기열 큐에 발급 요청을 저장
    • Key : issue.request
    • Value : CouponIssueRequest 객체를 String 으로 직렬화 한 값

 

위 Service 의 issue() 메서드에서 캐싱이나, checkIssuableCoupon() 은 딱히 문제가 없어 보입니다. 

따라서 분산 락이 성능 저하를 일으키는 병목 지점이라 판단했습니다.

 

 

의심 가는 부분

distributeLockExecutor.execute("lock_%s".formatted(couponId), 3000, 3000, () -> {
    redisCouponIssueService.checkCouponIssueQuantity(coupon, userId);
    issueRequest(couponId, userId);
});

 

 

 

락을 제거한 코드는 아래와 같습니다.

 public void issue(long couponId, long userId) {
    CouponRedisEntity coupon = couponCacheService.getCouponCache(couponId);
    coupon.checkIssuableCoupon();
    redisCouponIssueService.checkCouponIssueQuantity(coupon, userId);
    issueRequest(couponId, userId);
}

 

락을 제거하고, 위와 동일한 조건으로 부하테스트를 실행합니다.

 

 

결과 

 

RPS 가 5000 ~ 6000 까지 증가합니다.

따라서 병목의 원인은 분산 락이라고 볼 수 있습니다. 처리량이 락을 걸어줬던 것보다 4배 이상이 늘었습니다.

 

 

레디스에 500개가 아닌, 533개가 저장되었습니다. 락이 없어, 동시성 처리에는 실패한 모습입니다.

 

 

 

 

해결 방안

현재 쿠폰 발급 로직은 다음과 같습니다.

 

 

1. 쿠폰 발급 수량 검증

  • coupon.totalQuantity > redisRepository.sCard(key)
  • 쿠폰 발급 내역의 크기(SCARD)를 검사해 총 크기보다 작으면 쿠폰을 발급합니다.

2. 중복 발급 요청인지 검증

  • ! redisRepository.sIsMember(key, val);  // val 은 userId 값
  • 해당 유저가 이미 발급받은 경우를 체크해, 중복 발급을 금지합니다.

3. 쿠폰 발급 요청을 set에 저장

  • redisRepository.sAdd();  // 쿠폰 발급 요청을 저장

4. 쿠폰 발급 요청을 큐에 적재

  • redisRepository.rPush();  // 쿠폰 발급 큐에 적재

 

1 ~ 4 번 과정을 하나로 묶어 단일 과정으로 처리한다면 락을 걸 필요가 없어, 성능을 개선시킬 수 있을 것 같습니다.

 

 

Redis EVAL

https://redis.io/docs/latest/commands/eval/

 

EVAL

Executes a server-side Lua script.

redis.io

 

 

 

공식문서에 나와 있는 설명입니다. script에 위 1 ~ 4번 과정을 담아주면 명령어가 실행됩니다.

 

레디스는 싱글 스레드로 동작하기 때문에, 실행 자체는 원자성을 가지고 있습니다. 

따라서 실행 동안에는 다른 요청이 끼어들 수 없으므로, 동시성 이슈 해결이 가능합니다.

 

 

 

 

레디스 쿼리를 통한 구현

 

- issueRequestScript() 를 통해 레디스 쿼리 작성

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)
        );
        // validation 진행
        CouponIssueRequestCode.checkRequestResult(CouponIssueRequestCode.find(code));

    } catch (JsonProcessingException e) {
        throw new BaseException(400, e.getMessage());
    }
}

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

 

스프링의 RedisTemplate 를 통해 Redis 스크립트를 실행해줍니다. 위 코드에서 redisTemplate.execute()를 자세히 보자면...

 

RedisTemplate 클래스

@Override
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
   return scriptExecutor.execute(script, keys, args);
}

 

첫 번째 인자는 RedisScript<T> 객체,

두 번째 인자는 스크립트에 전달될 키 리스트입니다.

세 번째 부터는 인자들을 전달할 수 있고, ARGV 배열로 접근할 수 있습니다.

 

 

쿠폰 중복 요청 검증

if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
    return '2'
end

 

'SISMEMBER' 명령을 사용해 특정 사용자 (ARGV[1] 로 받는 userId) 가 이미 쿠폰을 요청했는지 확인합니다.

KEYS[1] 은 사용자별 쿠폰 발급을 저장하는 set의 키입니다.

사용자가 만약 이미 요청했다면 '2'를, 요청하지 않았다면 1을 반환합니다.

 

 

쿠폰 발급 수량 검증

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'    

 

  • 전체 발급 가능 수량 : ARGV[2]
  • tonumber(ARGV[2]) 는 전체 발급 가능한 쿠폰의 수량을 나타냅니다.
  • redis.call('SCARD', KEYS[1] : 현재 set에 들어가있는 요청의 수

SCARD 명령을 통해 현재까지 요청된 쿠폰의 수량 (KEYS[1])을 확인, 발급 수량이 남아있다면

  • SADD : 추가로 요청을 저장해준다.
  • RPUSH 를 통해 쿠폰발급 대기열 큐에 데이터를 적재.

마지막으로 '1' 을 리턴

 

발급 수량이 남아 있지 않다면 '3' 을 리턴해줍니다.

 

 

이렇게 EVAL 연산으로, 레디스 쿼리를 통해 다음 연산을 하나의 연산으로 묶었습니다.

 

1. 쿠폰 발급 수량 검증

2. 중복 발급 요청인지 검증

3. 쿠폰 발급 요청을 set에 저장

4. 쿠폰 발급 요청을 큐에 적재

 

결국 '1' 을 리턴 받으면 쿠폰 발급 성공입니다. 나머지 2, 3은 검증에 문제가 생겨 예외 처리를 해야합니다.

다음은 issueRequest() 의

CouponIssueRequestCode.checkRequestResult(CouponIssueRequestCode.find(code)); 에 대한 설명입니다.

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 BaseException(400, "존재하지 않는 코드입니다.");
    }

    public static void checkRequestResult(CouponIssueRequestCode code) {
        if (code == INVALID_COUPON_ISSUE_QUANTITY) {
            throw new CouponIssueException(ErrorCode.INVALID_COUPON_QUANTITY, "발급 가능한 수량을 초과했습니다.");
        }
        if (code == DUPLICATED_COUPON_ISSUE) {
            throw new ConflictException("이미 발급 요청된 쿠폰입니다.");
        }
    }
}

 

각각의 Case 에 대해 ENUM을 정의하고, 매핑하기 위해 숫자를 리턴했습니다.

 


레디스 쿼리로 락을 대체한 후, 성능을 측정해보겠습니다. 

테스트 환경은 위와 동일합니다.

 

 

결과

 

RPS 가 6500 까지 올랐습니다.

 

 

Redis

 

Redis -> 정확히 500개씩 들어와있음을 확인했습니다.

 

동시성 이슈를 잡으면서, 분산 락을 썼을 때보다 5~6배 RPS 성능이 향상되었습니다.

 

 

소감

동시성 이슈를 레디스 스크립트 버전으로 구현해보았습니다.

validation을 진행함과 동시에 쿠폰 발급 큐에 데이터를 적재하고, set에 넣는 작업 또한 같이 진행했습니다.

 

서버의 rps 를 개선했을 뿐 아니라 동시성 이슈도 해결하였습니다. 또한 Redis를 call 해서 응답받는 식으로 진행되었는데, 

로직마다 레디스에 요청하는 횟수를 묶어서 처리했으므로, 줄여서 네트워크 트래픽도 아낄 수 있었습니다.