Redis Lua 스크립트로 재고 동시성 제어하기 — reserve / commit / release 3단계 설계

정영범·2026년 3월 25일

토이프로젝트

목록 보기
4/11

Redis Lua 스크립트로 재고 동시성 제어하기 — reserve / commit / release 3단계 설계

들어가기 전에

지난글에서 멱등성 처리를 다뤘다. 이번 글은 재고 동시성 문제를 다룬다.

커머스 서비스에서 재고 관리는 동시성 문제가 가장 빈번하게 터지는 영역 중 하나다. 특히 한정 수량 상품에 동시 주문이 몰리면 재고보다 많은 주문이 생기는 오버셀링(overselling) 이 발생할 수 있다. 이걸 막기 위해 Redis Lua 스크립트를 선택한 이유와 구현 과정을 정리한다.


문제 — 왜 일반적인 방식으로는 안 되나

가장 단순한 재고 차감 코드를 보자.

fun order(productId: UUID) {
    val stock = stockRepository.findByProductId(productId)
    
    if (stock.count <= 0) throw InsufficientInventoryException()
    
    stock.count -= 1
    stockRepository.save(stock)
}

단일 요청에서는 아무 문제가 없다. 그런데 동시에 100개의 요청이 들어오면 어떻게 될까?

재고: 1개

Thread A: stock.count 조회 → 1
Thread B: stock.count 조회 → 1  (A가 저장하기 전에 읽음)
Thread A: stock.count = 0, 저장
Thread B: stock.count = 0, 저장  (둘 다 성공 → 재고 1개인데 2개 팔림)

이게 전형적인 Race Condition이다. 둘 다 재고가 있다고 읽고, 둘 다 차감에 성공한다. 재고가 1개인데 2개가 팔린다.


해결 방법 비교

방법 1: DB 비관적 락 (Pessimistic Lock)

@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByProductId(productId: UUID): Stock

조회할 때 DB 레코드에 락을 건다. 다른 트랜잭션은 락이 풀릴 때까지 대기한다. 정합성은 완벽하게 보장된다.

단점은 성능이다. 락을 기다리는 트랜잭션이 쌓이면 DB 커넥션이 고갈된다. 트래픽이 몰리는 순간 병목이 생긴다.

방법 2: DB 낙관적 락 (Optimistic Lock)

@Version
var version: Long = 0

충돌이 발생하면 예외를 던진다. 재시도 로직을 직접 구현해야 한다. 충돌이 적은 환경에서는 효율적이지만, 재고 차감처럼 충돌이 잦은 환경에서는 재시도가 폭발적으로 늘어난다.

방법 3: Redis 원자적 연산

Redis의 단일 명령어는 원자적으로 실행된다. DECR 명령어 하나로 조회와 차감을 동시에 처리할 수 있다. DB 락 없이 빠르게 처리된다.

그런데 재고 차감만으로는 부족하다. 이 프로젝트에서는 예약(reserve) → 확정(commit) / 해제(release) 3단계 구조가 필요했다. 결제가 완료되기 전까지는 재고를 "임시로 잡아두는" 상태가 있어야 하기 때문이다.

단순 DECR로는 이 구조를 원자적으로 처리할 수 없다. 여러 명령어를 원자적으로 묶어야 한다. 그래서 Lua 스크립트를 선택했다.


왜 Lua 스크립트인가

Redis는 Lua 스크립트를 원자적으로 실행한다. 스크립트가 실행되는 동안 다른 Redis 명령어는 끼어들 수 없다. 여러 Redis 명령어를 하나의 원자적 연산처럼 묶을 수 있다.

MULTI / EXEC 트랜잭션으로도 원자성을 보장할 수 있지만, 조건 분기(if stock > 0)를 트랜잭션 안에서 처리하려면 WATCH를 써야 하고 구현이 복잡해진다. Lua는 조건 분기와 여러 명령어를 하나의 스크립트로 깔끔하게 표현할 수 있다.


3단계 구조 설계

재고 흐름을 먼저 정리했다.

[정상 흐름]
주문 생성 → reserve() → 재고 임시 차감, hold 키 생성
결제 완료 → commit() → hold 키 삭제 (재고 확정)

[보상 흐름]
결제 실패 → release() → hold 키 삭제 + 재고 복구
주문 만료 → release() → hold 키 삭제 + 재고 복구

Redis 키 구조는 이렇게 설계했다.

stock:default       → 현재 가용 재고 수량 (Integer)
holdCount:default   → 현재 예약 중인 재고 수량 (Integer)
hold:<reservationId> → 예약 정보 JSON, TTL 10분

주문 생성 시 DB orders 테이블의 expiresAt을 now + 10분으로 설정한다. 스케줄러가 10초마다 expiresAt이 지난 ORDER_RESERVED 상태의 주문을 조회해서 release()를 호출한다. Redis의 hold 키 TTL도 동일하게 10분으로 맞춰두었지만, 이건 스케줄러가 놓친 경우를 대비한 보조 안전장치다.


reserve.lua — 재고 예약

-- KEYS[1] = stockKey         (stock:default)
-- KEYS[2] = holdKey          (hold:<reservationId>)
-- KEYS[3] = holdCountKey     (holdCount:default)
-- ARGV[1] = ttlSeconds
-- ARGV[2] = holdValue (JSON)

local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock <= 0 then
  return 0
end

-- 가용 재고 1 감소
redis.call('DECR', KEYS[1])

-- hold 키 생성 (TTL 포함)
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[1])

-- 예약 중 카운트 1 증가
redis.call('INCR', KEYS[3])

return 1

흐름은 단순하다.

  1. 가용 재고(stock:default)를 조회한다
  2. 재고가 0 이하면 즉시 0을 반환한다 (예약 실패)
  3. 재고가 있으면 DECR로 차감하고, hold 키를 TTL과 함께 생성하고, holdCount를 올린다
  4. 1을 반환한다 (예약 성공)

이 세 가지 명령어(DECR, SET, INCR)가 Lua 스크립트 안에서 원자적으로 실행된다. Thread A가 재고를 읽고 Thread B가 끼어드는 일이 없다.

Kotlin에서는 이렇게 호출한다.

fun reserve(orderId: UUID, ttlSeconds: Long): UUID? {
    val reservationId = UUID.randomUUID()
    val holdValue = objectMapper.writeValueAsString(
        mapOf("orderId" to orderId.toString())
    )

    val result = redisTemplate.execute(
        reserveScript,
        listOf(stockKey(), holdKey(reservationId), holdCountKey()),
        ttlSeconds.toString(),
        holdValue
    ) ?: 0L

    return if (result == 1L) reservationId else null
}

성공하면 reservationId를 반환한다. 이 UUID가 이후 commit()release()에서 hold 키를 찾는 데 사용된다. 재고 부족이면 null을 반환한다.


commit.lua — 재고 확정

-- KEYS[1] = holdKey
-- KEYS[2] = holdCountKey

if redis.call('EXISTS', KEYS[1]) == 1 then
    redis.call('DEL', KEYS[1])
    redis.call('DECR', KEYS[2])
    return 1
end

return 0

결제가 완료되면 호출된다.

  1. hold 키가 존재하는지 확인한다
  2. 존재하면 hold 키를 삭제하고, holdCount를 줄인다
  3. 가용 재고(stock:default)는 건드리지 않는다. reserve()에서 이미 차감했다.

hold 키가 없으면 0을 반환한다. TTL이 만료되어 이미 키가 사라진 경우다. 이 케이스는 OrderExpireScheduler가 이미 release()를 호출했을 가능성이 높다.


release.lua — 재고 해제

-- KEYS[1] = stockKey
-- KEYS[2] = holdKey
-- KEYS[3] = holdCountKey

if redis.call('EXISTS', KEYS[2]) == 1 then
    redis.call('DEL', KEYS[2])

    -- 가용 재고 복구
    redis.call('INCR', KEYS[1])

    -- 예약 중 카운트 감소
    redis.call('DECR', KEYS[3])

    return 1
end

return 0

결제 실패 또는 주문 만료 시 호출된다.

  1. hold 키가 존재하는지 확인한다
  2. 존재하면 hold 키를 삭제하고, 가용 재고를 복구하고, holdCount를 줄인다

commit()과의 차이는 INCR KEYS[1]이 있느냐 없느냐다. 해제는 재고를 돌려줘야 하기 때문이다.

hold 키가 없으면 아무것도 하지 않는다. 이미 TTL이 만료됐거나 이중 호출된 경우다. INCR을 조건 없이 실행하면 재고가 잘못 증가할 수 있기 때문에 반드시 EXISTS 체크가 선행되어야 한다.


Kotlin 서비스 코드

@Service
class InventoryReservationService(
    private val redisTemplate: StringRedisTemplate,
    private val objectMapper: ObjectMapper
) {
    private val reserveScript = DefaultRedisScript<Long>().apply {
        resultType = Long::class.java
        setScriptSource(ResourceScriptSource(ClassPathResource("lua/reserve.lua")))
    }
    private val commitScript = DefaultRedisScript<Long>().apply {
        resultType = Long::class.java
        setScriptSource(ResourceScriptSource(ClassPathResource("lua/commit.lua")))
    }
    private val releaseScript = DefaultRedisScript<Long>().apply {
        resultType = Long::class.java
        setScriptSource(ResourceScriptSource(ClassPathResource("lua/release.lua")))
    }

    private fun stockKey() = "stock:default"
    private fun holdCountKey() = "holdCount:default"
    private fun holdKey(reservationId: UUID) = "hold:$reservationId"

    fun reserve(orderId: UUID, ttlSeconds: Long): UUID? { ... }
    fun commit(reservationId: UUID) { ... }
    fun release(reservationId: UUID) { ... }
}

Lua 스크립트를 ClassPathResource로 로딩한다. 스크립트를 코드 안에 문자열로 박지 않고 별도 파일로 관리했다. 수정할 때 코드를 건드리지 않아도 된다.

DefaultRedisScript는 스크립트를 한 번 로딩 후 SHA로 캐싱한다. 매번 전체 스크립트를 전송하지 않고 SHA만 보내서 네트워크 오버헤드를 줄인다.


전체 흐름 — 주문부터 배송까지

[주문 생성]
OrdersService.orders()
  └── reserve(orderId, ttl=10분)
        ├── 성공: reservationId 반환 → 주문 저장 → ORDER_RESERVED 이벤트 발행
        └── 실패: InsufficientInventoryException → 주문 전체 롤백

[결제 완료]
OrdersService.handlePaymentCompleted()
  └── commit(reservationId)
        └── hold 키 삭제, holdCount 감소 → 주문 CONFIRMED

[결제 실패]
OrdersService.handlePaymentFailed()
  └── release(reservationId)
        └── hold 키 삭제, 재고 복구 → 주문 CANCELED

[주문 만료 - 10분 경과]
OrderExpireScheduler.expireReservedOrders()
  └── release(reservationId)
        └── hold 키 삭제, 재고 복구 → 주문 EXPIRED

동시성 테스트로 검증

Testcontainers로 실제 Redis를 띄워서 검증했다.

@Test
@DisplayName("200개 동시 요청 - 재고 100개 (경쟁 상황)")
fun `should handle 200 concurrent orders with 100 stock`() {
    // Given: 재고 100개, 요청 200개
    redisTemplate.opsForValue().set("stock:default", "100")

    val threadCount = 200
    val successCount = AtomicInteger(0)
    val failureCount = AtomicInteger(0)
    val executor = Executors.newFixedThreadPool(threadCount)
    val latch = CountDownLatch(threadCount)

    repeat(threadCount) {
        executor.submit {
            try {
                ordersService.orders(listOf(OrdersRequest(...)))
                successCount.incrementAndGet()
            } catch (e: Exception) {
                failureCount.incrementAndGet()
            } finally {
                latch.countDown()
            }
        }
    }

    latch.await()

    // Then: 정확히 100개만 성공
    assertEquals(100, successCount.get())
    assertEquals(100, failureCount.get())

    // 재고가 정확히 0이어야 함 (오버셀링 없음)
    val remainingStock = redisTemplate.opsForValue().get("stock:default")?.toLong()
    assertEquals(0L, remainingStock)
}

200개 동시 요청에서 재고 100개만큼 정확히 성공하고, 나머지 100개는 실패한다. 재고는 정확히 0이 된다. 오버셀링이 없다.


이 구현의 한계

hold TTL 만료와 스케줄러의 타이밍 문제

hold 키가 TTL로 만료되면 Redis에서는 사라진다. 그런데 스케줄러가 만료된 주문을 감지하고 release()를 호출하는 사이에 타이밍 차이가 있다.

t=0  : hold 키 TTL 만료 (Redis에서 키 사라짐)
t=5s : 스케줄러 실행 → release() 호출
       → EXISTS 체크: 키 없음 → 아무것도 안 함 (재고 복구 안 됨!)

hold 키가 TTL로 만료된 경우, 가용 재고(stock:default)는 자동으로 복구되지 않는다. release.luaEXISTS 체크 후 INCR을 하기 때문에, 키가 없으면 재고를 복구하지 않는다.

단일 Redis 인스턴스 의존

Redis가 다운되면 재고 예약이 불가능하다. 운영 환경에서는 Redis Sentinel이나 Redis Cluster로 고가용성을 확보해야 한다.

상품 단위 재고 미지원

이 구현은 stock:default 하나로 모든 재고를 관리한다. 실제 커머스에서는 상품별, 옵션별로 재고가 분리되어야 한다. stock:{productId}:{optionId} 형태로 키를 확장하면 된다.


정리

방식동시성 보장성능복잡도
DB 비관적 락낮음 (락 대기)낮음
DB 낙관적 락중간 (재시도 필요)중간
Redis Lua높음중간

Redis Lua를 선택한 핵심 이유는 두 가지다. 여러 Redis 명령어를 원자적으로 묶을 수 있다는 것, 그리고 DB 락 없이 빠르게 처리할 수 있다는 것이다.

재고 예약 → 결제 완료/실패 → 확정/해제라는 3단계 구조 덕분에 결제가 진행되는 동안 재고를 안전하게 "잡아두면서" 오버셀링을 막을 수 있었다.


다음 글: 주문 만료 처리 — 낙관적 락으로 스케줄러와 이벤트 핸들러 충돌 막기

profile
벨로그 좋은것만 드려요

0개의 댓글