✅ 구현 코드

▶️ 서비스 로직 전/후 비교

// 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 개수별 동시성 처리 전, 후 데이터 비교

🟥 동시성 처리 전

🟩 동시성 처리 후