Skip to content

[FEATURE] 주문 동시성 부하테스트와 Redis 선차감 개선을 추가한다.#17

Open
rnqhstmd wants to merge 5 commits into
developfrom
feat/load-test-infra
Open

[FEATURE] 주문 동시성 부하테스트와 Redis 선차감 개선을 추가한다.#17
rnqhstmd wants to merge 5 commits into
developfrom
feat/load-test-infra

Conversation

@rnqhstmd

@rnqhstmd rnqhstmd commented Jun 5, 2026

Copy link
Copy Markdown
Owner

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·PointServicestock.redis.pre-decrement.enabled 플래그로 Before/After를 토글할 수 있게 분기했다. 선차감 모드에서는 DB 락·UPDATE·이력 INSERT를 제거하고 재고/포인트 정합성의 진실 원천을 Redis로 둔다. 캐시 버그는 RedisCacheConfigPolymorphicTypeValidatorallowIfSubType을 추가하고 FAIL_ON_UNKNOWN_PROPERTIES를 비활성화해 해결했다. 측정 인프라로 loadtest 프로파일, k6 시나리오 3종, 커스텀 메트릭(OrderMetrics), Grafana 대시보드 6패널을 추가했고, 결과 문서·비교 차트·실행 가이드를 함께 제공한다. JDK 21 자동 프로비저닝을 위해 settings.gradle.kts에 foojay 플러그인을 추가했다.

Checklist

  • 주요 기능이 로컬에서 정상 동작하는지 확인
  • 기존 테스트가 통과하는지 확인
  • 선차감 모드의 재고/포인트 → DB 동기화(Outbox/배치)는 후속 작업으로 필요
  • 부하테스트 설정(VU/프로파일/환경 제약)이 문서화되었는지 확인

bs-koo and others added 5 commits June 5, 2026 15:49
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +27 to +37
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Redis에 해당 사용자의 포인트 잔액 키(point:balance:userId)가 존재하지 않는 상태(Cold Cache)에서 decrement를 호출하면, Redis는 기본값 0에서 차감을 시작하여 음수(-amount)를 반환하게 됩니다. 이로 인해 실제 DB에는 잔액이 충분히 있음에도 불구하고 항상 "포인트가 부족합니다." 예외가 발생하며 결제가 실패하게 됩니다.\n\n재고(Stock)와 달리 포인트는 모든 사용자의 데이터를 미리 워밍업하는 것이 불가능하므로, Redis에 키가 없을 경우 DB에서 잔액을 조회하여 Redis에 적재(Load-on-demand)하는 Cache-Aside 패턴이나, Lua 스크립트를 활용해 키 존재 여부를 확인하고 처리하는 로직이 반드시 추가되어야 합니다.

Comment on lines +69 to 95
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
        }
    }

Comment on lines +70 to +80
// 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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());
                        }
                    }
                }
            }
        );

Comment on lines 54 to 63
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

두 가지 개선이 필요합니다:\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);
    }

Comment on lines +28 to +38
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

tryDeduct 메서드 역시 Redis에 해당 상품의 재고 키가 존재하지 않을 경우, decrement 호출 시 0에서 차감되어 음수가 되고 결국 실패하게 됩니다. 부하테스트 시나리오에서는 수동으로 SET stock:1..10 100000을 실행하여 예방하고 있으나, 실제 운영 환경에서 새로운 상품이 등록되거나 Redis 캐시가 유실/재기동되는 경우 모든 재고 차감이 실패하게 됩니다.\n\n키가 존재하지 않을 때 DB에서 실제 재고를 조회하여 Redis에 적재하는 Fallback/On-demand 로직을 구현하거나, Lua 스크립트를 통해 안전하게 처리할 것을 권장합니다.

Comment on lines +104 to 105
orderMetrics.orderSuccess();
return OrderInfo.from(savedOrder);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

OrderMetrics에 정의된 orderFail()stockShortage() 메트릭이 실제 애플리케이션 코드 내에서 전혀 호출되지 않고 있습니다. 이로 인해 주문 실패나 재고 부족 상황이 발생하더라도 Prometheus 메트릭이 증가하지 않으며, Grafana 대시보드의 관련 패널들이 항상 0으로 표시되는 문제가 발생합니다.\n\nplaceOrder 메서드 내에서 예외 발생 시 적절히 메트릭을 증가시키도록 예외 처리 로직을 추가해 주세요.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Test Results

317 tests  +3   310 ✅ +3   51s ⏱️ ±0s
 86 suites +1     7 💤 ±0 
 86 files   +1     0 ❌ ±0 

Results for commit 530b652. ± Comparison against base commit 61bd186.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants