-
Notifications
You must be signed in to change notification settings - Fork 0
[FEATURE] 주문 동시성 부하테스트와 Redis 선차감 개선을 추가한다. #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
848fd2a
61b5d28
3efe7c7
a411f56
530b652
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.loopers.domain.order; | ||
|
|
||
| import io.micrometer.core.instrument.Counter; | ||
| import io.micrometer.core.instrument.MeterRegistry; | ||
| import io.micrometer.core.instrument.Timer; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * 주문 핫패스 커스텀 비즈니스 메트릭. | ||
| * 부하테스트 시 /actuator/prometheus 로 노출되어 Grafana 대시보드에서 관측한다. | ||
| */ | ||
| @Component | ||
| public class OrderMetrics { | ||
|
|
||
| private final Counter orderSuccess; | ||
| private final Counter orderFail; | ||
| private final Counter stockShortage; | ||
| private final Timer stockDeductTimer; | ||
|
|
||
| public OrderMetrics(MeterRegistry registry) { | ||
| this.orderSuccess = Counter.builder("ecommerce.order.success") | ||
| .description("주문 성공 건수").register(registry); | ||
| this.orderFail = Counter.builder("ecommerce.order.fail") | ||
| .description("주문 실패 건수").register(registry); | ||
| this.stockShortage = Counter.builder("ecommerce.order.stock_shortage") | ||
| .description("재고 부족으로 거절된 건수").register(registry); | ||
| this.stockDeductTimer = Timer.builder("ecommerce.order.stock_deduct") | ||
| .description("재고 차감 소요 시간").register(registry); | ||
| } | ||
|
|
||
| public void orderSuccess() { orderSuccess.increment(); } | ||
| public void orderFail() { orderFail.increment(); } | ||
| public void stockShortage() { stockShortage.increment(); } | ||
| public Timer stockDeductTimer() { return stockDeductTimer; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.Pageable; | ||
| import org.springframework.stereotype.Service; | ||
|
|
@@ -15,6 +16,10 @@ public class PointService { | |
|
|
||
| private final PointRepository pointRepository; | ||
| private final PointHistoryRepository pointHistoryRepository; | ||
| private final RedisPointService redisPointService; | ||
|
|
||
| @Value("${stock.redis.pre-decrement.enabled:false}") | ||
| private boolean preDecrementEnabled; | ||
|
|
||
| @Transactional | ||
| public Point createPoint(String userId) { | ||
|
|
@@ -37,11 +42,23 @@ private Point getPointWithLock(String userId) { | |
|
|
||
| @Transactional | ||
| public Point chargePoint(String userId, Long amount) { | ||
| return updatePointAndLog(userId, amount, PointHistoryType.CHARGE, Point::charge); | ||
| Point point = updatePointAndLog(userId, amount, PointHistoryType.CHARGE, Point::charge); | ||
| // 선차감 모드: 주문 핫패스가 Redis 잔액을 차감하므로 충전도 Redis에 반영 | ||
| if (preDecrementEnabled) { | ||
| redisPointService.charge(userId, amount); | ||
| } | ||
| return 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, "포인트가 부족합니다."); | ||
| } | ||
| return null; | ||
| } | ||
| return updatePointAndLog(userId, amount, PointHistoryType.USE, Point::use); | ||
| } | ||
|
Comment on lines
54
to
63
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 두 가지 개선이 필요합니다:\n1. 트랜잭션 롤백 시 Redis 포인트 복구: @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);
} |
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| package com.loopers.domain.point; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| /** | ||
| * Redis 원자 포인트 잔액 서비스 (개선안). | ||
| * 포인트 비관적 락(SELECT ... FOR UPDATE) + UPDATE + 이력 INSERT 대신 | ||
| * Redis DECRBY로 잔액을 원자적으로 차감해 주문 핫패스의 DB 락 경합을 제거한다. | ||
| */ | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class RedisPointService { | ||
|
|
||
| private final StringRedisTemplate redis; | ||
|
|
||
| private static String key(String userId) { | ||
| return "point:balance:" + userId; | ||
| } | ||
|
|
||
| public void charge(String userId, long amount) { | ||
| redis.opsForValue().increment(key(userId), amount); | ||
| } | ||
|
|
||
| /** 원자적 차감: 성공 true, 잔액부족 false(롤백). */ | ||
| 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; | ||
| } | ||
|
Comment on lines
+27
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redis에 해당 사용자의 포인트 잔액 키( |
||
|
|
||
| public void restore(String userId, long amount) { | ||
| redis.opsForValue().increment(key(userId), amount); | ||
| } | ||
|
|
||
| public long current(String userId) { | ||
| String v = redis.opsForValue().get(key(userId)); | ||
| return v == null ? 0 : Long.parseLong(v); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| package com.loopers.domain.product; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| /** | ||
| * Redis 원자 재고 선차감 서비스 (개선안). | ||
| * 비관적 락(SELECT ... FOR UPDATE) 대신 Redis DECRBY로 재고 게이트를 통과시켜 | ||
| * 핫상품 동시 주문 시의 DB 락 경합을 제거한다. | ||
| */ | ||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class RedisStockService { | ||
|
|
||
| private final StringRedisTemplate redis; | ||
|
|
||
| private static String key(long productId) { | ||
| return "stock:" + productId; | ||
| } | ||
|
|
||
| /** MySQL 재고를 Redis로 적재(워밍업). */ | ||
| public void warmUp(long productId, long stock) { | ||
| redis.opsForValue().set(key(productId), String.valueOf(stock)); | ||
| } | ||
|
|
||
| /** 원자적 선차감: 성공 true, 재고부족 false(롤백). */ | ||
| 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; | ||
| } | ||
|
Comment on lines
+28
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| /** 차감 복구(주문 실패/취소 시). */ | ||
| public void restore(long productId, long qty) { | ||
| redis.opsForValue().increment(key(productId), qty); | ||
| } | ||
|
|
||
| public long current(long productId) { | ||
| String v = redis.opsForValue().get(key(productId)); | ||
| return v == null ? 0 : Long.parseLong(v); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,11 +3,12 @@ | |
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Propagation; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.Comparator; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.function.Function; | ||
|
|
@@ -18,14 +19,22 @@ | |
| public class StockDeductionService { | ||
|
|
||
| private final ProductService productService; | ||
| private final RedisStockService redisStockService; | ||
|
|
||
| // 부하테스트 Before/After 토글: false=비관적 락, true=Redis 원자 선차감 | ||
| @Value("${stock.redis.pre-decrement.enabled:false}") | ||
| private boolean preDecrementEnabled; | ||
|
|
||
| /** | ||
| * 재고 차감 도메인 서비스. | ||
| * 반드시 기존 트랜잭션 내에서 호출되어야 합니다 (MANDATORY). | ||
| * 비관적 락은 ProductJpaRepository에서 ID 오름차순(ORDER BY p.id ASC)으로 획득하여 데드락을 방지합니다. | ||
| */ | ||
| @Transactional(propagation = Propagation.MANDATORY) | ||
| public List<Product> deductStock(List<StockDeductionCommand> commands) { | ||
| return preDecrementEnabled ? deductWithRedis(commands) : deductWithPessimisticLock(commands); | ||
| } | ||
|
|
||
| /** | ||
| * [Before] 비관적 락 — ProductJpaRepository에서 ID 오름차순(ORDER BY p.id ASC)으로 | ||
| * SELECT ... FOR UPDATE 획득하여 데드락을 방지한다. 핫상품 동시 주문 시 직렬화 → 락 경합. | ||
| */ | ||
| private List<Product> deductWithPessimisticLock(List<StockDeductionCommand> commands) { | ||
| List<Long> sortedProductIds = commands.stream() | ||
| .map(StockDeductionCommand::productId) | ||
| .sorted() | ||
|
|
@@ -47,11 +56,41 @@ public List<Product> deductStock(List<StockDeductionCommand> commands) { | |
| product.decreaseStock(command.quantity()); | ||
| } | ||
|
|
||
| // 변경된 상품에 대한 캐시 무효화 | ||
| for (Long productId : sortedProductIds) { | ||
| productService.evictProductCache(productId); | ||
| } | ||
| return products; | ||
| } | ||
|
|
||
| /** | ||
| * [After] Redis 원자 선차감 — DECRBY로 재고 게이트를 통과(원자적)시킨 뒤 | ||
| * DB는 비관적 락 없이 차감만 반영한다. DB 락 경합을 제거해 처리량을 끌어올린다. | ||
| */ | ||
| 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); | ||
| } | ||
|
Comment on lines
+70
to
+80
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
// 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());
}
}
}
}
); |
||
|
|
||
| // 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; | ||
| } | ||
|
Comment on lines
+69
to
95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Redis 선차감(1단계)이 성공한 이후, 2단계(상품 정보 조회 및 검증) 과정에서 예외(예: 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;
}
} |
||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| spring: | ||
| config: | ||
| activate: | ||
| on-profile: loadtest | ||
| # local 프로파일과 병행 실행(local,loadtest): datasource는 local에서, 아래 설정으로 오버라이드. | ||
| # ddl-auto=update로 재기동 시에도 시드 데이터를 유지(local의 create가 매번 drop하는 것 방지). | ||
| jpa: | ||
| hibernate: | ||
| ddl-auto: update | ||
| show-sql: false | ||
|
|
||
| # 부하테스트 시 캐시 효과 측정을 위해 활성화 (default 프로파일은 false) | ||
| cache: | ||
| redis: | ||
| enabled: true | ||
|
|
||
| # 재고 Redis 선차감 플래그 (Phase 3 개선 전까지 false = Before) | ||
| stock: | ||
| redis: | ||
| pre-decrement: | ||
| enabled: false | ||
|
|
||
| logging: | ||
| level: | ||
| org.hibernate.SQL: WARN # 부하 시 로그 I/O 배제 | ||
| root: WARN | ||
|
|
||
| management: | ||
| endpoints: | ||
| web: | ||
| exposure: | ||
| include: health,prometheus,metrics | ||
|
|
||
| # 8080은 Oracle TNS Listener(TNSLSNR)가 점유 → 부하테스트 앱은 8081 사용 | ||
| server: | ||
| port: 8081 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.loopers.domain.order; | ||
|
|
||
| import io.micrometer.core.instrument.simple.SimpleMeterRegistry; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class OrderMetricsTest { | ||
|
|
||
| @Test | ||
| void 주문성공_카운터가_증가한다() { | ||
| SimpleMeterRegistry registry = new SimpleMeterRegistry(); | ||
| OrderMetrics metrics = new OrderMetrics(registry); | ||
|
|
||
| metrics.orderSuccess(); | ||
| metrics.orderSuccess(); | ||
|
|
||
| assertThat(registry.counter("ecommerce.order.success").count()).isEqualTo(2.0); | ||
| } | ||
|
|
||
| @Test | ||
| void 주문실패_카운터가_증가한다() { | ||
| SimpleMeterRegistry registry = new SimpleMeterRegistry(); | ||
| OrderMetrics metrics = new OrderMetrics(registry); | ||
|
|
||
| metrics.orderFail(); | ||
|
|
||
| assertThat(registry.counter("ecommerce.order.fail").count()).isEqualTo(1.0); | ||
| } | ||
|
|
||
| @Test | ||
| void 재고부족_카운터가_증가한다() { | ||
| SimpleMeterRegistry registry = new SimpleMeterRegistry(); | ||
| OrderMetrics metrics = new OrderMetrics(registry); | ||
|
|
||
| metrics.stockShortage(); | ||
|
|
||
| assertThat(registry.counter("ecommerce.order.stock_shortage").count()).isEqualTo(1.0); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| apiVersion: 1 | ||
| providers: | ||
| - name: 'ecommerce-loadtest' | ||
| orgId: 1 | ||
| folder: '' | ||
| type: file | ||
| disableDeletion: false | ||
| editable: true | ||
| options: | ||
| path: /etc/grafana/provisioning/dashboards |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OrderMetrics에 정의된orderFail()과stockShortage()메트릭이 실제 애플리케이션 코드 내에서 전혀 호출되지 않고 있습니다. 이로 인해 주문 실패나 재고 부족 상황이 발생하더라도 Prometheus 메트릭이 증가하지 않으며, Grafana 대시보드의 관련 패널들이 항상0으로 표시되는 문제가 발생합니다.\n\nplaceOrder메서드 내에서 예외 발생 시 적절히 메트릭을 증가시키도록 예외 처리 로직을 추가해 주세요.