본문 바로가기
토이프로젝트

Redis 를 이용해서 재고를 관리해보자!

by 이석준석이 2022. 8. 3.

https://robin00q.tistory.com/107 글에서 이어집니다!

참고 : (선물하기 시스템의 상품 재고는 어떻게 관리되어질까?) https://techblog.woowahan.com/2709/

 

DB 에 걸리는 Lock 을 어떻게 해결할까?


RDBMS 를 통해 재고관리를 하는 경우, 해당 상품(혹은 옵션) 의 재고를 증가/감소시키려면 필연적으로 해당 Row 에 Lock (동시성 이슈) 을 걸 수 밖에 없다.

option_id option_name stock purchaseable (구매가능여부)
1 아디다스 슬리퍼 102 YES
2 아디다스 저지 34 YES

 

option_id 가 1인 row 에 여러 쓰레드가 접근하여, stock 을 증가/감소 시키더라도, 동시에 이를 수행할 수 없기에 하나의 쓰레드가 트랜잭션을 커밋할 때까지 대기해야 한다.

 

이를 해결하기 위해, Redis 를 사용하여 재고관리를 구현해본다.

 

Redis


Redis 는 그렇다면 하나의 데이터에 접근하는 경우 어떻게 Lock 을 피하는걸까?

  • 이유는 Redis에서 데이터에 접근할 수 있는 쓰레드오직 하나(싱글 쓰레드) 이기 때문이다.
  • Redis 에 오직 쓰레드가 하나만 있는 것은 아니지만, 데이터에 접근(오퍼레이션을 수행) 하는 쓰레드는 하뿐이다.

 

Redis 에서 오퍼레이션을 수행하는 쓰레드가 하나만 존재한다면, 너무 느린게 아닐까?

  • Redis는 데이터를 메모리 위에 올려놓고 사용 (in-memory-database) 한다.
    • DISK I/O 가 발생하지 않으므로 빠르다.
  • 데이터 복구를 위해 메모리에 있는 데이터를 I/O 를 통해, 디스크에 주기적으로 싱크를 맞출 수 있으나, 해당 I/O 는 오퍼레이션을 수행하는 쓰레드가 아닌, 다른 쓰레드가 수행한다.

 

redis-benchmark 명령을 사용하여, 백만개의 데이터를 set 하는동안 10초 정도밖에 걸리지 않았기에, 성능이 느리지않다.

  • CPU 에 따라 다르지만, 초당 O(1) 의 연산을 10만개 정도 수행했다.

 

결론적으로 Redis 는

  • in-memory-database 로 빠른 연산을 수행하며,
  • 오퍼레이션을 하는 쓰레드는 싱글 쓰레드로 동작하기에, 동시성 이슈를 걱정하지 않아도 된다.

 

Lock 을 피하는 두 번째 방법 - 이벤트소싱


RDBMS 로 Lock 을 피하는 방법은 아예 존재하지 않는 걸까?

  • 이벤트 소싱을 통해, 오직 insert 만 되는 테이블 (아래의 Record 테이블) 을 만들어 이를 처리할 수 있다.

 

Option 테이블

  • 각 옵션의 재고량을 저장한다.
option_id option_name stock purchaseable (구매가능여부)
1 아디다스 슬리퍼 102 YES
2 아디다스 저지 34 YES

Record 테이블

  • 사용자의 구매이력을 저장한다.
    • quantity 가 양수인 경우 구매
    • quantity 가 음수인 경우 환불
record_id option_id quantity (구매 수량)
1 1 2
2 1 -3
3 2 1
4 1 4

 

위의 과정을 통해,

  • Option 테이블의 stock
  • Record 테이블의 GROUP BY(option_id) + SUM 오퍼레이션을 통해
  • 현재 재고량을 Lock 없이 계산할 수 있다.

 

나의 토이프로젝트 (https://github.com/robin00q/kafka_toy_project)


구현 Concept :

  • 레디스가 살아있다면, 레디스를 이용하여 빠르게 사용자의 요청을 처리한다.
  • 어떠한 이유로 레디스가 다운됐다면, 이벤트소싱된 데이터를 이용하여 사용자의 요청을 처리한다.

 

재고 증가 :

  • 결제 API결제가 완료되면 상품의 재고를 증가시키는 이벤트를 Kafka 에 발행한다.
  • 재고 API해당 이벤트를 Consume 하여 상품의 재고를 증가시킨다.

 

재고 증가 Flow

  1. 현재 구매 가능한 옵션인지 확인한다.
  2. (To RDB) 재고량 증가 이벤트를 저장한다.
  3. (To Redis) 옵션 아이디에 해당하는 구매수량을 증가시킨다.
  4. (From Redis) 3번의 결과로 지금까지 총 옵션의 구매수량을 반환받는다.
  5. (From RDB) 레디스가 동작하지 않아, 4번을 통해 구매수량을 반환받지 못하면, RDB 를 통해 옵션의 총 구매수량을 반환받는다.
  6. API 서버는 Option 테이블을 통해 `총 구매수량` 과 `재고` 를 비교하여 구매가능한지 확인한다.
  7. 구매가 더이상 불가능하다면, 구매가 불가능한 옵션으로 세팅한다.

상품 재고 증가 Flow

재고 증가 Code

  • 2. (RDB) 재고량 증가 이벤트를 저장하고,
  • 3. (REDIS) 레디스의 상품재고량을 증가시키는 코드이다.
  • 4. (REDIS) 총 구매된 갯수를 Redis 를 통해 받으며,
  • 5. (RDB) 만약 Redis 가 다운됐다면 DB 를 통해 데이터를 받아온다.
public class PurchaseManageRepositoryImpl implements PurchaseManageRepository {

    private static final String STOCK_KEY_PREFIX = "option:purchase:";

    private final RedisTemplate<String, String> redisTemplate;
    private final PurchaseRecordRepository purchaseRecordRepository;

    @Override
    @Transactional
    public boolean increaseStock(SalesOptionPurchaseRecord record) {
        // RDBMS 에 `구매하는 record` 의 이력을 저장한다.
        // 구매라는 record 를 이벤트소싱한다.
        purchaseRecordRepository.save(OptionPurchaseHistoryDataModel.increase(record));

        String key = createKey(record.getProductId(), record.getOptionId());
        Long totalPurchaseCount;

        try {
            // 레디스의 옵션에 대한 총 구매수량을 증가시킨다.
            totalPurchaseCount = redisTemplate.opsForValue()
                    .increment(key, record.getQuantity());
        } catch (RedisConnectionFailureException e) {
            // 만약 Redis 가 다운된 상태라면, 이벤트소싱된 데이터를 통해 옵션의 총 구매수량을 반환받는다.
            totalPurchaseCount = purchaseRecordRepository.getPurchaseCount(
                    record.getProductId(), record.getOptionId());
        }

        return totalPurchaseCount <= record.getTotalStock();
    }

    private String createKey(long productId, long optionId) {
        return STOCK_KEY_PREFIX + productId + ":" + optionId;
    }
}

public class PurchaseRecordRepositoryImpl implements PurchaseRecordRepository {

    private final JpaOptionPurchaseHistoryRepository jpaOptionPurchaseHistoryRepository;

    @Override
    public long getPurchaseCount(long productId, long optionId) {
        return jpaOptionPurchaseHistoryRepository
                .countBySalesOptionId(productId, optionId);
    }
}

 

해당 구현의 문제점 :

  • redisTemplate.opsForvalue().increment('키', '수량') 을 통해 옵션의 총 구매수량을 증가시켰다.
    • 만약 Redis 가 다운돼도 해당 코드는 정상적으로 동작하겠지만,
    • 레디스의 데이터를 복구하는 과정에서, DB 의 데이터와 싱크를 맞추는 과정이 힘들 것 같다.

 

해당 구현을 개선하려면? ... (글을 쓰다보니 개선점이 생각났다 T^T)

  • 위에 참고한 우아한형제들에서 사용한 방법처럼, 셋 자료형을 사용하는 방식으로 개선해야 할 것 같다.
    • redisTemplate.opsForSet().add("option:purchase:{optionId}", "구매한 사람의 구매 key")
  • 위의 방식을 통해, set 에 저장된 갯수를 가져오는 방식으로 현재 총 구매수량을 가져올 수 있으며,
  • 복구 과정에서, 셋 자료형은 중복된 데이터가 들어가지 않으므로, 오류가있는 시간부터의 데이터를 모두 집어넣으면 정상적으로 복구할 수 있을것으로 보인다.

재고를 감소하는 과정은, 위의 증가하는 과정과 매우 유사하므로 생략한다.

 

 

구현하면서의 트러블슈팅?


Redis 는 롤백되지 않는다.

위에 참고한 우아한형제들의 링크는 아래와 같은 흐름으로 로직을 처리한다.

  1. (Redis)레디스의 총 구매수량을 증가시킨다.
  2. (Redis) 레디스의 총 구매수량을 반환받는다.
  3. (RDB) 히스토리정보를 Insert 한다.

 

3번 과정에서 에러가 발생한다면, 레디스와 RDB 의 싱크가 맞지 않게된다.

  • 1~3 을 같은 트랜잭션에서 처리하더라도, 히스토리정보의 insert 가 실패(3번과정) 하면
    • 레디스의 총 구매수량은 증가한 상태이며
    • 히스토리정보의 총 구매수량은 그대로인 상태가 된다.

 

나는 위에서 정리한 것처럼 RDB 의 데이터가 항상 올바른 데이터를 가지고 있도록 구현했다.

  1. (RDB) 히스토리정보를 Insert 한다.
  2. (Redis) 레디스의 총 구매수량을 증가시킨다.
  3. (Redis) 레디스의 총 구매수량을 반환받는다.
  4. (RDB) 만약 2,3번 과정이 정상적으로 수행되지 않으면 RDB 를 통해 총 구매수량을 반환받는다.

 

나의 방식또한 Redis 가 정상적으로 동작하지 않는 경우, RDB 와 싱크가 맞지 않지만 RDB 가 항상 올바른 데이터를 가지도록 하는 방향이 이해가 쉽지않을까 하는 생각에서 이처럼 구현하였다.

 

다음으로...


사실.. 저번 글에서 보상트랜잭션을 구현하는 목표또한 있었으나 지금 나의 실력으로는 카프카에 이벤트를 발행하여 현재 실행된 트랜잭션의 반대가되는 연산을 수행하는 정도로 끝날 것 같아 구현하지 않았다.

 

추후에 MSA 관련 책을 읽고, 해당과정을 다시 공부해서 더 나은 해결책을 찾아봐야 할 것 같다.

 

인프라에 대한 지식이 매우 낮기에,, 다음에는 Kubernetes 를 공부할 생각이다.

  • 정말 간단한 프로젝트를 만들어 AWS 에서 Kubernetes 를 사용하여 구성하는 과정을 진행해보려고한다.

 

참고 및 공부자료


(이것이 레디스다) https://ridibooks.com/books/443000225

  • 종이책은 파는곳을 찾지 못해서 e-book 을 통해 공부했다.

(우아한 형제들, 선물하기 시스템의 상품 재고는 어떻게 관리되어질까?) https://techblog.woowahan.com/2709/