동시성 이슈가 발생한 이유(Repeatable Read 환경에서의 Lost Update/경합)와, 이를 해결하기 위해 시도한 4가지 접근(격리수준/직렬화/비관적 락/Redis 단일스레드) 및 RabbitMQ 기반 RDB 동기화까지, 지금 적어둔 내용을 누락 없이 한 흐름으로 정리하고, Redis 재고 조회+차감의 원자성 깨짐을 해결하는 Lua 스크립트(및 Spring 적용 코드)까지 같이 정리한다.
REPEATABLE READ 격리수준 상황에서 동시 주문이 들어오면, “재고는 일부만 차감됐는데 주문 insert는 더 많이 된” 형태의 불일치가 발생할 수 있다
(예: 재고 100개에서 23개만 차감, 주문은 47개 insert).
![]()
(예: 재고 100에서 32만 차감되고 주문도 32만 들어갔지만, 재고가 남아도 에러가 터져 주문이 더 못 들어가는 상황).
@Transactional(isolation = Isolation.SERIALIZABLE)로 격리수준 올리기SERIALIZABLE은 “논리적으로 직렬 실행되도록 강제”하는 격리수준이고, 동시성 문제를 근본적으로 차단하려는 접근이다. synchronized를 스프링 메서드에 거는 건 단일 JVM 내부에서만 의미가 있고, 멀티서버 환경에서는 무효가 되며, 애초에 “DB 쿼리 실행 순서”까지 보장해주지 못한다는 한계가 있다. @Transactional(isolation = Isolation.SERIALIZABLE)을 쓰는 것이다.
![]()
![]()
(예: 재고 100에서 25만 차감/주문 25만 insert, 재고 남아도 에러), 처리량도 낮아질 수 있다(측정 예: Throughput 48.1/sec).
SELECT ... FOR UPDATE / JPA PESSIMISTIC_WRITE로 배타락(비관적 락)findById 같은 공용 조회 메서드에 락을 걸면 “모든 조회”가 락을 타서 성능이 깨질 수 있으니, 락 전용 메서드를 분리하는 설계가 필요하다. 예시:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
(측정 예: Throughput 52.2/sec).
재고 처리 절차(정리한 내용):
(측정 예: Throughput 70.3/sec).

Queue("stockQueue", true) 생성, RabbitTemplate에 Jackson2JsonMessageConverter 사용, @RabbitListener(queues="stockQueue")로 구독 처리. Product 조회 후 product.decreaseStockQuantity()로 RDB 반영한다(이 업데이트는 메시지 단위로 들어와 동시성 충돌이 줄어드는 방향). 현재 코드는 아래처럼 get으로 조회하고, 조건 검사 후 decrement를 별도 호출한다.
String remainValue = redisTemplate.opsForValue().get(key);
int remainQuantity = Integer.parseInt(remainValue);
if (remainQuantity < qty) throw ...
else redisTemplate.opsForValue().decrement(key, qty);
GET과 DECRBY 사이에 다른 요청이 끼면(다른 스레드/서버에서), 검증 시점의 값과 차감 시점의 값이 달라져 oversell/음수/불필요한 실패 같은 문제가 생긴다. 아래 스크립트는 “재고 키 1개”에 대해, (1) 값 조회 (2) 부족하면 실패 코드 반환 (3) 충분하면 차감 후 남은 재고 반환을 단일 실행으로 보장한다.
decrement_if_enough.lua-1 -- KEYS [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) = stock key (ex: "123" or "stock:123")
-- ARGV [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html) = decrement quantity
local key = KEYS [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html)
local qty = tonumber(ARGV [docs.spring](https://docs.spring.io/spring-data/redis/reference/redis/scripting.html))
if qty == nil or qty <= 0 then
return -1
end
local current = tonumber(redis.call('GET', key))
if current == nil then
return -1
end
if current < qty then
return -1
end
-- Atomic decrement
local remain = redis.call('DECRBY', key, qty)
return remain
Spring Data Redis는 RedisTemplate.execute(script, keys, args...)로 스크립트를 실행하고, 내부적으로 EVALSHA/EVAL을 관리해준다.
@Bean
public DefaultRedisScript<Long> decreaseStockScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setLocation(new ClassPathResource("scripts/decrement_if_enough.lua"));
script.setResultType(Long.class);
return script;
}
호출부(원래 깨지던 구간 대체):
String key = String.valueOf(itemDto.getProductId());
Long remain = redisTemplate.execute(
decreaseStockScript,
List.of(key),
String.valueOf(qty)
);
if (remain == null) {
throw new IllegalStateException("Redis Lua result is null");
}
if (remain == -1L) {
throw new IllegalArgumentException("재고 부족");
}
// 성공: remain이 차감 후 남은 재고