Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public class OrderFacade {
private final ProductService productService;
private final CouponService couponService;
private final ApplicationEventPublisher eventPublisher;
private final OrderMetrics orderMetrics;

@Transactional
public OrderInfo placeOrder(OrderPlaceCommand command) {
Expand Down Expand Up @@ -100,6 +101,7 @@ public OrderInfo placeOrder(OrderPlaceCommand command) {
);
eventPublisher.publishEvent(event);

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

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 메서드 내에서 예외 발생 시 적절히 메트릭을 증가시키도록 예외 처리 로직을 추가해 주세요.

}

Expand Down
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
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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

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


Expand Down
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

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 스크립트를 활용해 키 존재 여부를 확인하고 처리하는 로직이 반드시 추가되어야 합니다.


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

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 스크립트를 통해 안전하게 처리할 것을 권장합니다.


/** 차감 복구(주문 실패/취소 시). */
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
Expand Up @@ -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;
Expand All @@ -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()
Expand All @@ -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

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


// 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

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


Expand Down
36 changes: 36 additions & 0 deletions apps/commerce-api/src/main/resources/application-loadtest.yml
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);
}
}
10 changes: 10 additions & 0 deletions docker/grafana/provisioning/dashboards/dashboard.yml
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
Loading
Loading