CS/DB

[Caching] 상품 주문 성능 개선

봄의 개발자 2024. 5. 11.
728x90
반응형

들어가며

현재 진행하고 있는 프로젝트에서 레디스 캐시를 사용해 재고 관리를 하고 있다. 

처음에는 write-throgh 전략을 사용했다. 데이터 정합성을 맞추는 게 가장 중요하다고 생각했다. 사용자에게 재고를 보여줄 때 정확해야하기 때문이다. 그러나 데이터베이스에 자주 접근하기 때문에 성능도 조금 떨어질 수 있다는 점이 trade-off로 존재한다.

그래서 우선 가장 먼저 사용했던 전략에 대해 테스트를 수행했다.

 

그 다음에는 write-back 전략을 사용해서 테스트를 해보았다. 사실 write-through 방식을 사용하면서 캐시의 확실한 장점을 제대로 사용하지 못한다는 느낌이 들었다. 계속 데이터베이스에 들어갔다 나올거면 간김에 재고도 감소시키면 될텐데... 라는 생각이 들었다.

캐시의 장점을 좀 더 활용하기 위해서 이 전략으로 바꾸고 테스트를 진행했다.

 

마지막으로는 write-back 전략에 lua script를 사용해서 원자성을 보장하고 하나의 레디스 트랜잭션 안에서 여러개의 연산을 한번에 수행하여 성능을 개선시켜보고자 했다. 이 또한 테스트를 진행해보았다.

총 세가지 과정에 대해 테스트 결과를 비교해 보겠다.


테스트 환경

테스트는 JMeter를 사용해서 진행했다. 그리고 모든 테스트의 전제 조건은 재고 300개, 한 번에 접속하는 사용자 수 1000명으로 정해놓았다.

 

User Defined Variables

상품 주문을 위해 사용되는 user_id 값을 미리 변수로 저장해둔다.

Thread Group

  • Number of Threads(users) : 1000
    • 동시에 실행될 사용자 수 (쓰레드 수)를 의미한다.
    • 이 경우 1000명의 사용자가 동시에 테스트를 수행하게 된다.
  • Ramp-up peroid(seconds) : 1
    • 모든 쓰레드가 생성되는 데 걸리는 시간을 의미한다.
    • 1초 동안 1000개의 쓰레드가 생성된다.
  • Loop Count: 1
    • 테스트 시나리오 반복 횟수를 의미한다.
    • 1회 반복하게 된다.

HTTP Request

 

JSR223 PreProcessor

user_id에 현재 쓰레드 번호를 더해서 새로운 user_id 값을 생성하도록 한다.

 

 

HTTP Header Manager

 


테스트 결과

1. Write Through & 분산락 사용

through-put: 153.1/sec

실제로 재고가 감소될 때 db에서 엔티티를 조회하고 재고를 감소한 후에 db에 반영한다. 그 이후에 레디스에 있는 재고 정보도 업데이트 한다. 결국 재고 감소를 할 때 db를 무조건 거친다는 것이다. 미리 2, 3번과 비교해봤을 때 TPS가 현저히 낮음을 알 수 있다.

 

2. Write Back & 분산락

through-put: 237.1/sec

Write back 전략을 사용하면 1번과 달리 매번 db를 거치지 않는다. 재고는 레디스에서만 감소하고 30분마다 스케줄러르 통해 레디스에 있는 재고 정보를 db에 업데이트 해주는 방식이다. 1번에 비해 정합성은 떨어지겠지만 성능상 조금 더 이점을 갖는다.

 

3. Write Back & Lua Script

through-put: 648.1/sec

멘토님이 추천해주신 lua script를 사용해서 여러 연산을 하나의 스크립트 안에서 즉 하나의 트랜잭션 안에서 처리하는 방식이다. Lua script를 사용하게 되면 레디스 서버에서 단일 명령어로 실행되어 원자적으로 수행된다. 즉 데이터 일관성이 보장된다. 또한 내부 락 기능을 통해 다른 접근을 막는다. 즉! 내가 이전에 사용한 분산락을 굳이 사용하지 않아도 된다는 것이다. TPS를 비교해보면 굉장히 높아졌음을 알 수 있다. 

 

쓰기 전략 TPS 성능
Write-Through 153.1/sec -
Write-Back 237.1/sec 54.8% 증가
Lua Script 적용 648.1/sec Write-Through 대비: 323.3% 증가
Write-Back 대비: 173.3% 증가

 

 


 

Lua script를 사용한 예시를 자세하게 살펴보자!

public void decreaseQuantity(RequstDto reqestDto) {
    Long productId = reqestDto.getProductId();
    Integer quantity = reqestDto.getQuantity();

    String script = """
            local productKey = KEYS[1]
            local quantity = tonumber(ARGV[1])
                    
            local currentQuantity = tonumber(redis.call('GET', productKey))
            if currentQuantity == nil then
                return -2
            end
            if currentQuantity < quantity then
                return -1 -- 재고 부족
            end
                    
            redis.call('DECRBY', productKey, quantity)
            return 1 -- 재고 감소 성공
                    """;

    Long result = (Long) redisTemplate.execute(
            new DefaultRedisScript<>(script, Long.class),
            Collections.singletonList(REDIS_PREFIX + productId.toString()), quantity);

    if (result == -1) {
        // 재고 부족 예외 발생
    } else if (result == -2) {
    	// 찾을 수 없는 데이터 예외 발생
    }
}

 

이런 식으로 lua script 안에서 키를 통해 value(재고)를 조회한다. 만약 조회한 재고가 null이라면 -2를 반환한다. 아래 else 문에서 보면 찾을 수 없는 데이터 예외를 발생하도록 해주었다. (실제 로직은 throw exception 수행)

 

또 현재 조회된 재고가 주문하려는 개수보다 작다면 재고 부족으로 -1을 반환한다. 아래 if 문에서 보면 재고 부족 예뢰를 발생한다.

 

이 모든 조건에 걸리지 않는다면 해당 키에 대한 재고를 주문한 상품 개수만큼 감소한다.

 

개인적으로 헷갈렸던 부분은 KEYS[1], ARGV[1] 였다.

redisTemplate.execute() 메서드에서 두 번째 인자가 KEYS[]이고 세 번째 인자가 ARGV이다.

이렇게 keys 배열에 들어간 값중에 첫번째 값을 가져와서 사용하고, 이어서 인자로 들어가있는 args 중 첫번째 값을 가져와서 사용한다는 것을 알 수 있다. 

 

마치며

lua script를 사용해본 경험은 굉장히 의미가 깊었다. 신기하기도 했고 레디스와 조금 더 친해진 느낌이 들었다. 실제로 레디스를 사용해서 데이터를 관리해 본 경험도 처음이었고 다양한 캐싱 전략에 대해 들어보기만 했지 이렇게 직접 사용해본 건 처음이었다. 개인 프로젝트를 통해서 많은 지식을 알게 되는 것 같다. 그렇다고 지식의 깊이가 여전히 깊다고 생각하진 않지만 (오히려 얕지...) 하나라도 더 알게 되어서 이 또한 감사하게 생각하며, 더 열심히 해야겠다고 생각했다. 공부해야하는 내용이 끝도 없이 나오는 것 같다...! 이것 또한 감사하게 생각하며,,, 여기서 마치도록 하겠습니다!

728x90
반응형

댓글