주문 - ekdan38/HotDealService GitHub Wiki
- CPU : Intel i5-8250U 1.6GHz
- RAM : 8GB
- OS : Window10
- Databse : MySQL 8.0
- Test Tool : K6
테스트시나리오
1~3개 종류의 상품을 랜덤 주문
성능 개선 전 k6 테스트
k6 테스트 결과
-
VU 100
-
VU 200
-
VU 300
주문 생성 (3분) | 총 처리량 | Latency(mean) | Latency(P95) | TPS |
---|---|---|---|---|
VU 100 | 6028 | 3.01s | 3.74s | 32 |
VU 200 | 5587 | 6.56s | 7.86s | 29 |
VU 300 | 5657 | 9.83s | 18.12s | 29 |
테스트 결과 분석
- VU 증가에 따라 Latency 큰 증가
- TPS 또한 감소하거나 VU200 부터 동일
VU 300에서 아래와 같은 오류가 나타남
성능 개선을 하며 해당 오류의 발생 원인도 찾아볼 예정이다.
성능 개선 계획에 앞서, 현재 주문 흐름을 정리
[OrderService]
- 상품 재고 점유 요청(HotDealService로 FeignClient 요청 -> 동기 처리)
- -> 상품 존재 검증, 핫딜 활성 유무 검증, 재고 존재시 재고 예약 테이블에 재고 점유
- Order, OrderProduct, Delivery 생성
- Payment 생성 요청(PaymentService로 FeignClient 요청 -> 동기 처리)
- 응답 반환
HotDealService, PaymentService로 2번의 FeignClient 동기 통신이 이뤄지고 있음
HotDealService의 재고 점유API 성능 개선, PaymentService로 payment 생성은 비동기 처리 혹은, 방식을 변경
[HotDealService]
- 상품 조회 및 검증
- 핫딜 조회 및 활성화 검증
- 재고 점유
재고 점유 로그
2개 종류의 상품에 대한 주문을 요청했을때, 다음과 같은 쿼리 발생
- 2개의 상품 IN 절 조회 -> 쿼리 1번
- 2개의 상품에 대한 핫딜 IN 절 조회 -> 쿼리 1번
- 2개의 상품에 대한 재고 점유 조회 -> 쿼리 2번
상품, 핫딜 조회의 경우에는 IN 절을 통해 1번의 조회가 이루어지지만, 재고 점유 조회의 경우 각 상품마다 조회 쿼리 발생
-
[OrderService]
- HotDealService의 재고 점유API 성능 개선
- PaymentService로 payment 생성 과정을 비동기 처리 혹은, 방식을 변경
-
[HotDealService]
- 상품 조회
- 조회 쿼리 분석 및 개선
- 캐싱
- 핫딜 조회
- 조회 쿼리 분석 및 개선
- 캐싱
- 재고 점유
- COUNT 쿼리 최적화
- 상품 조회
재고 점유 API 성능 개선에 앞서, 재고 점유 API 자체의 성능 테스트 진행
재고 점유 API 성능 개선 전 k6 테스트
k6 테스트 결과
-
VU 100
-
VU 200
재고 점유 조회 (3분) | 총 처리량 | Latency(mean) | Latency(P95) | TPS |
---|---|---|---|---|
VU 100 | 13278 | 1.35s | 1.83s | 73 |
VU 200 | 13203 | 2.74s | 3.43s | 72 |
- VU 100 에서 처리량 한계
1. 상품 조회
[조회 쿼리 분석]
JPA 코드 및 실제 실행 쿼리
List<HotDealProduct> findByIdIn(List<Long> hotDealProductIds);
SELECT hdp.product_id,hdp.created_at,hdp.hotdeal_id,hdp.modified_at,hdp.price,hdp.stock,hdp.title
FROM hot_deal_product hdp
WHERE hdp.product_id IN (99951,99981);
실행 계획
EXPLAIN SELECT hdp.product_id,hdp.created_at,hdp.hotdeal_id,hdp.modified_at,hdp.price,hdp.stock,hdp.title
FROM hot_deal_product hdp
WHERE hdp.product_id IN (99951,99981);
- WHERE 조건문이 PK컬럼이며, PK 클러스터링 인덱스로 효율적인 탐색 중
2. 핫딜 조회
[조회 쿼리 분석]
JPA 코드 및 실제 실행 쿼리
@Query("SELECT new com.hong.hotdealservice.dto.projection.HotDealSimpleDto" +
"(h.id, h.title, h.description, h.status, h.startTime, h.endTime) " +
"FROM HotDeal h " +
"WHERE h.deleted = false " +
"AND h.id IN :hotDealIds")
List<HotDealSimpleDto> findByIds(@Param("hotDealIds") List<Long> hotDealIds);
SELECT hd.hotdeal_id,hd.title,hd.description,hd.status,hd.start_time,hd.end_time
FROM hot_deal hd
WHERE hd.deleted=0
AND hd.hotdeal_id IN (9996,9999);
실행계획
EXPLAIN SELECT hd.hotdeal_id,hd.title,hd.description,hd.status,hd.start_time,hd.end_time
FROM hot_deal hd
WHERE hd.deleted=0
AND hd.hotdeal_id IN (9996,9999);
- PK 클러스터링 인덱스 사용
- deleted 컬럼의 카디널리티는 2로 복합 인덱스 생성 불필요
- 효율적인 탐색 중
3. 상품의 재고 점유 현황 조회
[조회 쿼리 분석]
JPA 코드 및 실제 실행 쿼리
@Query("SELECT COALESCE(SUM(sr.reservedQuantity), 0) " +
"FROM StockReservation sr " +
"WHERE sr.productId = :productId " +
"AND sr.status = :reserveStatus")
Integer sumReservedQuantityByProductId(@Param("productId") Long productId,
@Param("reserveStatus") ReserveStatus reserveStatus);
SELECT sr.product_id, coalesce(sum(sr.reserved_quantity), 0)
FROM stock_reservation sr
WHERE sr.product_id=99951
AND sr.status = 'RESERVED';
- 위에서 확인한 재고 점유 로그를 보면 해당 쿼리로는 N개의 상품 종류를 주문하면, N개의 쿼리가 발생
- N개의 상품 종류를 주문해도, 1번의 조회 쿼리가 발생하도록 수정 필요
코드 수정
@Query("SELECT new com.hong.hotdealservice.dto.projection.ProductReservedQuantityDto" +
"(sr.productId, COALESCE(SUM(sr.reservedQuantity), 0L)) " +
"FROM StockReservation sr " +
"WHERE sr.productId IN :productIds " +
"AND sr.status = :reserveStatus " +
"GROUP BY sr.productId")
List<ProductReservedQuantityDto> sumReservedQuantityByProductId(@Param("productIds") List<Long> productIds,
@Param("reserveStatus") ReserveStatus reserveStatus);
SELECT sr.product_id, coalesce(sum(sr.reserved_quantity), 0)
FROM stock_reservation sr
WHERE sr.product_id IN (99951, 99991)
AND sr.status = 'RESERVED'
GROUP BY sr.product_id;
- IN + GROUP BY 를 사용하여 각 상품의 재고 점유 개수 처리
- DtoProjection 적용 및 List로 return 하여 여러 상품에 대한 재고 점유 현황 조회
[수정 후 로그]
N개의 상품 종류를 주문해도, 1번의 조회 쿼리가 발생하도록 수정 되었음
실행계획
EXPLAIN
SELECT sr.product_id, coalesce(sum(sr.reserved_quantity), 0)
FROM stock_reservation sr
WHERE sr.product_id IN (99951, 99991)
AND sr.status = 'RESERVED'
GROUP BY sr.product_id;
- 현재 인덱스를 사용하고 있지 않음, 인덱스 생성 필요
인덱스 생성 전 소요 시간
소요 시간 : 약 47ms
인덱스 생성
CREATE INDEX idx_reserved_product_stock
ON stock_reservation (product_id, reserved_quantity);
인덱스 생성 후 실행계획
- 커버링 인덱스를 사용하고 있음
인덱스 생성 후 소요 시간
소요 시간 : 약 27ms
47ms -> 27ms로, 42% 개선
조회 성능 개선, 인덱스 적용 후 k6 테스트
k6 테스트 결과
-
VU 100
-
VU 200
재고 점유 조회 (3분) | 총 처리량 | Latency(mean) | Latency(P95) | TPS |
---|---|---|---|---|
VU 100 | 14156 | 1.27s | 1.71s | 78 |
VU 200 | 13891 | 2.6s | 3.17s | 76 |
테스트 결과 분석
Latency
-
Latency(mean)
- VU 100: 1.35s → 1.27s (약 5.9% 개선)
- VU 200: 2.74s → 2.60s (약 5.1% 개선)
Latency(mean) : 약 5% 개선
-
Latency(P95)
- VU 100: 1.83s → 1.71s (약 6.6% 개선)
- VU 200: 3.43s → 3.17s (약 7.6% 개선)
Latency(P95) : 약 7%* 개선
TPS
- VU 100: 73 → 78 (약 6.8% 증가)
- VU 200: 72 → 76 (약 5.6% 증가)
TPS : 약 6% 개선
- 상품별 재고 점유 조회 쿼리를 N개의 상품일때, 1회 조회로 수정
- 인덱스 생성을 통해 쿼리 실행 시간을 (47ms->27ms)로 약 42% 개선
- TPS, Latency 모두 약 6% 의 개선을 보임
상품, 핫딜 조회에 캐싱을 적용하여 조회 성능 개선 시도
캐싱 적용 방식
- 핫딜, 상품의 경우 조회 API 성능 개선에서 캐싱을 적용하였음
- 해당 캐싱 데이터를 주문 처리 과정에서도 사용
[상품 조회 캐시 적용]
private Map<Long, ProductCacheDto> getProductsAndValidate(List<Long> productIds){
Map<Long, ProductCacheDto> productMap = new HashMap<>();
List<Long> cacheMissProducts = new ArrayList<>();
// 1. Redis 조회
List<ProductCacheDto> cachedProducts = productRedisRepository.findAllByIds(productIds);
// 2. cacheHit, Miss 정리
for(int i = 0; i < productIds.size(); i++){
ProductCacheDto cachedData = cachedProducts.get(i);
// cacheHit
if(cachedData != null){
log.info("CacheMissHit, product = {}", productIds.get(i));
productMap.put(productIds.get(i), cachedData);
}
// cacheMiss
else {
log.info("CacheMiss, product = {}", productIds.get(i));
cacheMissProducts.add(productIds.get(i));
}
}
// 3. cacheMiss 존재 하면 DB 조회 후 cache save
if (!cacheMissProducts.isEmpty()){
// DB 조회
log.info("상품 재고 점유. [상품 조회 쿼리 실행] : productIds = {}", cacheMissProducts);
List<HotDealProduct> foundProducts = hotDealProductRepository.findByIdIn(cacheMissProducts);
log.info("상품 재고 점유. [상품 조회 쿼리 종료] : productIds = {}", cacheMissProducts);
// DB 에 존재 하지 않는 product 예외 처리
if(cacheMissProducts.size() != foundProducts.size()){
List<Long> noneMatchIds = cacheMissProducts.stream()
.filter(id -> foundProducts.stream().noneMatch(hp -> hp.getId().equals(id)))
.toList();
log.error("요청된 핫딜 상품이 존재하지 않습니다. productIds = {}", noneMatchIds);
throw new HotDealProductException(ErrorCode.HOTDEAL_PRODUCT_NOT_FOUND, noneMatchIds);
}
// productMap 에 정리 (cacheHit, cacheMiss 데이터 관리)
List<ProductCacheDto> productsToCache = new ArrayList<>();
foundProducts.forEach(hp -> {
ProductCacheDto productCacheDto = new ProductCacheDto(hp);
productsToCache.add(productCacheDto);
productMap.put(hp.getId(), productCacheDto);
});
// 4. Redis Save
productRedisRepository.saveAllWithTTL(productsToCache);
}
return productMap;
}
[핫딜 조회 캐싱 적용]
private void getHotDealsAndValidateOrderAble(Map<Long, ProductCacheDto> productMap){
Map<Long, HotDealCacheDto> hotDealMap = new HashMap<>();
List<Long> cacheMissIds = new ArrayList<>();
// 1. hotDealIds 추출
List<Long> hotDealIds = productMap.values().stream()
.map(ProductCacheDto::getHotDealId)
.distinct()
.sorted()
.toList();
// 2. Redis 조회
List<HotDealCacheDto> cachedHotDeals = hotDealRedisRepository.findAllByIds(hotDealIds);
// 3. cacheHit, Miss 정리
for(int i = 0; i < hotDealIds.size(); i++){
HotDealCacheDto hotDealCacheDto = cachedHotDeals.get(i);
// cacheHit
if(hotDealCacheDto != null){
log.info("CacheMissHit, hotDeal = {}", hotDealIds.get(i));
hotDealMap.put(hotDealIds.get(i), hotDealCacheDto);
}
// cacheMiss
else {
log.info("CacheMiss, hotDeal = {}", hotDealIds.get(i));
cacheMissIds.add(hotDealIds.get(i));
}
}
// 4. cacheMiss 존재 하면 DB 조회 후 cache save
if (!cacheMissIds.isEmpty()){
// DB 조회
log.info("상품 재고 점유. [핫딜 조회 쿼리 실행] : hotDealIds = {}", cacheMissIds);
List<HotDealSimpleDto> foundHotDeals = hotDealRepository.findByIds(cacheMissIds);
log.info("상품 재고 점유. [핫딜 조회 쿼리 실행] : hotDealIds = {}", cacheMissIds);
// DB 에 존재 하지 않는 product 예외 처리
if(cacheMissIds.size() != foundHotDeals.size()){
List<Long> noneMatchIds = cacheMissIds.stream()
.filter(id -> foundHotDeals.stream().noneMatch(h -> h.getId().equals(id)))
.toList();
log.debug("핫딜이 존재하지 않습니다. hotDealId = {}", noneMatchIds);
throw new HotDealException(ErrorCode.HOTDEAL_NOT_FOUND, noneMatchIds);
}
// hotDealMap 에 정리 (cacheHit, cacheMiss 데이터 관리)
List<HotDealCacheDto> hotDealsToCache = new ArrayList<>();
foundHotDeals.forEach(h -> {
HotDealCacheDto hotDealCacheDto = new HotDealCacheDto(h);
hotDealsToCache.add(hotDealCacheDto);
hotDealMap.put(h.getId(), hotDealCacheDto);
});
// Redis Save
hotDealRedisRepository.saveAllWithTTL(hotDealsToCache);
}
// 5. hotDeal 활성화 검증
List<Long> noneActiveHotDealIds = new ArrayList<>();
hotDealMap.values().forEach(h -> {
if(h.getStatus() != HotDealStatus.ACTIVE) noneActiveHotDealIds.add(h.getId());
LocalDateTime now = LocalDateTime.now().truncatedTo(ChronoUnit.MILLIS);
if(!(now.isAfter(h.getStartTime()) && now.isBefore(h.getEndTime()))){
noneActiveHotDealIds.add(h.getId());
}
});
// 주문 가능 상태가 아닌 hotDeal 포함 되어 예외 처리
if (!noneActiveHotDealIds.isEmpty()) {
List<Long> sortedHotDealIds = noneActiveHotDealIds.stream().sorted().collect(toList());
log.error("활성화 된 핫딜이 아닙니다. hotDealId = {}", sortedHotDealIds);
throw new HotDealException(ErrorCode.HOTDEAL_NON_ACTIVE, sortedHotDealIds);
}
}
캐싱 적용 후 k6 테스트
k6 테스트 결과
-
VU 100
-
VU 200
재고 점유 조회 (3분) | 총 처리량 | Latency(mean) | Latency(P95) | TPS |
---|---|---|---|---|
VU 100 | 15615 | 1.15.s | 1.54s | 86 |
VU 200 | 16067 | 2.25s | 3.13s | 88 |
테스트 결과 분석
Latency
-
Latency(mean)
- VU 100: 1.35s → 1.15s (약 14.8% 개선)
- VU 200: 2.74s → 2.25s (약 17.9% 개선)
Latency(mean) : 약 15 ~ 18% 개선 효과를 보임
-
Latency(P95)
- VU 100: 1.83s → 1.54s (약 15.8% 개선)
- VU 200: 3.43s → 3.13s (약 8.7% 개선)
Latency(P95) : 약 9 ~ 16% 개선 효과를 보이지만 여전히 지연 모습을 보임
TPS
- VU 100: 73 → 86 (약 17.8% 증가)
- VU 200: 72 → 88 (약 22.2% 증가)
TPS : 약 18 ~ 22% 개선
- 상품, 핫딜 캐싱 조회로 인해 Latency는 약 16%, TPS 는 약 20% 개선
항목(각 개선 단계는 이전 단계 포함) | 로직 개선 및 인덱싱 | 캐싱 |
---|---|---|
평균 Latency 감소율 | 6% | 16% |
TPS 증가율 | 6% | 20% |
1.2.1. 로직 수정
결제 생성은 PaymentService로 동기 처리 되고있음 "Outbox + FeignClient + 이벤트" 기반의 비동기 처리로 수정
Outbox Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "create_payment_outbox",
indexes = {@Index(name = "idx_outbox_status", columnList = "outbox_status")})
public class CreatePaymentOutbox extends TimeEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String orderId;
@Column(nullable = false)
private Long userId;
@Column(nullable = false)
private BigDecimal amount;
@Column(nullable = false)
private LocalDateTime expireAt;
@Enumerated(value = EnumType.STRING)
private OutboxStatus outboxStatus;
@Column(nullable = false)
private Integer tryCount;
private CreatePaymentOutbox(String orderId, Long userId, BigDecimal amount, LocalDateTime expireAt) {
this.orderId = orderId;
this.userId = userId;
this.amount = amount;
this.expireAt = expireAt;
this.outboxStatus = OutboxStatus.PENDING;
this.tryCount = 0;
}
// == 생성 메서드 ==
public static CreatePaymentOutbox create(String orderId, Long userId, BigDecimal amount, LocalDateTime expireAt) {
return new CreatePaymentOutbox(orderId, userId, amount, expireAt);
}
// == 이벤트 성공 처리 ==
public void updateToSent(){
this.outboxStatus = OutboxStatus.SENT;
}
// == 이벤트 실패 처리 ==
public void updateToFailed(){
this.outboxStatus = OutboxStatus.FAILED;
}
// == 이벤트 재시도 처리 스킵 처리 ==
public void updateToSkip(){
this.outboxStatus = OutboxStatus.SKIP;
}
// == 이벤트 진행중 처리 ==
public void updateToInProgress(){
this.outboxStatus = OutboxStatus.IN_PROGRESS;
this.tryCount++;
}
}
Outbox 생성
private void saveCreatePaymentOutbox(Long userId, Order savedOrder){
CreatePaymentOutbox outbox = CreatePaymentOutbox.create(savedOrder.getId(), userId, savedOrder.getAmount(), savedOrder.getExpiresAt());
createPaymentOutboxRepository.save(outbox);
}
Outbox Scheduler
@Component
@Profile("!test")
@RequiredArgsConstructor
@Slf4j(topic = "[CreatePaymentScheduler]")
@Transactional
public class CreatePaymentScheduler {
private final CreatePaymentOutboxRepository outboxRepository;
private final ApplicationEventPublisher eventPublisher;
@Scheduled(fixedDelay = 3_000) // 3초 주기
@SchedulerLock(
name = "CreatePaymentScheduler",
lockAtMostFor = "10s",
lockAtLeastFor = "3s")
public void publishOutbox(){
// 1. outbox DB 조회(오래된 데이터부터 가져와서 순서 보장)
List<OutboxStatus> statuses = List.of(OutboxStatus.FAILED, OutboxStatus.PENDING);
List<CreatePaymentOutbox> foundOutboxes = outboxRepository.findByOutboxStatusInOrderByIdAsc(statuses);
// 2. 조회된 outbox FeignClient 이벤트 발행(비동기 처리)
if(!foundOutboxes.isEmpty()){
log.info("Payment 생성 outbox {} 건 진행중", foundOutboxes.size());
for (CreatePaymentOutbox outbox : foundOutboxes) {
// Status = IN_PROGRESS, tryCount++ 처리;
outbox.updateToInProgress();
// 이벤트 발행
eventPublisher.publishEvent(outbox);
}
}
}
}
Outbox EventListener
@Component
@RequiredArgsConstructor
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Slf4j(topic = "[CreatePaymentEventListener]")
public class CreatePaymentEventListener {
private final PaymentServiceClient paymentServiceClient;
private final CreatePaymentOutboxRepository outboxRepository;
@Async("EventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleCreatePaymentOutbox(CreatePaymentOutbox outbox) {
Long userId = outbox.getUserId();
String orderId = outbox.getOrderId();
try {
// 1. FeignClient RequestDto 생성
PaymentCreateRequestDto requestDto =
new PaymentCreateRequestDto(orderId, userId, outbox.getAmount(), outbox.getExpireAt());
// 2. FeignClient 호출
PaymentCreateResponseDto responseDto = paymentServiceClient.createPayment(requestDto);
// 3. Payment 생성 성공이면, Outbox Status = sent
if (responseDto.isSuccess()) {
log.info("Payment 생성 완료. userId = {}, orderId = {}", userId, orderId);
outbox.updateToSent();
}
// 4. Payment 생성 실패(circuitBreaker, Retry Fallback -> 재시도 대상)
else if(responseDto.isRetriable()){
log.error("Payment 생성 실패. (CircuitBreaker OR Retry Fallback 발생). userId = {}, orderId = {}", userId, orderId);
outbox.updateToFailed();
}
}
// 5. OrderException 이면, 비지니스 로직상 의도된 예외이기 때문에 Outbox Status = skip 처리,
// 다시 예외 던져서 GlobalHandler 로 예외 응답 처리
catch (OrderException e){
log.error("Payment 생성 실패. 비지니스 로직상 예외 발생. userId = {}, orderId = {}", userId, orderId);
outbox.updateToSkip();
// 다시 예외 던져서 GlobalHandler 로 예외 응답 처리
throw e;
}
// 6. 예기치 못한 예외
catch (Exception e){
log.error("알 수 없는 오류 발생. userId = {}, orderId = {}, errorMessage = {}",
userId, orderId, e.getMessage());
outbox.updateToFailed();
}
// 7. 상태 변경된 Outbox 저장
outboxRepository.save(outbox);
}
}
k6 테스트
k6 테스트 결과
-
VU 100
-
VU 200
-
VU 300
주문 생성 (3분) | 총 처리량 | Latency(mean) | Latency(P95) | TPS |
---|---|---|---|---|
VU 100 | 7507 | 2.41s | 3.14s | 41 |
VU 200 | 7678 | 4.75s | 5.58s | 41 |
VU 300 | 7925 | 6.94s | 11.67s | 42 |
테스트 결과 분석
Latency
-
Latency(mean)
- VU 100: 3.01s → 2.41s (약 19.9% 개선)
- VU 200: 6.56s → 4.75s (약 27.6% 개선)
- VU 300: 9.83s → 6.94s (약 29.4% 개선)
Latency(mean) : 약 20 ~ 30% 개선
-
Latency(P95)
- VU 100: 3.74s → 3.14s (약 16.0% 개선)
- VU 200: 7.86s → 5.58s (약 29.0% 개선)
- VU 300: 18.12s → 11.67s (약 35.6% 개선)
Latency(P95) : 약 16 ~ 36% 개선
TPS
- VU 100: 32 → 41 (약 28.1% 증가)
- VU 200: 29 → 41 (약 41.4% 증가)
- VU 300: 29 → 42 (약 44.8% 증가)
TPS : 약 28 ~ 45% 개선
- 개선 전 테스트 VU 300 에서 발생했던 오류는 더이상 발생하지 않음
- 재고 점유API 개선, 결제 생성 비동기처리로 Latency, TPS 모두 눈에 띄게 개선
- 최종적으로 Latency 약 25% 개선, TPS 약 36% 개선