쿠폰 재고의 설계 및 개발


알림

본 설계 및 샘플 소스코드는 재 구성한 것으로, 실제와 다른 부분이 있습니다.

배경

  • 쿠폰 비용을 제한할 수 있는, 쿠폰 재고 도메인 개발을 진행하게 되었습니다.
  • 쿠폰의 재고는 크게 두가지로 나뉩니다.
    • 첫째, 지급 재고
    • 둘째, 사용 재고
  • 지급 재고는, 사용자에게 쿠폰을 몇 번 지급할 수 있는지 결정합니다.
  • 사용 재고는, 사용자가 쿠폰을 몇 번 사용할 수 있는지 결정합니다.
  • 즉, 쿠폰 재고 도메인을 통해 쿠폰을 활용하는 마케팅 비용을 제한할 수 있게 됩니다.

목표

  • 재고 도메인을 개발하여, 쿠폰의 지급/사용 횟수를 제한할 수 있는 기능을 개발.

KeyPoint

  • 가장 중요한 KeyPoint를 정리해보면 아래와 같습니다.
    • 재고의 설정과 재고의 처리는 분리되어야 합니다.
    • 운영이 종료된 재고는 비휘발성 저장소에 저장 후, 휘발성 메모리를 Cleansing하여 가용 메모리를 유지할 수 있어야 합니다.
    • 관리자에서 재고 조회시, 실시간 재고 처리 영역에 부하를 주지 않아야 합니다.
    • 재고 조회/삽입/삭제/변경의 성능은 반드시 시간복잡도 O(1)을 만족해야 합니다.
    • 재고 증가/감소의 동시성 이슈를 해소해야 합니다.
    • 대용량 트래픽 대응을 위해, 비동기 처리가 가능해야 합니다.
    • 재고는 운영중에도 늘리거나 줄일 수 있어야 합니다.
    • 재고 도메인은 확장에 열려있어야 합니다. 어떠한 재고도 표현 가능해야 합니다.

KeyPoint와 의사 결정

  • 자주 변하지 않는 총 재고 설정 값과, 자주 변하는 남은 재고 값을 분리합니다. 재고의 총 설정은 JPA 기반의 Entity로, 남은 재고의 처리는 Redis로 분리합니다.
  • 실시간 처리를 위해 Redis에서 처리되던 재고 값은, 재고의 운영이 종료되면 Database로 Sync 후, Redis 재고를 Cleansing하여 가용 메모리를 유지할 수 있도록 합니다.
  • 관리자에서 재고 조회시 Database에 저장된 재고를 조회하며, Redis -> DB Sync API를 추가적으로 제공하여 실시간으로 남은 재고를 확인할 수 있도록 합니다.
  • 재고의 조회/삽입/삭제/변경의 시간복잡도를 만족하기 위해, 재고의 실시간 처리 저장소를 Disk 기반이며 B Tree로 O(logN)의 시간복잡도를 가지는 RDB가 아닌 Memory 기반의 Redis로 결정하였습니다.
  • 재고 증가/감소의 동시성 이슈는 Single Thread인 Redis를 사용하여 해결합니다.
  • 비동기 처리를 위해 JDBC Level에서 Blocking 될 수 있는 RDB가 아닌, Redis로 결정하였습니다.
  • 재고를 운영중에도 늘리거나 줄일 수 있도록, 재고를 증가 및 감소시킬 수 있는 API를 제공합니다.
  • 재고 도메인은 쉽게 확장 가능해야 합니다. 따라서, 재고 도메인은 가장 기본적인 정보만 담아야 하며, 재고를 활용하는 각 도메인에서 Redis Key 생성 방식을 결정합니다.

기본적인 구조 - 구조 설계

  • 재고 기능을 구현하기 위해, 아래와 같은 구조로 설계하였습니다.
    • 첫째, 재고 제한 설정을 의미하는 Stock 도메인
    • 둘째, 재고 제한을 적용하려는 각 도메인
    • 셋째, Reactive를 지원하는 Global AtomicLong 개념의 Counter 도메인
    • 넷째, Reactor 기반의 비동기 코드를, JPA 기반의 코드로 매핑을 담당하는 모듈

기본적인 흐름 - 쿠폰의 재고 설정 및 활용

  • 쿠폰의 설정
    • 쿠폰에는 확정 기능이 존재하며, 확정시 재고 설정에 있는 total 값을 Redis에 저장합니다.
    • 확정된 쿠폰만 지급 혹은 사용될 수 있습니다.
  • 쿠폰 설정 후 지급 및 사용
    • 쿠폰을 사용 혹은 지급 처리 시, 쿠폰에 재고 제한 설정이 있는지 확인합니다.
    • 재고 제한 설정이 존재한다면, 실시간 재고 처리를 위해 Redis Key를 생성 후, Counter 도메인을 활용하여 Redis의 재고를 증가/감소합니다.

재고의 카운팅 방식

성능을 위해 사용한 개수를 저장하는 방식이 아닌, 남아 있는 재고 수를 저장하는 방식 사용

  • 사용한 개수를 저장하는 방식의 경우, 재고가 남아있는지 여부를 체크하기 위해 매번 총 재고 설정 값과 현재 사용한 개수를 비효율적으로 두번 조회해야 합니다.
    • long remain = total - used;
  • 반대로, 남아 있는 재고 수 저장 방식의 경우, 재고가 남아있는지 여부를 체크하기 위해, total값을 조회하지 않아도 됩니다.
  • 재고는 결국 몇 개 남았는지 혹은 재고가 존재하는지 확인하는게 주된 관심사이기 때문에, 남아 있는 재고 수를 저장합니다.

재고 제한 설정을 의미하는 Stock Entity 설계

재고 제한 설정을 의미하는 Stock은 Entity로 RDB에 저장

  • 간단한 구조의 Stock Entity Stock Domain

  • Stock Entity는 재고 제한 설정을 의미합니다. 그렇기에 Stock Entity를 사용하는 각 도메인에서 재고 제한 설정이 없으면 그것은 무제한을 의미합니다.
  • total은 총 설정 재고, remain은 남아 있는 재고를 의미합니다.
  • 이때, remain은 실제 주문 흐름중에 사용되는 실시간 재고가 아닙니다.
    • 관리자에서 총 재고 설정 및 남은 재고를 조회하기 위해 사용되며, 나아가 휘발성 메모리에 적재된 실시간 재고를, 비휘발성 저장소로 Sync하여 영구 저장하기 위해 사용됩니다.
    • Redis의 실시간 재고를 RDB로 Sync하는 API를 제공하여 remain의 값을 Refresh가 가능하게 합니다. 이를 통해 관리자는 남은 재고를 Refresh하여 볼 수 있습니다.
    • 나아가, Redis를 실시간 재고 저장소로 사용하지만, 쿠폰의 지급 및 사용 기간이 종료되면 영구 저장소인 RDB로 이관 및 저장하여 관리하기 위해 사용합니다.
    • 결론적으로, Redis는 Atomic Operation을 지원하는 단기 저장소로 사용, 지급/사용 기간이 만료된 쿠폰은 영구 저장소인 RDB로 재고 정보를 이관하여 관리합니다.

재고 제한을 적용하려는 각 도메인

재고 제한을 사용하려는 각 도메인에서 @OneToOne과 같은 관계를 설정하여 사용

Stock Entity는 DDD에서 말하는 Aggregate Root로 사용되지 않습니다.

즉, 직접 Stock으로 접근이 불가능하며, 쿠폰 사용의 재고, 지급의 재고와 같이 어떤 행위를 하는 도메인을 통해 재고가 적용됩니다.

따라서, Stock은 Repository를 가지지 않으며, 타 도메인 Entity를 통해서 접근 가능합니다.

  • 아래는 그 예시로 구성한 쿠폰 도메인입니다. Stock In Coupon

Counter 도메인의 설계

Counter는 Atomic increment/decrement 기능을 제공하는 Global 버전의 AtomicLong과 유사합니다.

  • 직접 정의한 ReactiveRepository를 활용하는 Counter 도메인의 구조 Counter Domain Structure

  • 간단한 형태의 Counter Dto Counter Domain
  • CounterRepository - Counter Repository Implementation Counter Repository Implementation
  • Counter 도메인은, Reactive Streams Spec을 따르는 Reactor 기반으로 동작합니다.
  • Global AtomicLong 연산을 지원하는 도메인이 Counter 도메인이라고 할 수 있습니다.

Counter 도메인에서 활용하는 ReactiveRepository

  • ReactiveRepository들은 공통 도메인 패키지에 위치합니다.
  • ReactiveRepository - Reactive Operation Interface Reactive Repository
  • ReactiveRedisRepository - Redis Common Operation Interface Reactive Redis Repository
  • ReactiveRedisValueRepository - Redis Value Operation Interface Reactive Redis Value Repository
  • AbstractReactiveRedisRepository - Abstract Redis Common Operation Implementation Reactive Redis Repository Implementation
  • AbstractReactiveRedisValueRepository - Abstract Redis Value Operation Implementation Reactive Redis Value Repository Implementation

Reactor 기반의 비동기 코드와, JPA 기반의 코드의 연동

  • 기본 구조 stockHandler

  • 상품 리스트에서 각 상품에 남아있는 쿠폰 재고를 참조하여, 쿠폰 마감임박과 같은 뱃지를 노출하는 등 대용량 트래픽에 노출되는 재고 모듈은, 성능을 위해 Reactor 기반으로 구현하였습니다.

    • 현재, JPA를 함께 사용하고 있으며, JPA 스펙이 비동기를 지원하고 있지 않아, 비동기와의 연동을 담당하는 모듈이 필요한 상황이었습니다.
    • 트래픽이 많은 순수한 재고 조회가 아닌, 관리자에서 재고를 설정하는 등, 트래픽이 적고 DB와 Redis를 함께 활용하는 케이스에 필요한 모듈입니다.
    • 예를 들면, 관리자 화면을 통해, DB에 재고를 설정하고, Redis에도 함께 재고 값을 설정하는 케이스에 사용됩니다.
      • 이는 서로 다른 물리장비의 분산 트랜잭션 문제로, 2PC 등 하나의 트랜잭션으로 묶지 않았습니다.
      • DB의 성공을 보장할 것인지, Redis 성공을 보장할 것인지 도메인에 따라 중요도를 산정하여 선택하였습니다.
    • 위와 같은 연동 부분은 아래와 같은 구현을 통해 해결하였습니다. safeSet

재고 Key 생성 전략

재고 Key는 Stock Entity 즉 재고 제한을 사용하려는 각 도메인에서 자체적으로 생성하여 사용

  • 예를 들면, Coupon Entity에 재고를 적용하려고 할 경우, Coupon Entity가 위치한 패키지 내에 아래와 같은 KeyGenerator를 둡니다.
  • 재고 제한을 사용하려는 각 도메인에서, 자신의 요구사항에 맞는 Key 생성 전략을 가지는 것입니다.
  • Stock Entity는 재고 제한 설정을 가지고 있으며, 외부에 노출될 필요가 없는 Redis Key를 가지고 있지 않습니다.
  • 위와 같이 구현할 경우, 재고는 각 도메인의 다양한 Key 전략을 유연하게 수용할 수 있습니다. coupon_stock_key_resolver

재고의 동시성 이슈는 어떻게 해결하는가 ?

  • 쿠폰 이벤트로 동시에 1만명이 쿠폰을 사용하려고 할 경우, 동시성 이슈는 ?
    • 동시성 문제는 Redis를 통해 해소합니다.
    • Redis에서 실제 커맨드를 처리하는 부분은 Single Thread로 동작하기에, decrease/increase와 같은 증가/감소 명령의 순차 처리를 보장할 수 있습니다.
    • 따라서, 쿠폰 재고가 1만개이고, 1만명이 동시에 사용처리를 할 경우 남은 재고가 0이됨을 보장할 수 있습니다.

재고 증가/감소의 Atomic은 어떻게 보장하는가 ?

  • Redis는 set/increment/decrement 같은 각각의 Redis Command 단위에 대한 Atomic을 지원합니다.

추가적으로 고민했었던 부분은 무엇인가 ?

쿠폰을 사용처리하기 이전에 재고를 선점해야 하는 요구사항이 있었습니다.

따라서, 쿠폰의 사용/사용 취소 처리와 재고의 증가/감소가 직접적으로 연결되지 않습니다.

위와 같은 요구사항으로 인해, 재고를 선점하는 기능이 추가적으로 필요하였습니다.

재고 선점을 시도하는 행위는 성공할 수도 실패할 수도 있습니다.

  • 메소드 스펙을 생각해 본다면 아래와 같습니다.
    • boolean tryDecrease(String key);

  • 이에 따라, 재고 선점 시도를 위해 아래와 같은 Redis Atomic Operation이 추가적으로 필요하였습니다.

      Value = get(Key); 
      if (Value == Null) return false;
      if (Value > 0) { 
          if (decreaseAndGet(Key) != Null) return true;
      }
      return false;
    
  • 위 명령은 조회한 재고가 0보다 크다면 재고가 있는 것이고, 그게 아니라면 재고가 없다고 판단하는 것입니다.
    • 간단한 예를 들어보겠습니다, 2명의 사용자 (A,B)가 재고가 1개 남은 쿠폰을 동시에 사용하려 합니다.
      • 사용자 A가 get() 수행
      • 사용자 B가 get() 수행
      • 사용자 A는 재고 1이 남았다고 응답을 받음
      • 사용자 B는 재고 1이 남았다고 응답을 받음
      • 사용자 A는 재고가 있으므로, 쿠폰을 사용하여 재고 감소 -> 재고가 0이됨, 응답은 true
      • 사용자 B는 재고가 있으므로, 쿠폰을 사용하여 재고 감소 -> 재고가 -1이 됨, 응답은 true
    • 사용자 B는 false 즉 tryDecrease()에 실패해야 하지만, Atomic으로 동작하지 않아 의도하지 않은 결과가 발생합니다.
    • 따라서, 위 명령의 모음이 의도한 동작으로 수행되려면, 반드시 Atomic을 보장해야만 합니다.
    • 먼저, Redis에서 Atomic을 위해 지원하는, Redis Transaction 그리고 Lua Script를 고려하였습니다.
  • 쿠폰 재고 조회는 트래픽이 많은 편으로, Reactive Redis를 활용하고 있습니다.
  • 이후, Lua Script를 고려하였고, Lua Script의 경우 Lettuce의 부하 분산 기능을 이용하지 못하는 문제가 있었습니다.

재고 선점을 위한 Atomic 처리, 어떻게 해결하였는가 ?

  • 재고를 선점하는 요구사항이 반드시 Atomic을 보장해야 하는가에 대해 고민하였습니다.
  • 그리고 다른 방법으로 우회하여 문제를 해결할 수 있지 않을까? 생각하였습니다.
  • 그 결과로, 주어진 문제를 아래와 같이 다른 방법으로 풀어보았습니다.

      Value = decreaseAndGet(Key); 
      if (Value == Null) return false;
      if (Value >= 0) return true;
      if (increaseAndGet(key) != Null) return true;
      return false;
    
  • 기존에는, get command와 조회한 값이 0보다 큰 값인지 평가하는 흐름에 대해 Atomic을 반드시 보장해야 했습니다.
    • get을 통해 재고를 조회하는 명령과 get을 통해 조회한 재고를 평가하고 decreaseAndGet하는 명령이 분리되어 의도하지 않은 데이터 불일치가 발생할 수 있기 때문입니다.
  • 따라서, 위 문제를, 2개의 작은 문제로 분리해서 생각해 보았습니다.
    • 먼저 decreaseAndGet 후 그 값이 음수이면, 감소한 값을 그대로 증가하여 보상해주는 2개의 Phase로 분리하였습니다.
    • 쉽게 말해서, 보상 트랜잭션 기법을 적용한 것으로 Atomic을 보장하지 않고, Eventual Consistency를 지원하는 것입니다.
    • 각 Phase는 각 역할에 충실하면 되며, 이를 통해 시간 차이로 인한 데이터 불일치 문제를 해결할 수 있습니다.
  • 그리고 그 결과는 아래와 같습니다. tryDecrease

Atomic을 지원하지 않고, Eventual Consistency를 지원하는 보상 트랜잭션 개념으로 접근하였는데 문제는 없을까 ?

  • 아래와 같은 부분에 대해, 반드시 고민이 필요합니다.
  • Example) 재고 감소 후, 그 값이 음수라 재고 보상이 이루어져야 한다. 따라서, 재고 보상을 진행하려는데 이때 Redis의 장애가 발생하면 ?
    • AWS Elasticache의 Cluster Redis를 사용하고 있으며, Cluster Failover를 통해 복구가 진행됩니다.
    • Proxy Endpoint인 Cluster Endpoint를 통해 접근을 하여, Failover 후 Slave => Master가 된 노드로 재고 보상 요청이 인입됩니다.
    • 만약, Cluster Failover를 통해 복구하였지만 처리가 실패한 재고 보상 요청이 있다면, Error Logging을 통해 보정할 수 있도록 합니다.
    • 기본적으로 성능과 일관성 사이에서는 하나를 얻으면 하나를 잃습니다.
    • 이는, 다루는 데이터의 특성에 따라 선택해야 할 문제로 보입니다.

결과

  • 만료된 재고에 대해, 휘발성 메모리인 Redis -> 비휘발성 저장소인 DB로 Sync 후, Redis를 Cleansing하여 Redis의 가용 메모리를 유지할 수 있습니다
  • 관리자에서 재고 조회를 위해 Redis를 조회하지 않아, 비효율적인 Redis 부하가 발생하지 않습니다. 필요시 Sync API를 통해 실시간 재고 확인 가능합니다.
  • 실시간 재고 처리는 시간복잡도 O(1)을 보장합니다.
  • 재고 처리의 동시성 문제를 해결하였습니다.
  • 대용량 트래픽 대응을 위해, 실시간 재고 처리는 Reactor 기반으로 비동기로 처리될 수 있습니다.
  • 운영중에 재고를 늘리거나 줄일 수 있습니다. 상품이 너무 잘 팔릴 경우 재고를 추가로 늘릴 수 있고, 잘 팔리지 않을 경우 반대로 줄일 수 있습니다.
  • 어떠한 재고도 표현 가능합니다. 쿠폰의 사용재고, 지급재고, 일별재고 등 재고 도메인을 사용하려는 곳에서 각자의 관심사에 맞게 재고 Key를 Generation하여 사용합니다.

Sample Project

마치며

  • 재고의 확장성, 성능, Redis의 가용 메모리 유지, 관리자 페이지까지 다각도로 고려하며 고민하였습니다.
  • 가장 재미있게 진행했던 프로젝트 중 하나로 기억될 것 같습니다.

pkgonan

서버 개발자 Github Linkedin Facebook

Java 및 Spring을 활용한 서버 개발과 성능 튜닝에 관심이 있습니다. 객체지향 및 테스트 코드 작성을 중요하게 생각하며, 변화에 강한 코드를 작성하고자 노력하고 있습니다.