지난글에서 멱등성 처리를 다뤘다. 이번 글은 재고 동시성 문제를 다룬다.
커머스 서비스에서 재고 관리는 동시성 문제가 가장 빈번하게 터지는 영역 중 하나다. 특히 한정 수량 상품에 동시 주문이 몰리면 재고보다 많은 주문이 생기는 오버셀링(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개가 팔린다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findByProductId(productId: UUID): Stock
조회할 때 DB 레코드에 락을 건다. 다른 트랜잭션은 락이 풀릴 때까지 대기한다. 정합성은 완벽하게 보장된다.
단점은 성능이다. 락을 기다리는 트랜잭션이 쌓이면 DB 커넥션이 고갈된다. 트래픽이 몰리는 순간 병목이 생긴다.
@Version
var version: Long = 0
충돌이 발생하면 예외를 던진다. 재시도 로직을 직접 구현해야 한다. 충돌이 적은 환경에서는 효율적이지만, 재고 차감처럼 충돌이 잦은 환경에서는 재시도가 폭발적으로 늘어난다.
Redis의 단일 명령어는 원자적으로 실행된다. DECR 명령어 하나로 조회와 차감을 동시에 처리할 수 있다. DB 락 없이 빠르게 처리된다.
그런데 재고 차감만으로는 부족하다. 이 프로젝트에서는 예약(reserve) → 확정(commit) / 해제(release) 3단계 구조가 필요했다. 결제가 완료되기 전까지는 재고를 "임시로 잡아두는" 상태가 있어야 하기 때문이다.
단순 DECR로는 이 구조를 원자적으로 처리할 수 없다. 여러 명령어를 원자적으로 묶어야 한다. 그래서 Lua 스크립트를 선택했다.
Redis는 Lua 스크립트를 원자적으로 실행한다. 스크립트가 실행되는 동안 다른 Redis 명령어는 끼어들 수 없다. 여러 Redis 명령어를 하나의 원자적 연산처럼 묶을 수 있다.
MULTI / EXEC 트랜잭션으로도 원자성을 보장할 수 있지만, 조건 분기(if stock > 0)를 트랜잭션 안에서 처리하려면 WATCH를 써야 하고 구현이 복잡해진다. Lua는 조건 분기와 여러 명령어를 하나의 스크립트로 깔끔하게 표현할 수 있다.
재고 흐름을 먼저 정리했다.
[정상 흐름]
주문 생성 → 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분으로 맞춰두었지만, 이건 스케줄러가 놓친 경우를 대비한 보조 안전장치다.
-- 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
흐름은 단순하다.
stock:default)를 조회한다0을 반환한다 (예약 실패)DECR로 차감하고, hold 키를 TTL과 함께 생성하고, holdCount를 올린다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을 반환한다.
-- 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
결제가 완료되면 호출된다.
stock:default)는 건드리지 않는다. reserve()에서 이미 차감했다.hold 키가 없으면 0을 반환한다. TTL이 만료되어 이미 키가 사라진 경우다. 이 케이스는 OrderExpireScheduler가 이미 release()를 호출했을 가능성이 높다.
-- 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
결제 실패 또는 주문 만료 시 호출된다.
commit()과의 차이는 INCR KEYS[1]이 있느냐 없느냐다. 해제는 재고를 돌려줘야 하기 때문이다.
hold 키가 없으면 아무것도 하지 않는다. 이미 TTL이 만료됐거나 이중 호출된 경우다. INCR을 조건 없이 실행하면 재고가 잘못 증가할 수 있기 때문에 반드시 EXISTS 체크가 선행되어야 한다.
@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.lua가 EXISTS 체크 후 INCR을 하기 때문에, 키가 없으면 재고를 복구하지 않는다.
단일 Redis 인스턴스 의존
Redis가 다운되면 재고 예약이 불가능하다. 운영 환경에서는 Redis Sentinel이나 Redis Cluster로 고가용성을 확보해야 한다.
상품 단위 재고 미지원
이 구현은 stock:default 하나로 모든 재고를 관리한다. 실제 커머스에서는 상품별, 옵션별로 재고가 분리되어야 한다. stock:{productId}:{optionId} 형태로 키를 확장하면 된다.
| 방식 | 동시성 보장 | 성능 | 복잡도 |
|---|---|---|---|
| DB 비관적 락 | ✅ | 낮음 (락 대기) | 낮음 |
| DB 낙관적 락 | ✅ | 중간 (재시도 필요) | 중간 |
| Redis Lua | ✅ | 높음 | 중간 |
Redis Lua를 선택한 핵심 이유는 두 가지다. 여러 Redis 명령어를 원자적으로 묶을 수 있다는 것, 그리고 DB 락 없이 빠르게 처리할 수 있다는 것이다.
재고 예약 → 결제 완료/실패 → 확정/해제라는 3단계 구조 덕분에 결제가 진행되는 동안 재고를 안전하게 "잡아두면서" 오버셀링을 막을 수 있었다.
다음 글: 주문 만료 처리 — 낙관적 락으로 스케줄러와 이벤트 핸들러 충돌 막기