[JoyMall] 부하테스트 성능 개선기 - Redis에서 재고 수량을 관리하기 (2)

청포도봉봉이·2024년 7월 21일
1

JoyMall

목록 보기
10/13
post-thumbnail

이전 글
[JoyMall] 부하테스트 성능 개선기 - Kafka 도입 (1)

제목 그대로 RDB에서 update를 통해 재고감소를 하던 로직을 Redis에서 재고 수량을 관리하여 update 해주는 방식으로 변경하였습니다.

이번 글에서는 재고에 대한 데이터를 Redis에서 관리하는 방식으로 변경하여 성능 개선한 경험을 공유하겠습니다.




RDB vs Redis: 재고 수량 관리의 차이점과 Redis의 장점

애플리케이션에서 재고 수량을 관리할 때, RDB (관계형 데이터베이스)와 Redis (인메모리 데이터베이스)를 사용하는 방법에는 차이점이 있습니다.

RDB를 통한 재고 수량 관리

  1. 데이터 저장소:

    • RDB는 데이터를 디스크 기반으로 영구 저장합니다. 데이터베이스는 안정성과 일관성을 보장하며, 데이터 손실을 최소화하기 위해 트랜잭션과 백업 기능을 제공합니다.
  2. 동시성 처리:

    • RDB는 트랜잭션을 통해 데이터 일관성을 유지합니다. 여러 사용자가 동시에 데이터를 수정하려고 할 때, 트랜잭션을 사용하여 데이터 충돌과 무결성 문제를 해결합니다. 그러나 높은 동시성 상황에서는 잠금(Locking)과 같은 문제로 인해 성능 저하가 발생할 수 있습니다.

Redis를 통한 재고 수량 관리

  1. 데이터 저장소:

    • Redis는 데이터를 메모리 기반으로 관리합니다. 이는 디스크 I/O의 병목 현상을 제거하고 매우 빠른 데이터 접근 속도를 제공합니다. Redis는 필요에 따라 데이터를 디스크에 저장하여 영구성을 보장할 수 있습니다.
  2. 동시성 처리:

    • Redis는 원자적 명령어(atomic operations)와 단일 스레드 모델을 사용하여 높은 동시성 처리를 지원합니다. 이러한 원자적 명령어는 여러 클라이언트가 동시에 데이터를 수정할 때 데이터 일관성을 보장합니다. 예를 들어, INCRBYDECRBY 명령어는 재고 수량을 원자적으로 증가하거나 감소시킵니다.

Redis의 장점

  1. 고속 성능:

    • Redis는 메모리 기반 접근을 통해 초고속 데이터 읽기/쓰기가 가능합니다. 이는 실시간 데이터 처리가 중요한 애플리케이션에서 특히 유용합니다.
  2. 높은 동시성 처리:

    • Redis는 원자적 명령어와 비동기 I/O 처리를 통해 높은 동시성 처리를 지원합니다. 이를 통해 여러 클라이언트가 동시에 데이터를 수정할 때도 데이터 일관성을 유지할 수 있습니다.
  3. 유연한 데이터 구조:

    • Redis는 다양한 데이터 구조(Set, List, Hash 등)를 지원하여 다양한 방식으로 데이터를 저장하고 조회할 수 있습니다. 이는 복잡한 데이터 모델링을 단순화하는 데 도움이 됩니다.
  4. 확장성:

    • Redis는 분산 시스템 구성을 통해 수평 확장이 용이합니다. 클러스터링과 파티셔닝을 통해 많은 데이터를 효율적으로 관리할 수 있습니다.




Redis의 원자적 명령어

저는 코드 레벨에 Redis의 원자적 명령어 (Atomic Operations)을 적용했습니다.

Redis의 원자적 명령어는 여러 클라이언트가 동시에 데이터를 수정할 때 데이터 일관성을 보장합니다. decrementincrement 명령어는 Redis의 원자적 계산 명령어 중 일부입니다.

Redis는 다양한 원자적 명령어를 제공하여 동시성 문제를 해결하고 데이터를 안전하게 수정할 수 있도록 합니다. 여기서는 decrementincrement 명령어를 중심으로 Redis의 원자적 계산 기능을 소개하겠습니다.

INCR 명령어

INCR 명령어는 지정된 키의 값을 원자적으로 증가시킵니다. 만약 키가 존재하지 않으면, 키를 0으로 초기화하고 1을 더합니다.

INCR key

DECR 명령어

DECR 명령어는 지정된 키의 값을 원자적으로 감소시킵니다. 만약 키가 존재하지 않으면, 키를 0으로 초기화하고 1을 뺍니다.

DECR key

INCRBY 명령어

INCRBY 명령어는 지정된 키의 값을 주어진 만큼 원자적으로 증가시킵니다.

INCRBY key increment

DECRBY 명령어

DECRBY 명령어는 지정된 키의 값을 주어진 만큼 원자적으로 감소시킵니다.

DECRBY key decrement




Java, Spring 코드 적용

코드는 GitHub에서 확인할 수 있습니다.
https://github.com/f-lab-edu/joy-mall

JoyMall은 쿠버네티스 기반 컨테이너 환경에서 4개의 팟으로 구성되어 있습니다.

이런 Redis의 원자적 명령어를 사용하고 분산락 코드를 제거하여 락 획득으로 인한 병목 현상을 해결할 수 있었습니다.

고민됐던 점

이렇게 원자적 명령어를 통해 해결할 수 있다는 점을 알게되었지만 고민되는 문제가 또 발생했습니다.

  • Redis 서버에서 재고에 대한 수량을 관리를 해도 기존 RDB에서 수량을 마지막엔 update해주고 싶다.

이거에 대한 방법을 찾아보니 Spring에서 제공하는 Scheduling 기능을 사용하기로 하였습니다.

하지만 여러 개의 팟으로 구성된 제 환경에서 4개의 스케줄이 동시에 실행되면 동시성 문제가 또 발생할 수 있다고 판단하였습니다.

그래서 분산 환경에서 리더 선출 메커니즘 구현을 통해 해결하였습니다.


코드 적용

SchedulerLeader (리더 선출 메커니즘 구현)

@Component
@RequiredArgsConstructor
public class SchedulerLeader {

    private final RedisTemplate<String, String> redisTemplate;

    private static final String LEADER_KEY = "scheduler:leader";
    private static final long LEADER_TIMEOUT = 60000; // 60 seconds
    private String instanceId = UUID.randomUUID().toString();

    public boolean isLeader() {
        Boolean isLeader = redisTemplate.opsForValue().setIfAbsent(LEADER_KEY, instanceId, LEADER_TIMEOUT, TimeUnit.MILLISECONDS);

        if (Boolean.TRUE.equals(isLeader) || instanceId.equals(redisTemplate.opsForValue().get(LEADER_KEY))) {
            redisTemplate.expire(LEADER_KEY, LEADER_TIMEOUT, TimeUnit.MILLISECONDS);
            return true;
        }

        return false;
    }
}

SchedulerLeader 클래스는 분산 환경에서 리더 선출 메커니즘을 구현합니다. UUID를 사용하여 각 인스턴스마다 고유한 Id를 가지도록 합니다.

Redis를 이용한 리더 선출: LEADER_KEY라는 Redis 키를 사용하여 현재 리더를 표시합니다.setIfAbsent 메소드로 이 키가 없을 때만 값을 설정합니다. 이는 여러 인스턴스 중 하나만 리더가 될 수 있게 합니다.

리더십 유지: 리더로 선출되면 키의 만료 시간을 갱신합니다. (LEADER_TIMEOUT) 이는 리더 인스턴스가 활성 상태임을 주기적으로 알립니다.

리더 확인: 키 설정에 성공하거나, 현재 키의 값이 자신의 instanceId와 일치하면 리더로 인정됩니다.

이를 통해 분산 환경에서 스케줄러의 중복 실행을 방지하고, 단일 리더에 의한 일관된 작업 수행을 보장하도록 구성하였습니다.


SalesProductFacadeRedis

@Component("salesProductFacadeRedis")
@RequiredArgsConstructor
public class SalesProductFacadeRedis implements SalesProductFacade {

    private final RedisTemplate<String, String> redisTemplate;
    private static final String STOCK_KEY_PREFIX = "salesProduct_stock:";
    private static final String CHANGE_LOG_KEY = "salesProduct_stock_change_log";

    @Override
    public void decreaseStock(Set<OrderItem> orderItems) {
        for (OrderItem orderItem : orderItems) {
            String stockKey = STOCK_KEY_PREFIX + orderItem.getSalesProductId();

            Long remainStock = redisTemplate.opsForValue().decrement(stockKey, orderItem.getQuantity());

            if (remainStock == null || remainStock < 0) {
                redisTemplate.opsForValue().increment(stockKey, orderItem.getQuantity());
                throw new RuntimeException("판매 수량이 부족합니다.");
            }

            redisTemplate.opsForSet().add(CHANGE_LOG_KEY, stockKey);
        }
    }
}

위 코드에선 Redis에 재고 수량을 가져와 주문 개수 만큼 차감해주고 수량이 부족하면 예외를 발생시키고 변경된 판매 상품에 대한 변경을 위해 Redis Sets을 사용하여 재고가 변경된 상품의 키를 저장합니다.


SalesProductSyncScheduler

@Component
@RequiredArgsConstructor
public class SalesProductSyncScheduler {

    private final SalesProductRepository salesProductRepository;
    private final RedisTemplate<String, String> redisTemplate;
    private final SchedulerLeader schedulerLeader;
    private static final String CHANGE_LOG_KEY = "salesProduct_stock_change_log";

    @Scheduled(fixedRate = 60000)
    public void syncStockToDB() {
        if (!schedulerLeader.isLeader()) {
            return;
        }

        Set<String> stockKeys = redisTemplate.opsForSet().members(CHANGE_LOG_KEY);
        if (stockKeys == null || stockKeys.isEmpty()) return;

        for (String stockKey : stockKeys) {
            if (stockKey.isEmpty()) {
                return;
            }

            Long salesProductId = Long.valueOf(stockKey.replace("salesProduct_stock:", ""));
            String stockInRedis = redisTemplate.opsForValue().get(stockKey);

            if (stockInRedis != null) {
                int stock = Integer.parseInt(stockInRedis);
                SalesProduct salesProduct = salesProductRepository.findById(salesProductId)
                        .orElseThrow(NoSuchElementException::new);

                if (salesProduct != null) {
                    salesProduct.decreaseStock(salesProduct.getSalesStock() - stock);
                    salesProductRepository.save(salesProduct);
                }
            }
        }
    }
}

SalesProductSyncScheduler 클래스는 Redis에 임시 저장된 재고 정보를 주기적으로 RDB(관계형 데이터베이스)와 동기화하는 역할을 합니다.

  1. Spring Scheduling: 60초마다 동기화 작업을 실행하여 최신 재고 상태를 RDB에 반영합니다.
  2. Redis Set (CHANGE_LOG_KEY): 변경된 재고 키만 저장하여 불필요한 DB 업데이트를 방지합니다.
  3. 분산 환경 대응 (SchedulerLeader): 여러 인스턴스 중 하나만 동기화 작업을 수행하도록 보장합니다. 또한 변경된 재고만 업데이트하여 불필요한 DB 작업을 최소화합니다.

이 구현을 통해 아래의 문제들을 해결하였습니다.

  • 실시간성과 일관성 균형: Redis의 빠른 처리와 주기적인 DB 동기화로 성능과 데이터 일관성을 모두 확보합니다.
  • DB 부하 감소: 변경된 재고만 업데이트하여 불필요한 DB 작업을 최소화합니다.
  • 분산 환경 대응: 리더 선출 메커니즘으로 중복 실행을 방지합니다.




부하 테스트

nGrinder 실행

테스트 조건은 Vuser 약 300명, 10분 동안 진행하였습니다.

nGrinder 실행 결과

이전 테스트에선
평균 TPS: 118
최고 TPS: 171
평균 테스트 시간 2,476 ms
의 결과가 나왔는데

Redis에서 재고 수량 관리 적용 후 테스트는
평균 TPS: 207
최고 TPS: 322
평균 테스트 시간 1,422 ms

으로 2배 가까이 성능이 개선된걸 확인할 수 있습니다.

오류를 보니 테스트 막바지에 400건 정도 발생하였습니다.

Pinpoint 모니터링

이전 테스트의 결과는
응답시간이 평균 2.36초, 최대 22.64초 였습니다.

Redis에서 재고 수량 관리 적용 후 테스트는
응답시간이 평균 1.15초, 최대 60.06초 인것을 확인할 수 있습니다.

성능이 확실히 개선되었습니다!!

에러가 난 부분들을 확인해보겠습니다.

  • ClientAbortException (Broken pipe): 클라이언트가 연결을 갑자기 중단했을 때 발생합니다. 서버가 응답을 보내려 할 때 이미 클라이언트 연결이 끊어진 상태입니다.
  • HttpMessageNotReadableException: 서버가 클라이언트로부터 받은 메시지를 읽는 도중 I/O 에러가 발생했습니다.

아마 부하때문에 생긴 에러 같습니다.

Heap 메모리 사용량은 매우 안정적이고 CPU 사용량도 절반 정도 안정화 되었습니다.

아무래도 스케줄링을 통해 1개의 애플리케이션에서 계속 RDB 동기화를 진행시켜줘서 그런거 같습니다.




마무리

이렇게 Redis에서 재고 수량을 관리하고 RDB에 동기화까지 하는 코드를 적용해보고 부하 테스트도 진행해보았습니다. MSA 아키텍쳐를 적용해서 다른 서버에서는 RDB에 동기화만 하는 작업을 해주었으면 훨씬 성능이 좋았을거 같은데 아쉽습니다.

profile
서버 백엔드 개발자

0개의 댓글