[FEATURE] 주문 동시성 부하테스트와 Redis 선차감 개선을 추가한다.#17
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
allowIfSubType 누락 + FAIL_ON_UNKNOWN_PROPERTIES 미설정으로 cache ON에서 앱이 다운되던 문제 해결. test 프로파일이 캐시 OFF라 기존 테스트가 못 잡던 결함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadtest 프로파일, k6 시나리오 3종(상품조회/주문/스트레스), 시드, 커스텀 메트릭 3종, Grafana 대시보드 6패널. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
비관적 락 제거(재고 SELECT FOR UPDATE, 포인트 락+UPDATE+이력) → Redis 원자 DECRBY. 주문 트랜잭션 DB 쓰기를 주문 INSERT만 남김. TPS 3.75배, p99 78%↓ (플래그 stock.redis.pre-decrement.enabled로 토글). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request optimizes the order placement hot-path by replacing database pessimistic locks with Redis-based atomic pre-decrement operations for stock and user points. It also integrates custom Micrometer metrics, Grafana dashboards, and k6 load-testing infrastructure. While these changes significantly improve throughput, several critical issues must be addressed to ensure system reliability. Specifically, cold cache misses on Redis decrements for points and stock can cause false failures, and a lack of transaction synchronization means Redis state will not roll back if database transactions fail. Additionally, returning null from point usage risks NullPointerExceptions, and failure metrics are currently not being recorded in the order facade.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| public boolean tryUse(String userId, long amount) { | ||
| Long remain = redis.opsForValue().decrement(key(userId), amount); | ||
| if (remain == null) { | ||
| return false; | ||
| } | ||
| if (remain < 0) { | ||
| redis.opsForValue().increment(key(userId), amount); | ||
| return false; | ||
| } | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Redis에 해당 사용자의 포인트 잔액 키(point:balance:userId)가 존재하지 않는 상태(Cold Cache)에서 decrement를 호출하면, Redis는 기본값 0에서 차감을 시작하여 음수(-amount)를 반환하게 됩니다. 이로 인해 실제 DB에는 잔액이 충분히 있음에도 불구하고 항상 "포인트가 부족합니다." 예외가 발생하며 결제가 실패하게 됩니다.\n\n재고(Stock)와 달리 포인트는 모든 사용자의 데이터를 미리 워밍업하는 것이 불가능하므로, Redis에 키가 없을 경우 DB에서 잔액을 조회하여 Redis에 적재(Load-on-demand)하는 Cache-Aside 패턴이나, Lua 스크립트를 활용해 키 존재 여부를 확인하고 처리하는 로직이 반드시 추가되어야 합니다.
| private List<Product> deductWithRedis(List<StockDeductionCommand> commands) { | ||
| // 1) Redis 원자 선차감 (실패 시 이미 차감한 항목 롤백) | ||
| List<StockDeductionCommand> deducted = new ArrayList<>(); | ||
| for (StockDeductionCommand command : commands) { | ||
| if (!redisStockService.tryDeduct(command.productId(), command.quantity())) { | ||
| for (StockDeductionCommand done : deducted) { | ||
| redisStockService.restore(done.productId(), done.quantity()); | ||
| } | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); | ||
| } | ||
| deducted.add(command); | ||
| } | ||
|
|
||
| // 2) 재고의 진실 원천은 Redis. DB는 차감하지 않아(주문 트랜잭션에서 재고 UPDATE row lock 제거), | ||
| // 주문 항목 생성에 필요한 상품 정보만 락 없이 조회한다. | ||
| List<Long> ids = commands.stream() | ||
| .map(StockDeductionCommand::productId).sorted().distinct().toList(); | ||
| List<Product> products = productService.getProductsByIds(ids); | ||
| Map<Long, Product> productMap = products.stream() | ||
| .collect(Collectors.toMap(Product::getId, Function.identity())); | ||
| for (StockDeductionCommand command : commands) { | ||
| if (productMap.get(command.productId()) == null) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); | ||
| } | ||
| } | ||
| return products; | ||
| } |
There was a problem hiding this comment.
Redis 선차감(1단계)이 성공한 이후, 2단계(상품 정보 조회 및 검증) 과정에서 예외(예: NOT_FOUND 에러 또는 DB 커넥션 타임아웃 등)가 발생하면, 이미 차감된 Redis 재고가 복구(롤백)되지 않고 그대로 유실되는 심각한 정합성 문제가 발생할 수 있습니다.\n\n따라서 2단계 비즈니스 로직을 try-catch 블록으로 감싸고, 예외 발생 시 이미 차감된 Redis 재고를 다시 restore 해주는 예외 처리 코드가 필요합니다.
private List<Product> deductWithRedis(List<StockDeductionCommand> commands) {
// 1) Redis 원자 선차감 (실패 시 이미 차감한 항목 롤백)
List<StockDeductionCommand> deducted = new ArrayList<>();
for (StockDeductionCommand command : commands) {
if (!redisStockService.tryDeduct(command.productId(), command.quantity())) {
for (StockDeductionCommand done : deducted) {
redisStockService.restore(done.productId(), done.quantity());
}
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
deducted.add(command);
}
try {
// 2) 재고의 진실 원천은 Redis. DB는 차감하지 않아(주문 트랜잭션에서 재고 UPDATE row lock 제거),
// 주문 항목 생성에 필요한 상품 정보만 락 없이 조회한다.
List<Long> ids = commands.stream()
.map(StockDeductionCommand::productId).sorted().distinct().toList();
List<Product> products = productService.getProductsByIds(ids);
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
for (StockDeductionCommand command : commands) {
if (productMap.get(command.productId()) == null) {
throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.");
}
}
return products;
} catch (Throwable t) {
for (StockDeductionCommand done : deducted) {
redisStockService.restore(done.productId(), done.quantity());
}
throw t;
}
}| // 1) Redis 원자 선차감 (실패 시 이미 차감한 항목 롤백) | ||
| List<StockDeductionCommand> deducted = new ArrayList<>(); | ||
| for (StockDeductionCommand command : commands) { | ||
| if (!redisStockService.tryDeduct(command.productId(), command.quantity())) { | ||
| for (StockDeductionCommand done : deducted) { | ||
| redisStockService.restore(done.productId(), done.quantity()); | ||
| } | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); | ||
| } | ||
| deducted.add(command); | ||
| } |
There was a problem hiding this comment.
deductStock 메서드가 성공적으로 완료된 이후, 주문 트랜잭션(OrderFacade.placeOrder)의 후속 단계(예: 주문 저장, 쿠폰 적용, 이벤트 발행 등)에서 예외가 발생하여 DB 트랜잭션이 롤백되는 경우, Redis에서 이미 선차감된 재고는 자동으로 롤백되지 않습니다.\n\nSpring의 TransactionSynchronizationManager를 사용하여 트랜잭션이 롤백될 때 Redis 재고를 자동으로 복구하도록 동기화 리스너를 등록하는 것이 안전합니다.
// 1) Redis 원자 선차감 (실패 시 이미 차감한 항목 롤백)
List<StockDeductionCommand> deducted = new ArrayList<>();
for (StockDeductionCommand command : commands) {
if (!redisStockService.tryDeduct(command.productId(), command.quantity())) {
for (StockDeductionCommand done : deducted) {
redisStockService.restore(done.productId(), done.quantity());
}
throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다.");
}
deducted.add(command);
}
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
new org.springframework.transaction.support.TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
for (StockDeductionCommand command : commands) {
redisStockService.restore(command.productId(), command.quantity());
}
}
}
}
);| public Point usePoint(String userId, Long amount) { | ||
| // [After] 포인트 비관적 락 + DB UPDATE + 이력 INSERT 제거 → Redis 원자 차감만 | ||
| if (preDecrementEnabled) { | ||
| if (!redisPointService.tryUse(userId, amount)) { | ||
| throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); | ||
| } | ||
| return null; | ||
| } | ||
| return updatePointAndLog(userId, amount, PointHistoryType.USE, Point::use); | ||
| } |
There was a problem hiding this comment.
두 가지 개선이 필요합니다:\n1. 트랜잭션 롤백 시 Redis 포인트 복구: usePoint가 성공한 후 DB 트랜잭션이 롤백되면 Redis 포인트만 차감된 상태로 남게 됩니다. TransactionSynchronizationManager를 등록하여 트랜잭션 롤백 시 Redis 포인트를 복구(restore)해야 합니다.\n2. null 반환 위험: usePoint 메서드가 null을 반환하면, 이 메서드의 반환값을 사용하는 상위 호출부에서 NullPointerException이 발생할 위험이 큽니다. 비록 선차감 모드라 하더라도 더미 Point 객체를 반환하거나 상위 호출부의 널 안정성을 보장할 수 있는 구조로 개선하는 것이 좋습니다.
@Transactional
public Point usePoint(String userId, Long amount) {
// [After] 포인트 비관적 락 + DB UPDATE + 이력 INSERT 제거 → Redis 원자 차감만
if (preDecrementEnabled) {
if (!redisPointService.tryUse(userId, amount)) {
throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다.");
}
org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(
new org.springframework.transaction.support.TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
redisPointService.restore(userId, amount);
}
}
}
);
return null;
}
return updatePointAndLog(userId, amount, PointHistoryType.USE, Point::use);
}| public boolean tryDeduct(long productId, long qty) { | ||
| Long remain = redis.opsForValue().decrement(key(productId), qty); | ||
| if (remain == null) { | ||
| return false; | ||
| } | ||
| if (remain < 0) { | ||
| redis.opsForValue().increment(key(productId), qty); // 롤백 | ||
| return false; | ||
| } | ||
| return true; | ||
| } |
There was a problem hiding this comment.
tryDeduct 메서드 역시 Redis에 해당 상품의 재고 키가 존재하지 않을 경우, decrement 호출 시 0에서 차감되어 음수가 되고 결국 실패하게 됩니다. 부하테스트 시나리오에서는 수동으로 SET stock:1..10 100000을 실행하여 예방하고 있으나, 실제 운영 환경에서 새로운 상품이 등록되거나 Redis 캐시가 유실/재기동되는 경우 모든 재고 차감이 실패하게 됩니다.\n\n키가 존재하지 않을 때 DB에서 실제 재고를 조회하여 Redis에 적재하는 Fallback/On-demand 로직을 구현하거나, Lua 스크립트를 통해 안전하게 처리할 것을 권장합니다.
| orderMetrics.orderSuccess(); | ||
| return OrderInfo.from(savedOrder); |
Background
develop의 주문 핫패스(
POST /api/v1/orders)는 재고와 포인트를 모두 비관적 락(SELECT ... FOR UPDATE)으로 처리한다. 인기 상품에 주문이 몰리면 락이 직렬화되어 HikariCP 커넥션 풀(40)이 포화되고 처리량이 급감하는지 측정으로 확인이 필요했다. 또한 부하 측정을 준비하는 과정에서, 캐시 ON 환경(dev/qa/prd 프로파일)에서 앱이 다운되는 Redis 직렬화 버그 등 test 프로파일(캐시 OFF)이 잡지 못한 결함들이 드러났다.Summary
k6 기반 부하/스트레스 테스트 인프라를 구축하고, 측정으로 병목을 추적해 재고·포인트를 Redis 원자 연산으로 전환하고 주문 트랜잭션의 DB 쓰기를 주문 INSERT만 남기도록 축소했다. 그 결과 주문 TPS가 3.75배(21.5→80.5), p99가 78% 감소(5.37s→1.19s)했다. 재고만 Redis로 바꾼 1차 시도가 효과 없자, 측정을 통해 포인트 비관적 락과 DB UPDATE row lock이 진짜 병목임을 규명하고 2차에서 해결했다. 부수적으로 발견한 캐시 직렬화 버그와 JDK 21 toolchain 문제도 함께 수정했다.
Changes
재고·포인트 선차감은
RedisStockService/RedisPointService(DECRBY+롤백)를 도입하고StockDeductionService·PointService에stock.redis.pre-decrement.enabled플래그로 Before/After를 토글할 수 있게 분기했다. 선차감 모드에서는 DB 락·UPDATE·이력 INSERT를 제거하고 재고/포인트 정합성의 진실 원천을 Redis로 둔다. 캐시 버그는RedisCacheConfig의PolymorphicTypeValidator에allowIfSubType을 추가하고FAIL_ON_UNKNOWN_PROPERTIES를 비활성화해 해결했다. 측정 인프라로 loadtest 프로파일, k6 시나리오 3종, 커스텀 메트릭(OrderMetrics), Grafana 대시보드 6패널을 추가했고, 결과 문서·비교 차트·실행 가이드를 함께 제공한다. JDK 21 자동 프로비저닝을 위해settings.gradle.kts에 foojay 플러그인을 추가했다.Checklist