Redis 재고 차감과 Redisson 락 적용에서 배운 점
상품 주문 요청이 한꺼번에 몰리면 같은 상품의 재고 행을 여러 요청이 동시에 읽고 수정한다. PopcornTalk 프로젝트에서는 이 경합을 줄이려고 상품별 수량을 Redis Hash에 올려두고, 구매가 성공할 때 Redis에서 먼저 차감하는 방법을 사용했다. 재고가 0이 되면 Redis 키를 지우고 데이터베이스의 수량을 0으로 바꾼 뒤 상품을 soft delete 처리하는 흐름이었다.
그 뒤에는 상품 정보 수정과 구매 요청이 겹칠 때 데이터가 어긋나는 문제를 다루기 위해 Redisson RLock도 적용했다. 두 글을 다시 읽으며 확인한 건, Redis 카운터와 분산 락은 각각 다른 경합 문제를 줄이는 도구라는 점이다. 둘을 붙였다고 주문, 결제, DB 재고가 하나의 트랜잭션처럼 움직이지는 않는다.
키 확인과 감소 사이에 생기는 경쟁 조건
처음 구현한 형태는 대략 “Redis 키가 있는지 확인하고, 있으면 수량을 하나 줄인다”는 순서였다. 두 요청이 거의 동시에 들어오면 둘 다 키가 있다고 확인한 다음 각자 감소 명령을 실행할 수 있다. 확인과 변경이 두 번의 독립된 명령이기 때문이다. 재고가 한 개 남았을 때 이 틈이 생기면 마지막 한 개를 두 주문이 모두 확보했다고 판단할 수 있다.
Redis Lua 스크립트는 서버에서 실행되는 동안 다른 명령이 끼어들지 않는다. 수량 확인과 감소를 한 스크립트 안에 묶으면 Redis 카운터의 음수 차감을 막을 수 있다.
local quantity = tonumber(redis.call('HGET', KEYS[1], 'amount'))
if not quantity then
return -1 -- 재고 키가 아직 준비되지 않음
end
if quantity <= 0 then
return 0 -- 품절
end
return redis.call('HINCRBY', KEYS[1], 'amount', -1)
여기서 -1, 0, 양수는 애플리케이션이 구분할 반환값의 예다. 이 스크립트가 원자적으로 묶는 범위는 Redis 안의 명령뿐이다. 주문 행 저장이나 결제 승인까지 함께 성공하거나 실패하게 만들지는 않는다.
또한 수량이 0이 되자마자 캐시 키를 없애면 다음 요청은 “캐시 미스”로 처리될 수 있다. 캐시 미스 처리기가 DB의 재고를 다시 읽어 같은 키를 채우는 구조라면, 품절 상태와 캐시 재생성 순서를 신중하게 관리해야 한다. 품절을 의미하는 0을 잠시 유지하거나, DB의 판매 상태를 확인하는 흐름을 별도로 두는 편이 명확하다.
재고의 기준 데이터와 예약 흐름
설계에서 먼저 결정할 것은 재고의 원장이다. 재고 손실이 허용되지 않는 주문이라면 DB 트랜잭션 안에서 조건부 감소를 수행하는 방식이 단순하고 이해하기 쉽다. 예를 들어 UPDATE product SET quantity = quantity - 1 WHERE id = ? AND quantity > 0의 영향 행 수로 재고 확보 성공 여부를 판단할 수 있다. 같은 트랜잭션에서 주문을 기록하면 DB가 보장하는 정합성 경계를 활용할 수 있다.
Redis로 예약 카운터를 운영하면 DB의 핫스폿을 줄일 수 있지만, 예약부터 결제 완료까지 상태 전이가 늘어난다. 구매 예약이 성공한 뒤 결제가 실패할 수 있고, 애플리케이션이 예약과 주문 기록 사이에서 종료될 수도 있다. 그래서 예약 식별자, 만료 시각, 취소·환불 때 수량 복구, 중복 주문 방지, Redis와 DB의 주기적 대사가 함께 필요하다. 이 복구 경로를 만들지 않는다면 Redis를 최종 재고 원장으로 취급하는 것은 위험하다.
Redisson lease와 잠금 범위
두 번째 글에서는 상품 ID를 잠금 키로 사용하고 AOP에서 tryLock을 호출했다. 원문 코드에는 대기 시간 60초, lease time 4초가 적혀 있었다. lease time은 잠금이 자동 해제되는 시간이다. 처리 시간이 4초를 넘기거나 JVM이 잠시 멈춘 사이 lease가 끝나면, 첫 요청의 코드가 아직 진행 중인데 두 번째 요청이 같은 상품 잠금을 획득할 수 있다.
Redisson은 lease time을 직접 지정하지 않은 RLock에 watchdog을 사용해 잠금 만료를 연장한다. 반대로 lease를 지정하면 지정한 시간이 지나 잠금이 풀린다. 따라서 “짧게 잡으면 안전하고 빠르다”처럼 숫자만 바꿔서는 해결되지 않는다. DB 트랜잭션 commit까지 잠금이 유지되는지, 최악의 처리 시간과 JVM 정지 상황은 어떤지 함께 봐야 한다. 잠금 만료 뒤 늦게 살아난 이전 작업까지 막아야 하는 외부 자원에는, 자원 자체가 fencing token을 검증하는 설계도 고려할 수 있다.
잠금 범위를 상품별로 한정하면 서로 다른 상품 주문은 병렬 처리할 수 있다. 모든 상품을 하나의 잠금으로 묶으면 정확성을 얻는 대신 처리량을 크게 잃는다. 그리고 같은 상품을 수정하는 관리자 작업과 주문 경로가 서로 다른 잠금 키를 사용한다면 두 작업은 여전히 동시에 실행될 수 있다. 잠금 이름과 획득 순서도 코드 전반에서 일관되어야 한다.
성능 최적화 뒤에 복구 경로를 남기기
Redis는 재고를 빠르게 읽고 갱신하는 방법이고, Redisson은 여러 애플리케이션 인스턴스가 특정 구간에 동시에 들어오지 않도록 조정하는 방법이다. 정확성을 맡길 저장소와 주문 상태 전이를 먼저 정한 뒤 두 도구가 어디까지 책임지는지 나누면 설계가 훨씬 선명해진다.
제가 당시 구현에서 배운 점은 “Redis로 옮기면 DB 부하가 줄어든다”에서 멈추면 안 된다는 것이었다. 결제 취소, 서버 재시작, 키 만료, 중복 요청, 잠금 시간 초과 후 재진입 같은 실패 경로를 직접 따라가야 한다. 정상 구매 한 번보다 실패 후 재고가 어떻게 복구되는지 설명할 수 있어야 운영 가능한 재고 시스템에 가까워진다.
읽기 캐시와 재고 예약은 목적과 복구 기준이 다르다. 캐시의 만료와 무효화 사례는 공지 조회에 Redis 캐시를 적용한 기록에 정리했다.
참고 문서: Redis Lua scripting 원자성, Redisson Locks and Synchronizers
당시 기록: Redis 재고 감소, Redisson 분산 락