종우의 삶 (전체 공개)

MSA Project - 대용량 요청 처리 테스트 (1만 건) 본문

개발/Spring

MSA Project - 대용량 요청 처리 테스트 (1만 건)

jonggae 2024. 7. 22. 19:35

 

예약 구매 시나리오에 맞게, 특정 시간에 많은 주문이 몰리게되는 테스트를 진행했다. 

 

주어진 상황

특정 시간에 상품이 오픈되면 한정된 재고 (eg. 10개) 를 1만명이 구매하려한다.

구매, 결제 과정에서 20% 는 이탈하게 되고, 그 재고도 다시 반영하여 구매자들에게 제공되어야한다.

 

대충 이런 형태인데, 기존에 만들었던 로직과는 좀 다른 느낌이어서 새로운 API로 만들어 보았다.

 

 

@Slf4j
@Service
@RequiredArgsConstructor
public class TimeOrderService {

    private final ProductClient productClient;
    private final PaymentClient paymentClient;
    private final OrderRepository orderRepository;

    private final RedisTemplate<String, String> redisTemplate;

    @Transactional
    public OrderDto createTimeOrder(Long customerId, Long productId, Long quantity) {
        String lockKey = "lock:product:" + productId;

        try {
            boolean locked = Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", Duration.ofSeconds(10)));
            if (!locked) {
                throw new ConcurrentOrderException("다른 주문 처리 중");
            }

            try {
                //재고 확인 및 감소
                boolean stockReserved = productClient.reserveStock(productId, new StockReservationRequestDto(quantity));
                if (!stockReserved) {
                    throw new InsufficientStockException("재고가 부족합니다");
                }
                // 상품 정보 조회
                ProductDto product = productClient.getProductOrderInfo(productId);

                // 주문 생성
                Order order = Order.builder()
                        .customerId(customerId)
                        .orderDate(LocalDateTime.now())
                        .orderStatus(OrderStatus.PENDING_ORDER)
                        .orderItemList(new ArrayList<>())  // 명시적으로 빈 리스트 설정
                        .build();

                OrderItem orderItem = OrderItem.builder()
                        .productId(productId)
                        .productName(product.getProductName())
                        .quantity(quantity)
                        .price(product.getPrice())
                        .order(order)
                        .build();

                order.addOrderItem(orderItem);
                Order savedOrder = orderRepository.save(order);

                // 결제 처리
                PaymentRequestDto paymentRequestDto = new PaymentRequestDto(savedOrder.getId());
                PaymentResult paymentResult = paymentClient.processPayment(paymentRequestDto);

                // 주문 상태 업데이트
                if (paymentResult == PaymentResult.SUCCESS) {
                    savedOrder.setOrderStatus(OrderStatus.PAYMENT_COMPLETE);
                    productClient.confirmStockReservation(productId, quantity);
                } else {
                    savedOrder.setOrderStatus(OrderStatus.PAYMENT_FAILED);
                    productClient.cancelStockReservation(productId, quantity);
                }

                Order updatedOrder = orderRepository.save(savedOrder);
                return OrderDto.from(updatedOrder);
            } catch (FeignException e) {
                log.error("외부 서비스 통신 간 오류",e);
                productClient.cancelStockReservation(productId, quantity);
                throw new ExternalServiceException("외부 서비스 통신 중 오류가 발생했습니다");
            }
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
}

 

TimeOrderService라는 새로운 서비스를 생성한다.

 

테스트는 Python 스크립트를 작성하여 1만개의 구매 요청을 실제로 진행하였다.

더보기
import requests
import concurrent.futures
import time
import json
from datetime import datetime

# 파일에서 customerId 읽기
with open('customer_ids.txt', 'r') as file:
    customer_ids = [line.strip() for line in file]

headers_template = {
    "Content-Type": "application/json"
}

url = "http://localhost:8080/api/test/time-orders/create"

result_file = open('time_order_results.txt', 'w', encoding='utf-8')


def send_request(customer_id):
    params = {
        "customerId": customer_id,
        "productId": 1,
        "quantity": 1
    }
    start_time = time.time()
    response = requests.post(url, headers=headers_template, params=params)
    end_time = time.time()
    duration = end_time - start_time
    request_time = datetime.now().isoformat()

    try:
        response_json = response.json()
        order_status = response_json.get('data', {}).get('status')
        order_id = response_json.get('data', {}).get('orderId')
    except json.JSONDecodeError:
        order_status = "PARSE_ERROR"
        order_id = None

    result = {
        "customerId": customer_id,
        "status_code": response.status_code,
        "order_status": order_status,
        "order_id": order_id,
        "duration": duration,
        "request_time": request_time
    }
    return result


start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
    futures = [executor.submit(send_request, customer_id) for customer_id in customer_ids]
    for future in concurrent.futures.as_completed(futures):
        result = future.result()
        result_file.write(json.dumps(result, ensure_ascii=False) + "\n")
end_time = time.time()

test_duration = end_time - start_time
result_file.write(f"\nTotal test duration: {test_duration} seconds\n")

result_file.close()

print(f"Total test duration: {test_duration} seconds")

# 결과 분석
with open('time_order_results.txt', 'r', encoding='utf-8') as file:
    results = [json.loads(line) for line in file if line.strip() and not line.startswith('Total')]

successful_orders = [r for r in results if r['order_status'] == 'PAYMENT_COMPLETE']
failed_orders = [r for r in results if r['order_status'] != 'PAYMENT_COMPLETE']

print(f"Total orders: {len(results)}")
print(f"Successful orders: {len(successful_orders)}")
print(f"Failed orders: {len(failed_orders)}")

if successful_orders:
    first_success = min(successful_orders, key=lambda x: x['request_time'])
    print(f"First successful order: Customer ID {first_success['customerId']}, Time: {first_success['request_time']}")

if len(successful_orders) > 0:
    avg_duration = sum(order['duration'] for order in successful_orders) / len(successful_orders)
    print(f"Average duration for successful orders: {avg_duration:.4f} seconds")

좀 복잡하지만, 찾아보면 금방 작성할 수 있는 스크립트이다.

 

가장 기본적인 분산 락 설정을 하고 나서 처음 테스트를 진행해보았다.

실패 사유는 결제 과정에서 20% 이탈하게 되는 것으로 설정하였다.

 

전체 테스트 시간은 54초 가량으로, 아주 오래 걸리지는 않았는데 결과가 이상했다

10개의 재고에서 20% 실패가 일어난다고 설정하였는데,

주문 완료는 9개고 실패는 5개인 이상한 숫자가 나온것이다. 

 

이것은 동시성의 설정이 잘 되지 않았고, 실패한 주문을 재시도하지도 않은 것이라는 결론을 내렸다.

사실 재시도 설정은 해놓지 않았으니 당연한것이고, 동시성 문제가 조금 발생한 것 같다.

 

그렇다면 개선을 진행해보자

 

1. Redisson

동시성 처리에 Redisson 라이브러리를 사용하면 편리한 부분이 있다. 분산락을 이미 사용하고 있기 때문에 redisson의 분산락으로 대체해 본다.

 

그냥 redis의 분산 락을 RedissonClient의 메서드로 변경한다

 

public class TimeOrderService {

    private final ProductClient productClient;
    private final PaymentClient paymentClient;
    private final OrderRepository orderRepository;
    private final RedissonClient redissonClient;


    private static final String LOCK_PREFIX = "lock:product:";
    private static final long LOCK_WAIT_TIME = 2000;
    private static final long LOCK_LEASE_TIME = 5000;

    @Transactional
    public OrderDto createTimeOrder(Long customerId, Long productId, Long quantity) {
        RLock lock = redissonClient.getLock(LOCK_PREFIX + productId);

        try {
            boolean locked = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.MILLISECONDS);
            if (!locked) {
                throw new ConcurrentOrderException("다른 주문 처리 중");
            }

            return processOrder(customerId, productId, quantity);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new ConcurrentOrderException("락 획득 중 인터럽트 발생");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

//하략//

 

 

이 부분에서 락 획득 시도와 관련된 TIME들을 지정하고, 분산 락을 구현할 수 있다.

Redisson을 사용함으로써 

1. 유효 시간 설정으로 데드락을 방지하고,

2. 락 대기 큐에로 공정성있는 락 획득을 보장하며

3. 동시 접근을 제어할 수 있다. 

 

초반의 문제가 전부 해결된 것이다. 이렇게 redisson 라이브러리를 사용하면 요청시간이 굉장히 많이 늘어난다.

같은 테스트 스크립트를 진행한 결과, 

 

오류들은 해소되었지만 총 요청 시간이 340초 가량으로 급격히 증가했다. (약 5배)

개별 요청들의 duration만봐도 많이 늘어난 것을 알 수 있다.

 

그렇다면 이러한 긴 요청시간을 어떻게 줄일 수 있을까?

 

2. 캐싱

Redisson은 순차적으로 락을 획득하려 하기때문에 대기 시간이 생긴다고 한다. 

그렇다면 반복적인 작업들에서 캐싱을 적용하여 성능을 향상시킬 수 있다. 

 

이전 로직에서 테스트를 여러번 돌려보지 않아 대조값은 적은데 어쨌든 상품 정보 조회 쪽에 캐싱을 적용해보고 다시 테스트를 진행해보자.

 

여러 방식으로 캐싱을 해본 결과, 큰 개선이 되지 않았다. 상품 정보 조회 로직은 성능에 큰 영향이 없다고 결론짓는다.

 

3. 락 최적화 + 주문 요청을 비동기 처리 (Async) + 기타 설정

Redisson을 사용했을 때, 이러한 락 관련 시간이 있다.

 

private static final long LOCK_WAIT_TIME = 2000;
private static final long LOCK_LEASE_TIME = 5000;

 

락 대기 시간 (LOCK WAIT TIME) 을 조절하면 총 요청 시간을 줄일수 있을 것 같다는 생각이 들었다. 

락 대기 시간을 줄이고, 

다음과 같이 주문 요청을 비동기로 처리해보았다.

 

public CompletableFuture<OrderDto> createTimeOrderAsync(Long customerId, Long productId, Long quantity) {
    return CompletableFuture.supplyAsync(() -> {
        try {
            return createTimeOrder(customerId, productId, quantity);
        } catch (Exception e) {
            log.error("주문 처리 중 오류 발생", e);
            throw new CompletionException(e);
        }
    }, executorService);
}

 

이러한 메서드를 추가해준다. CreateTimeOrder 메서드를 비동기로 처리하였다. 이로써 여러 주문을 병렬적으로 처리한다.

 

파이썬 스크립트의 결과창에서 110초까지 줄은 것을 확인할 수 있다.

 

마지막 성공 사례의 지연 시간은 계속 바뀌는데, 여러번 테스트해보니 안정적으로 들어왔다. 아마 시스템의 일시적인 문제인듯 했다.

 

이정도까지 시간을 낮추어 보았는데, 어쨌든 재고가 10개이므로 정확히 10개의 주문은 완료되고

시간도 기존의 60초까지 가까워졌다. 

 

비동기 처리의 경우는 kafka가 더 효율적일 것같은데, 카프카를 반영하여서 다시 진행해보도록 하자.

Comments