▶️ 서비스 로직 전/후 비교
// Before: DB 트랜잭션에만 의존 -> 병목 유발 및 동시성 이슈 발생
@Override
@Transactional
public void decreaseStock(Long itemId, int quantity) {
Stock stock = stockRepository.findByItemId(itemId)
.orElseThrow(() -> new BusinessException(NOT_FOUND_STOCK));
stock.decreaseQuantity(quantity);
stockRepository.save(stock);
}
// After: Redis + Lua script 원자 연산으로 데이터 일관성 보장
@Override
@Transactional
public void decreaseStock(Long userId, List<Long> itemIds, Long orderId) {
List<Object> decreasedResult = redisService.decreaseConfirmStocks(List.of(), getArgsForRedis(userId, itemIds));
Map<Long, Integer> qtyMap = getQtyMap(decreasedResult);
try {
for (Map.Entry<Long, Integer> entry : qtyMap.entrySet()) {
Long itemId = entry.getKey();
int qty = entry.getValue();
int updated = stockRepository.decreaseStock(itemId, qty);
if (updated == 0) {
log.error("DB 재고 차감 실패 - itemId: {}, qty: {}", itemId, qty);
throw new BusinessException(ExceptionCode.NOT_ENOUGH_STOCK);
}
}
StockDecreasedDomainEvent event = mapper.toStockDecreasedEvent(orderId);
publisher.publishWithOutboxAfterCommit(event);
} catch (Exception e) {
stockRollbackEventWriter.writeRedisStockRollbackEvent(orderId, qtyMap);
throw e;
}
}
@Slf4j
@Service
public class RedisService {
public List<Object> decreaseConfirmStocks(List<String> keys, String[] args) {
return executeScript(decreaseStockConfirmScript, keys, args);
}
private <T> T executeScript(RedisScript<T> script, List<String> keys, Object... args) {
List<String> strArgs = Stream.of(args)
.map(Object::toString)
.toList();
try {
return luaRedisTemplate.execute(script, keys, strArgs.toArray());
} catch (Exception e) {
log.warn("예외 발생: {}", e.getMessage());
throw new RuntimeException(e);
}
}
}
-- Lua Script
-- ARGV[1]: userId
-- ARGV[2:] = itemIds
local userId = ARGV[1]
local zsetKey = "reserved:ttl"
local result = {}
for i = 2, #ARGV do
local itemId = ARGV[i]
local reservedKey = "reserved:item:" .. itemId
local zsetMember = itemId .. ":" .. userId
local reservedQty = redis.call("HGET", reservedKey, userId)
if reservedQty then
redis.call("HDEL", reservedKey, userId)
redis.call("ZREM", zsetKey, zsetMember)
table.insert(result, itemId)
table.insert(result, reservedQty)
end
end
return result
▶️ 동시성 유효성 테스트 코드
// CONCURRENCY TEST
@DisplayName("재고 동시성 테스트")
@Test
void 동시성_테스트() throws InterruptedException {
int executeCount = 10000;
int numOfThread = 40;
int expectedSuccessCount = 10;
int expectedFailCount = executeCount - expectedSuccessCount;
ExecutorService executorService = Executors.newFixedThreadPool(numOfThread);
CountDownLatch countDownLatch = new CountDownLatch(executeCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
long startTime = System.currentTimeMillis();
for (int i = 0; i < executeCount; i++) {
executorService.submit(() -> {
try {
stockService.decreaseStock(1L, List.of(1L), 1L);
successCount.getAndIncrement();
System.out.println(Thread.currentThread().getName() + " succeeded.");
} catch (Exception e) {
failCount.getAndIncrement();
System.out.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
executorService.shutdown();
long stopTime = System.currentTimeMillis();
long diff = stopTime - startTime;
System.out.printf("""
Thread 개수: %d \n
실행 횟수: %d회 \n
예상 재고 감소량: %d개 \n
실제 재고 감소량: %d개 \n
예상 실패량: %d개 \n
실제 실패량: %d개 \n
테스트 경과 시간: %d ms
""",
numOfThread, executeCount, expectedSuccessCount, successCount.get(),
expectedFailCount, failCount.get(), diff
);
assertAll(
() -> assertThat(successCount.get()).isEqualTo(expectedSuccessCount),
() -> assertThat(failCount.get()).isEqualTo(expectedFailCount)
);
}
🔶 Thread 개수별 동시성 처리 전, 후 데이터 비교
🟥 동시성 처리 전
결과 사진







🟩 동시성 처리 후
DB Persistence Lock 사용 결과






Redis + Lua Script 사용 결과







테스트 결과
| 동시성 제어 전 | DB Lock | Redis + Lua Script | |
|---|---|---|---|
| Thread 1 | 3382 ms | 4891 ms | 8778 ms |
| Thread 2 | 2413 ms | 4563 ms | 4407 ms |
| Thread 5 | 1814 ms | 2237 ms | 2142 ms |
| Thread 10 | 1582 ms | 1795 ms | 1546 ms |
| Thread 20 | 1605 ms | 1812 ms | 1653 ms |
| Thread 40 | 1594 ms | 1803 ms | 1549 ms |