안녕하세요. 저는 주문서비스팀의 서버개발자 강홍구입니다. 이 글에서는 배달의민족 선물하기 서비스의 상품권 재고관리를 위한 시스템 설계에 대한 경험을 공유드리고자 합니다.

들어가며

선물하기 서비스는 배달의민족 어플리케이션에서 고객분들끼리 일종의 상품권을 서로 선물하여 맛있는 음식으로 마음을 전하는 아주 뜻깊은 서비스 입니다.
저희 팀에서는 선물하기 서비스를 담당하여 개발을 진행하게 되었고, 약 6개월간의 개발기간을 거쳐 작년 10월경 저희팀에서 성심성의것 개발을 진행했던 선물하기 서비스가 드디어 오픈을 하게 되었습니다.

선물하기홈

새해복 많이 받으세요.

오픈후 서비스를 운영하며, 특정 프랜차이즈의 가게에서는 할인된 가격으로 상품권을 선물할 수 있는 기능이 추가되면 고객분들에게 ‘더 좋은 경험이 될 수 있을것 같다’ 라는 결정이 있었고, 저희팀에서는 프랜차이즈 상품권 기능을 추가로 개발하게되었습니다. 프랜차이즈 상품권 기능이 추가됨에 따른 주된 요구사항은 할인설정, 재고관리 기능의 추가였습니다.

할인설정 기능은 초기 오픈단계에서 추가될 가능성이 있다고 판단이 되어, 도메인 설계시 어느정도 반영이 되어있는 상태였습니다. (사실은 상품의 판매단위가 큰 변화를 겪음으로써 많은 변화가 있었지만요..) 그러나 재고관리 기능의 경우에는 초기설계시 고민이 되어있지 않은 상태였기에 어떻게 재고관리를 설계해야 할지가 ‘프랜차이즈 상품권’ 릴리즈에 중요한 고민 요소였습니다.

타 커머스에서는 재고관리를 어떻게 하고 있을지 리서칭을 해보았지만 재고관리 설계에 대한 글은 많이 보이지 않았습니다.
그래서 저희 배달의민족 선물하기 서비스에서는 어떻게 재고관리 시스템을 설계했는지 공유를 한다면 조금이나마 도움이 되지 않을까 하여 글을 작성하게 되었습니다.

선물하기 시스템

선물하기 서비스는 상품의 속성을 정의하고, 관리하는 상품시스템, 정의된 상품을 어느 카테고리에 매핑시켜 노출시킬지를 결정하는 전시시스템, 상품을 상품권화 시키기 위해 고객님의 구매가 이루어질 수 있도록 하는 구매시스템, 상품권을 음식주문시 사용할 수 있도록 하는 상품권 시스템 총 4가지 시스템으로 이루어져 있습니다.

재고라는 속성은 상품의 속성이라고 판단이 되었고, 재고량(총 재고량, 재고 사용량)의 관리는 상품시스템에서 관리를 하도록 결정을 하였습니다.

선물하기_상품_ERD

상품시스템의 초간단 ERD

상품시스템의 ERD를 간단히 보면, 위와같이 상품, 판매상품, 가격정책의 엔티티로 이루어져있습니다. 상품 엔티티는 상품명, 상품이미지 등 보여지는 요소에 대한 속성을 정의하며, 판매상품 엔티티는 정의된 상품이 어떤식(판매기간)으로 판매될지를 결정합니다. 마지막으로 가격정책 엔티티는 어떻게 판매될지 결정된 판매상품을 어떠한 가격에 얼만큼 팔지(원금액, 할인금액, 인당재고, 총재고)를 결정합니다.

class SalesProductPrice(
    type: DiscountType,
    salesProduct: SalesProduct,
    priceCommonId: Long,
    price: Long,
    defaultFlag: Boolean,
    /** 할인 정보 **/
    amount: Long?,
    rate: Int?,
    charge: Long?,
    sellerCharge: Long?,
    franchiseCharge: Long?,
    discountStatus: DiscountStatus?,
    /** 재고 정보**/
    totalQuantity: Long?, // 총 재고수량
    perLimitTotalQuantity: Long?, // 인당 구매제한수량
    stockStatus: StockStatus?,
    status: SalesProductPriceStatus
) : BaseUuidEntity() {
 ...
}

enum class SalesProductPriceStatus(
    description: String
) {
    ON("활성"),
    OFF("비활성"),
    SOLD_OUT("품절");
}

가격정책 엔티티를 통해서 총 재고 수량과 인당 구매제한 수량을 관리한다.
총 수량을 정해두고, 사용량이 총수량을 넘어설 경우 가경정책 상태를 SOLD_OUT 처리한다.

재고관리 요구사항

프랜차이즈 상품권 판매를 위해서 추가로 구현되어야 할 주된 기능중 하나인 재고관리 기능은 아래와 같은 요구사항을 만족해야했습니다.

1. 상품의 권종별로 전체 재고수량과 인당 재고수량이 관리되어야 한다.
2. 상품은 전체재고량을 초과하여 판매되면 안된다.
3. 판매가 시작된 상품의 전체 재고수량은 감소시킬 수 없다.
  1. 상품의 권종별로 전체 재고수량과 인당 재고수량이 관리되어야 한다.

    하나의 상품은 여러개의 권종일 가질 수 있습니다.
    재고는 권종별로 관리가 되어지며, 고객 한분당 구입이 가능한 수를 제한해야하고, 권종 전체의 구입 가능한 수를 제한해야했습니다.

  2. 상품의 권종은 전체 재고량을 초과하여 판매되면 안된다.

    권종별로 제한된 재고량은 절대 초과하여 판매되면 안됩니다.
    상품이 덜 판매되어서 재고량이 남는 이슈가 생길지언정 절대 초과하여 상품이 판매되면 안되어야 합니다.

  3. 판매가 시작된 상품의 전체 재고수량은 감소시킬 수 없다.

    판매가 한번 시작된 상품의 경우에는 재고량 수정이 가능하나 최초 설정된 재고량 이상을 설정할 수 없어야 합니다.

재고관리 설계

위와 같은 요구사항을 만족시키기 위해서는 재고관리 시스템을 설계할때 아래의 부분을 중심으로 고려해야 했습니다.

  1. 전체 재고량의 관리와 트랜잭션이 일어나는 재고 사용량은 분리하여 저장한다.

    전체 재고량의 경우 RDB에 저장하여 관리하고, 트랜잭션이 일어나는 재고사용량의 관리는 연산속도가 빠른 in-memory DB를 사용한다.

  2. 재고 사용량의 증가와 감소시 동시성 이슈는 없어야 한다.

    연산처리는 단일 스레드에서 처리하는 Redis를 이용하여 동시성 이슈를 해결한다.

  3. 재고 사용량 데이터는 유실되어서는 안된다.

    in-memory DB는 휘발성 데이터로 데이터 유실이 일어날 수 있으므로, 재고 사용량 데이터를 RDB에 싱크할 수 있도록 한다.

  4. 재고 사용량의 관리는 Redis 의 Set 자료구조에 구매번호를 저장하여 관리한다.

    구매번호는 유니크한 값이고, Redis의 Set 자료구조는 중복을 허용하지 않기때문에 구매번호를 Set에 저장할 경우 SCARD 오퍼레이션을 통해 손쉽게 사용량을 가져올 수 있다.

저희는 재고관리 시스템에 RDBRedis를 함께 사용하기로 하였습니다.

실제 상품권 구매에 대한 트랜잭션에 대해서는 Redis를 사용하여 동시성 이슈를 처리하였고, 데이터 유실을 방지하기 위해 트랜잭션 시점에 RDB에 데이터를 싱크하도록 하였습니다.

데이터 싱크를 위한 RDB 는 아래와 같이 구매 시점, 구매취소 시점에 판매상품번호, 구매번호, 구매한회원번호, 구매가격, 타입만을 저장하는 단일 엔티티로 단순 설계하여 재고량 증가 혹은 감소시점에 insert 쿼리만 발생하도록 하였습니다.

@Entity
@Table(
    name = "stock_history",
    indexes = [
        Index(name = "idx_stock_history_sales_product_number", columnList = "salesProductNumber"),
        Index(name = "idx_stock_history_purchase_number", columnList = "purchaseNumber"),
        Index(name = "idx_stock_history_member_number", columnList = "memberNumber")
    ]
)
class StockHistory(
    salesProductNumber: String,
    purchaseNumber: String,
    memberNumber: String,
    price: Long,
    type: StockHistoryType
) : BaseUuidEntity() {

    @Column(nullable = false, length = 10)
    var salesProductNumber: String = salesProductNumber

    @Column(nullable = false, length = 10)
    var purchaseNumber: String = purchaseNumber

    @Column(nullable = false, length = 20)
    var memberNumber: String = memberNumber

    @Column(nullable = false)
    var price: Long = price

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    var type: StockHistoryType = type
}

enum class StockHistoryType(
    val description: String
) {
    PLUS("재고사용량 증가"),
    MINUS("재고사용량 감소");
}

Redis의 경우에는 아래와 같이 전체 실시간 재고와, 인당 실시간 재고를 관리하도록 Key 값을 정하였고, 자료구조는 Set 자료구조를 선택하였습니다.
각 Key (전체 실시간 재고, 인당 실시간 재고)에 해당하는 Value에는 유니크한 구매번호를 저장하도록 하였습니다. (레디스의 Set 자료구조는 기본적으로 값에 대한 중복을 제거해 줍니다.)

데이터구조 Set 자료구조
- 전체 실시간 재고 Key = {판매상품번호}:{상품가격}:stock:total
- 인당 실시간 재고 Key = {판매상품번호}:{상품가격}:stock:{구매자회원번호}

예시
- 재고사용량 증가
// G0AA0001JR 구매 발생 전체 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:total G0AA0001JR
(interger) 1
// G0AA0001JR 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:201209320003 G0AA0001JR
(interger) 1

// G0AA0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:total G0AA0001JQ
(interger) 1
// G0AA0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:201209320003 G0AA0001JQ
(interger) 1

// G0BB0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:total G0BB0001JQ
(interger) 1
// G0BB0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:201209320003 G0BB0001JQ
(interger) 1

- 재고 사용량 조회
// 전체 재고사용량 조회
redis> SCARD S0630000RU:5000:stock:total
(integer) 3 // G0AA0001JR,G0AA0001JQ,G0BB0001JQ
// 인당 재고사용량 조회
redis> SCARD S0630000RU:5000:stock:201209320003
(integer) 3 // G0AA0001JR,G0AA0001JQ,G0BB0001JQ

- 재고사용량 감소
// G0BB0001JQ 구매취소 발생 전체 재고사용량 감소
redis> SREM S0630000RU:5000:stock:total G0BB0001JQ
(interger) 1
// G0BB0001JQ 구매취소 발생 인당 재고사용량 감소
redis> SREM S0630000RU:5000:stock:201209320003 G0BB0001JQ
(interger) 1

- 재고 사용량 조회
redis> SCARD S0630000RU:5000:stock:total
(integer) 2 // G0AA0001JR,G0AA0001JQ
redis> SCARD S0630000RU:5000:stock:201209320003
(integer) 2 // G0AA0001JR,G0AA0001JQ

아래 코드는 재고 사용량의 증가,감소,사용량 조회에 관련된 레디스 오퍼레이션 코드입니다.

/**
* 재고관리 데이터 클래스
**/
data class Stock(
    val salesProductNumber: String,
    val price: Long,
    val purchaseNumber: String? = null,
    val memberNumber: String? = null
)

/**
 * 전체 재고 사용량 체크 레디스 오퍼레이션
 * 자료구조 : Set
 */
class TotalStockOperation {
    companion object {
        val log = logger()
        private var key = "stock:total"

        /**
         * SADD Operation
         */
        fun add(redisOperations: RedisOperations<String, String>, stock: Stock) {
            val key = getKey("${stock.salesProductNumber}:${stock.price}")
            redisOperations.opsForSet().add(key, stock.purchaseNumber)
            log.info { "[TotalStockOperation][add] 전체 재고 사용량 증가 key $key | 재고정보 $stock" }
        }

        /**
         * SREM Operation
         */
        fun remove(redisOperations: RedisOperations<String, String>, stock: Stock): Long? {
            val key = getKey("${stock.salesProductNumber}:${stock.price}")
            val popCnt = redisOperations.opsForSet().remove(key, stock.purchaseNumber)
            log.info { "[TotalStockOperation][remove] 전체 재고 remove key $key | 재고정보 $stock | remove원소수: $popCnt" }
            return popCnt
        }

        /**
         * SCARD Operation
         */
        fun totalUsedCount(redisOperations: RedisOperations<String, String>, stock: Stock): Long {
            val key = getKey("${stock.salesProductNumber}:${stock.price}")
            val size = redisOperations.opsForSet().size(key) ?: 0
            log.info { "[TotalStockOperation][totalUsedCount] 전체 재고량 TOTAL key $key | 재고정보 $stock | 총수량: $size" }
            return size
        }

        fun getKey(keyPrefix: String) = "$keyPrefix:$key"
    }
}

...

/**
 * 회원당 재고 사용량 체크 레디스 오퍼레이션
 * 자료구조 : Set
 */
class MemberStockOperation {
    companion object {
        val log = logger()
        private var key = "stock"

        /**
         * SADD Operation
         */
        fun add(redisOperations: RedisOperations<String, String>, stock: Stock) {
            val key = createKey("${stock.salesProductNumber}:${stock.price}", stock.memberNumber!!)
            redisOperations.opsForSet().add(key, stock.purchaseNumber)
            log.info { "[MemberStockOperation][add] 회원인당 재고 사용량 증가 key $key | 재고정보 $stock" }
        }

        /**
         * SREM Operation
         */
        fun remove(redisOperations: RedisOperations<String, String>, stock: Stock): Long? {
            val key = createKey("${stock.salesProductNumber}:${stock.price}", stock.memberNumber!!)
            val popCnt = redisOperations.opsForSet().remove(key, stock.purchaseNumber)
            log.info { "[MemberStockOperation][remove] 회원인당 재고 remove key $key | 재고정보 $stock | remove원소수: $popCnt" }
            return popCnt
        }

        /**
         * SCARD Operation
         */
        fun totalUsedCount(redisOperations: RedisOperations<String, String>, stock: Stock): Long {
            val key = createKey("${stock.salesProductNumber}:${stock.price}", stock.memberNumber!!)
            val size = redisOperations.opsForSet().size(key) ?: 0
            log.info { "[MemberStockOperation][totalUsedCount] 회원인당 재고량 TOTAL key $key | 재고정보 $stock | 총수량: $size" }
            return size
        }

        private fun createKey(keyPrefix: String, keyPostFix: String) = "$keyPrefix:$key:$keyPostFix"
    }
}

재고 사용량 증가/차감 흐름

재고시스템에서 재고 사용량의 증가 & 차감의 전체적인 시스템 흐름을 재고 사용량 증가, 재고 사용량 감소로 나누어서 말씀드리겠습니다.

1. 재고 사용량 증가

먼저 재고 사용량 증가는 아래와 같은 흐름으로 진행이 되게 됩니다.

재고량증가

구매 시스템에서는 상품권의 구매가 발생하면 구매 API 가 호출됩니다. 구매 시스템에서는 인증 정보 조회를 위해서 회원시스템 호출, 상품 유효성 검증 및 재고사용량 증가를 위한 상품 시스템 호출, 결제를 위한 결제시스템 호출 등 상품권 구매를 완료하기 위해 여러 타 시스템을 호출하게 됩니다.

이러한 일련의 과정들은 구매에 영향을 미치는 요소이기 때문에, (구매가 정상적으로 이루어지지 않았는데 재고사용량이 증가되어 있으면 안되니까요) 모두 구매가 일어나는 API와 동기 방식으로 얽혀서 진행되게 되어집니다.

구매시스템에서 구매가 진행되면, 상품 시스템의 재고사용량 증가 API가 호출되게 됩니다. 상품 시스템에서는 아래와 같은 흐름으로 재고사용량을 증가 시킵니다.

1) (RDB) 트랜잭션 시작 (BEGIN Transaction)
2) 현재 구매가 가능한 상태인지 유효성 검증 과정을 거칩니다.
3) 구매가 가능할경우 Redis에 구매번호를 Add 해줍니다.
4) 구매가 가능할경우 RDB의 StockHistory 엔티티에 구매정보를 Insert 합니다.
5) (RDB)트랜잭션 커밋 (COMMIT Transaction)

구피라는 회원(회원번호: 201209320003)이 오늘도 수고했어(판매상품번호: S0630000RU) 5000원권의 상품권을 구매(구매번호: G0AA0001JR)하는 시나리오로 설명을 드려보겠습니다.

위의 재고시스템 설계에서 데이터 유실과 동시성 이슈를 해결하고자 저장소를 RDB(재고 히스토리 관리)와 Redis(재고 사용량 관리) 두곳을 사용한다고 말씀을 드렸는데요.

재고 사용량 증가를 위해서 먼저 1)RDB 트랜잭션이 시작되게 되고, 현재 2)구매가 가능한 상태인지 유효성검증을 하게 됩니다. Redis의 SCARD 오퍼레이션을 통해서 현재 구매가 발생된 상품권의 갯수를 조회하게 되고, 가격정책 엔티티(SalesProductPrice)에 저장된 총 재고수량(totalQuantity)와 인당 구매제한 수량(perLimitTotalQuantity)을 조회하여 두값을 비교하게 됩니다.

[재고 사용량 유효성 검증]
SalesProductPrice.totalQuantity = 1,000 // 총 재고수량 1,000개
SalesProductPrice.perLimitTotalQuantity = 2 // 인당 구매 제한수량 2개
SCARD S0630000RU:5000:stock:201209320003 = 0 // 구피(201209320003)회원이 구매한 상품권 0개
SCARD S0630000RU:5000:stock:total = 100 // 현재 구매된 상품권 100개
perLimitTotalQuantity > 회원 재고 사용량
totalQuantity > 전체 재고 사용량

유효성 검증이 통과되면 3)Redis에 전체 재고 사용량 정보와 인당 재고 사용량 정보를 증가시키게 됩니다.
이때, 인당 재고사용량과 전체 재고사용량을 하나의 트랜잭션에서 처리하기 위해서 redis 의 multi(), exec() 명령어로 묶어 줌으로써 두 오퍼레이션에 대한 트랜잭션을 보장하도록 처리하였습니다.

[재고 사용량 증가]
redis> multi
// G0AA0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:total G0AA0001JQ
// G0AA0001JQ 구매 발생 인당 재고량 사용량 증가
redis> SADD S0630000RU:5000:stock:201209320003 G0AA0001JQ
redis> exec

Redis에 재고 사용량이 업데이트 되게되면 4)RDB의 재고 히스토리정보에 Insert 쿼리가 발생하게 되어 히스토리 정보를 저장하게 됩니다.

[재고 히스토리 저장]
insert into stock_history (sales_product_number, purchase_number, member_number, price, type) values(‘S0630000RU’, ‘G0AA0001JR’, ‘201209320003’, ‘5000’, ‘PLUS’)

재고사용량을 증가하게되면 마지막으로 5)RDB 트랜잭션이 COMMIT 되면서 재고사용량 증가에 대한 모든 프로세스가 완료되어지게 됩니다.

지금까지 말씀드린 재고사용량 증가에 대한 코드는 아래와 같습니다.

/**
* 레디스의 여러 오퍼레이션을 하나의 트랜잭션에서 처리하기 위해 multi() / exec() 연산으로 묶어준다.
**/
object RedisTransaction {
    fun transaction(operations: RedisOperations<String, String>, command: (operation: RedisOperations<String, String>) -> Unit) {
        operations.execute(object : SessionCallback<Void?> {
            @Throws(DataAccessException::class)
            override fun <K, V> execute(callbackOperations: RedisOperations<K, V>): Void? {
                callbackOperations.multi()
                command.invoke(operations)
                callbackOperations.exec()
                return null
            }
        })
    }
}

@Service
@Transactional // 1) 트랜잭션 Begin 5) 트랜잭션 Commit
class StockHistoryDomainService(
    private val redisTemplate: RedisTemplate<String, String>,
    private val stockHistoryRepository: StockHistoryRepository,
    private val saleProductDomainService: SalesProductDomainService
) {
    ...
    @Synchronized
    fun increaseStock(data: StockHistoryData, totalQuantity: Long, perLimitTotalQuantity: Long): StockResult {
        val stock = Stock(
            salesProductNumber = data.salesProductNumber,
            purchaseNumber = data.purchaseNumber,
            memberNumber = data.memberNumber,
            price = data.price
        )

        // 2) 유효성 검증 (인당 제한 수량 체크)
        if (!validationMemberQuantity(perLimitTotalQuantity, stock)) {
            return StockResult.MEMBER_LIMIT
        }

        // 2) 유효성 검증 (전체 재고 수량 체크)
        return if (validationTotalQuantity(totalQuantity, stock)) {
            RedisTransaction.transaction(
                redisTemplate
            ) { operations ->
                log.info { "[StockHistoryDomainService][increaseStock] 상품 구매완료 재고정보 : $stock" }
                // 3) 재고 사용량 증가
                TotalStockOperation.add(operations, stock)
                MemberStockOperation.add(operations, stock)
                // 4) 히스토리 정보 추가
                stockHistoryRepository.save(StockHistoryConverter.toEntity(data))
            }

            // 재고 품절처리
            this.checkSoldOut(totalQuantity, stock)
            StockResult.SUCCESS
        } else {
            StockResult.TOTAL_LIMIT
        }
    }

    fun validationMemberQuantity(
        perLimitTotalQuantity: Long,
        stock: Stock
    ): Boolean {
        return validateQuantity(total = perLimitTotalQuantity, quantity = MemberStockOperation.totalUsedCount(redisTemplate, stock))
    }

    fun validationTotalQuantity(
        totalQuantity: Long,
        stock: Stock
    ): Boolean {
        return validateQuantity(total = totalQuantity, quantity = TotalStockOperation.totalUsedCount(redisTemplate, stock))
    }

    private fun validateQuantity(total: Long, quantity: Long): Boolean {
        log.info { "[StockHistoryDomainService][validateQuantity] $total > $quantity (${total > quantity})" }
        return total > quantity
    }

    private fun checkSoldOut(totalQuantity: Long, stock: Stock) {
        val totalUsedCount = TotalStockOperation.totalUsedCount(redisTemplate, stock)
        log.info { "재고 품절 상태 체크 (전체수량:$totalQuantity | 전체 사용량:$totalUsedCount)" }
        if (totalQuantity <= totalUsedCount) {
            saleProductDomainService.updateSoldOut(
                salesProductNumber = stock.salesProductNumber,
                price = stock.price
            )
        }
    }

}

2. 재고 사용량 차감

재고 사용량의 차감은 아래와 같은 흐름으로 진행이 됩니다.

재고량차감

구매가 취소되거나, 구매시 API 호출이 실패했을 경우, 구매시스템에서는 event-queue에 재고사용량을 차감시키라는 이벤트를 비동기방식으로 발행하게 됩니다.
상품 시스템의 이벤트 워커(product-event-worker)에서는 재고 사용량 차감이벤트가 발행되면, event-queue를 구독하여 재고사용량을 차감하게 됩니다.

1) (RDB) 트랜잭션 시작 (BEGIN Transaction)
2) Redis SREM 오퍼레이션으로 Set 에서 구매번호 제거
3) RDS StockHistory 엔티티에 재고량 감소 구매정보 데이터 저장
4) (RDB)트랜잭션 커밋 (COMMIT Transaction)

재고 사용량 증가때 구매가 이루어진 G0AA0001JR 구매번호의 상품권이 구매취소 가 일어나거나 혹은 구매시 재고 사용량 API 증가 호출 과정에서 에러 가 발생했을 경우, 구매시스템에서는 재고 사용량을 차감 하기위해 이벤트를 발행하게 됩니다.

[이벤트 발행]
gift-product-purchase-stock-event-queue » ‘G0AA0001JR’

상품시스템의 이벤트 워커에서는 재고사용량 감소를 위한 큐를 구독하고 있다가 이벤트가 수신되었을 경우 해당 이벤트를 수신하여 재고사용량을 차감 시켜줍니다. 레디스의 SET 자료구조를 사용했기 때문에, Set 안에 구매번호가 존재한다면 정상적으로 차감 처리가 이루어질 것이고, 그렇지 않다면 재고사용량은 차감되지 않을것 입니다.

[재고사용량 차감]
redis> multi
redis> SREM S0630000RU:5000:stock:total G0AA0001JR
redis> SREM S0630000RU:5000:stock:201209320003 G0AA0001JR
redis> exec

정상적으로 Redis 오퍼레이션이 수행되었다면, RDB의 재고 히스토리 정보에 차감 내역을 Insert 해주게 됩니다.

[재고 히스토리 저장]
insert into stock_history (sales_product_number, purchase_number, member_number, price, type) values(‘S0630000RU’, ‘G0AA0001JR’, ‘201209320003’, ‘5000’, ‘MINUS’)

마지막으로 트랜잭션이 COMMIT 되고, 재고 사용량 차감에 대한 프로세스가 완료되게 됩니다.

재고사용량 차감에 대한 프로세스를 비동기 방식으로 처리한 것에 대한 의문이 있으실 수 있을것 같습니다. 재고사용량 차감의 경우에는 아래와 같은 이유로 비동기 방식으로 처리가 가능했습니다.

  1. 전체 재고량을 관리하고 재고 사용량을 증가 혹은 차감 시키는 방식을 사용한다.
  2. 재고사용량 증가 방식을 동기 방식으로 처리함으로써, 절대 재고가 더 팔리는일은 발생하지 않는다.
  3. Redis의 SET 자료구조를 사용함으로써, 재고사용량 차감에 대한 잘못된 구매번호의 이벤트가 발행되어도 재고사용량 차감에 영향을 미치지 않는다.

재고사용량 차감에 대한 처리 코드는 아래와 같습니다.

/**
* 재고사용량 차감 이벤트 서브스크라이버
**/
@Component
class StockNotificationSubscriber(
    private val stockNotificationProcessor: StockNotificationProcessor
) {

    val log = logger()

    @SqsListener(
        value = ["\${messaging.subscriber.sqs-url.gift-product-purchase-stock-event}"],
        deletionPolicy = SqsMessageDeletionPolicy.NEVER
    ) // 재고사용량 차감에 대한 이벤트를 수신한다.
    fun receivePurchaseStockEvent(
        @Header("ApproximateReceiveCount") receiveCount: String,
        @Header("MessageId") messageId: String,
        @Payload payLoad: StockPurchasePayload,
        acknowledgment: Acknowledgment
    ) {
        try {
            log.info("[StockNotificationSubscriber][receivePurchaseStockEvent] messageId : $messageId | payload: $payLoad")
            stockNotificationProcessor.process(payLoad) // 이벤트 프로세서에서 차감 로직을 수행하도록 호출한다.
            acknowledgment.acknowledge()
        } catch (e: Exception) {
            log.error("[StockNotificationSubscriber][receivePurchaseStockEvent] ERROR messageId : $messageId, receiveCount : $receiveCount", e)
        }
    }
}

...

/**
* 재고사용량 차감 이벤트 프로세서
**/
@Component
class StockNotificationProcessor(
    private val stockHistoryDomainService: StockHistoryDomainService
) {

    val log = logger()

    fun process(payLoad: StockPurchasePayload) {
        log.info("[StockNotificationProcessor][process] 이벤트 프로세싱 시작: $payLoad")
        stockHistoryDomainService.decreaseStock(convertData(payLoad)) // 재고 사용량을 차감한다.
        log.info("[StockNotificationProcessor][process] 이벤트 프로세싱 끝 | payload: $payLoad")
    }

    private fun convertData(payLoad: StockPurchasePayload) = StockHistoryData(
        salesProductNumber = payLoad.salesProductNumber,
        purchaseNumber = payLoad.purchaseNumber,
        memberNumber = payLoad.memberNumber,
        price = payLoad.price,
        type = StockHistoryType.MINUS
    )
}

/**
* 재고사용량 감소 서비스레이어
**/
@Service
@Transactional
class StockHistoryDomainService(
    private val redisTemplate: RedisTemplate<String, String>,
    private val stockHistoryRepository: StockHistoryRepository,
    private val saleProductDomainService: SalesProductDomainService
) {
    ...
    fun decreaseStock(data: StockHistoryData) {
        val stock = Stock(
            salesProductNumber = data.salesProductNumber,
            purchaseNumber = data.purchaseNumber,
            memberNumber = data.memberNumber,
            price = data.price
        )

        RedisTransaction.transaction(
            redisTemplate
        ) { operations ->
            log.info { "[StockHistoryDomainService][decreaseStock] 상품 취소완료 재고정보 : $stock" }
            TotalStockOperation.remove(operations, stock) // Redis 전체 재고 사용량 차감
            MemberStockOperation.remove(operations, stock) // Redis 인당 재고 사용량 차감
            stockHistoryRepository.save(StockHistoryConverter.toEntity(data)) // 히스토리 저장
        }
    }
}

마치며

이제까지 배민의 선물하기 서비스에서 상품 재고관리를 어떻게 관리하는지에 대해서 소개해드렸습니다. 재고관리 시스템을 설계하며 재고가 더 팔리면 어떻게하지?, 데이터가 유실되면 어떻하지? 라는 고민과 걱정을 많이하며 데이터 싱크와 동시성 이슈에 대해서 많은 고민을 하며 진행을 했었습니다. 이러한 고민이 글을 읽어주신 분들에게 조금이나마 도움이 되었으면 좋겠습니다.

긴글 읽어 주셔서 감사합니다. (꾸벅)