이전 프로젝트에서는 동시간대에 많은 주문 요청이 들어올 때 동시성 문제를 해결하는데에 집중해서 프로젝트를 진행하였고 마무리 하였다.
이번에는 동시간대에 많은 주문 요청이 들어올 때 어떻게 성능을 개선해볼 수 있을지 고민해 보려고 한다.
기존 로직 성능 측정 & 문제 분석
현재까지의 주문 요청 로직을 분석해보고, 어떻게 개선해볼 수 있을지 고민해보자.
아래는 현재 주문 요청을 처리하는 컨트롤러, 서비스, 레포지토리 코드이다.
/**
* 주문 생성 컨트롤러
*/
@PostMapping
public Response<Long> create(@RequestBody final OrderCreate orderCreate) {
final Long savedOrderId = orderService.create(orderCreate);
return Response.success(savedOrderId);
}
/**
* 주문 생성 서비스
*/
@Transactional
public Long create(final OrderCreate orderCreate) {
// 1. 유효성 검증 : 상품 판매 기간 (DB I/O)
checkReservationStartAt(orderCreate);
// 2. 상품 서버에서 상품을 조회해옴 (DB I/O)
final int orderProductPrice = requestOrderProduct(orderCreate).getPrice();
// 3. 주문엔티티를 생성하고, DB에 저장 (DB I/O)
final Order order = Order.create(orderCreate, orderProductPrice);
final Order savedOrder = orderRepository.save(order);
// 4. 주문기록 엔티티를 생성하고, DB에 저장 (DB I/O)
final OrderHistory orderHistory = OrderHistory.create(savedOrder);
orderHistoryRepository.save(orderHistory);
// 5. 재고 서버에 상품 재고 감소 요청 (DB I/O)
requestSubtractStock(order);
return savedOrder.getId();
}
현재 코드 상태로 많은 주문트래픽을 발생시켜서 성능을 측정해보면 아래와 같다.
[결과]
평균 RPS : 약 50
위의 서비스 로직의 문제는 너무 많은 Database I/O가 일어나고 있다.
Database에 부하를 적게 가하면서, 성능을 높이기 위해서 Redis를 활용해 구조를 개선해보자.
구조 개선
생각해본 개선 구조는 아래와 같다.
1. 먼저 주문 요청에 대한 서비스 로직에서 MySQL에 접근하지 않고, redis를 활용해서 유효성검사, 재고수량감소를 수행한다.
2. 그리고 실제 주문에 대해 엔티티를 생성하고 저장하는 로직은 주문큐(Queue)를 활용해서 별도의 비동시 서버가 처리하도록 한다.
3. 실제 사용자는 주문 요청에 대해 주문이 접수되었음을 빠르게 응답받을 수 있다.
이런 방식으로 개선했을 때 많은 주문 요청 트래픽에 대해서 이전보다 좋은 성능을 보일 것으로 예상된다.
또한 서비스 특성상 주문자는 주문 요청에 대한 결과를 성공/실패에 상관없이 응답받고, 주문이 성공했는지에 대한 결과는 주문 결과 페이지로 이동해서 확인하는 방식이라고 생각했을 때, 그 시간 간격이 존재하기 때문에 서비스 특성상 크게 문제 없을 것이라고 판단하였다.
redis를 통해서 개선한 주문 서비스의 로직은 아래와 같다.
public void create(final OrderCreate orderCreate) {
Long productId = orderCreate.getProductId();
// 1. 유효성 검사
// 1 - 1. 레디스에서 상품 판매 시작시간 정보를 가져온다. "products:34:reservation_start_at" = "시간"
// 1 - 2. 시간보다 작으면 예외 발생
try {
String key = "products:%s:reservation_start_at".formatted(productId);
String reservationStartAt = redisRepository.get(key);
if (reservationStartAt == null) {
ReservationTimeEntity reservationTime = reservationTimeRepository.findByProductId(productId)
.orElseThrow(() -> new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] 상품 예약 시간을 찾을 수 없음."));
reservationStartAt = objectMapper.writeValueAsString(reservationTime.getReservationStartAt());
redisRepository.set(key, reservationStartAt);
}
LocalDateTime startAt = objectMapper.readValue(reservationStartAt, LocalDateTime.class);
if (startAt.isAfter(LocalDateTime.now())) {
throw new GlobalException(HttpStatus.CONFLICT, "[ERROR] 상품 판매 전 입니다");
}
} catch (Exception e) {
throw new GlobalException(HttpStatus.INTERNAL_SERVER_ERROR, "[ERROR] 상품 정보 조회 에러");
}
distributeLockExecutor.execute("lock_%s".formatted(orderCreate.getProductId()), 3000, 3000, () -> {
// 1 - 3. 레디스에서 상품 재고 정보를 가져온다. "products:34:stocks" = "남은 개수"
// 1 - 4. 상품 재고가 없으면 예외 발생
try {
String key = "products:%s:stocks".formatted(productId);
String stockCount = redisRepository.get(key);
if (stockCount == null) {
StockEntity stock = stockRepository.findByProductId(productId)
.orElseThrow(() -> new GlobalException(HttpStatus.NOT_FOUND, "[ERROR] 상품 재고 정보를 찾을 수 없음"));
stockCount = objectMapper.writeValueAsString(stock.getStockCount());
redisRepository.set(key, stockCount);
}
Long stock = objectMapper.readValue(stockCount, Long.class);
if (stock <= 0) {
throw new GlobalException(HttpStatus.CONFLICT, "[ERROR] 재고 수량이 부족합니다.");
}
}catch (Exception e) {
throw new GlobalException(HttpStatus.INTERNAL_SERVER_ERROR, "[ERROR] 재고 수량 조회 에러");
}
// 2. 재고 감소 요청
// 2 - 1. 레디스의 상품 재고를 감소시킴 "products:34:stocks" = "남은 개수" 감소
String key = "products:%s:stocks".formatted(productId);
redisRepository.decr(key);
// 3. 주문 발급 큐에 삽입
// 3 -1. redis의 큐 자료구조에 주문발급
issueRequest(productId, orderCreate.getMemberId(), orderCreate.getQuantity(), orderCreate.getAddress());
});
}
public void issueRequest(Long productId, Long memberId, Integer quantity, String address) {
String issueRequestKey = "issue.request.productId:%s".formatted(productId);
OrderIssueRequest orderIssueRequest = new OrderIssueRequest(productId, memberId, quantity, address);
try {
String value = objectMapper.writeValueAsString(orderIssueRequest);
redisRepository.rPush(issueRequestKey, value);
} catch (JsonProcessingException e) {
throw new GlobalException(HttpStatus.INTERNAL_SERVER_ERROR, "[ERROR] 주문큐에 발급 중 에러");
}
}
[결과]
평균 RPS : 약 50 -> 약 185
이렇게 MySQL I/O를 최소화하고, redis로 재고 수량을 관리하면서 RPS를 약 3~4배정도 높일 수 있었다.
추가적으로 발급한 주문 큐에 대해 스케쥴러를 통해 처리해주는 서버를 구현해줘야 한다.
(주문 서비스 로직이 너무 길어서 가독성이 안좋은데 리팩토링도 해줘야겠지?!)
'프로젝트 > 예약상품' 카테고리의 다른 글
[프로젝트 그 이후...] 선착순 주문 요청 프로젝트 개선하기(보충학습) (0) | 2024.07.22 |
---|---|
[회고] 예약 상품 프로젝트 (0) | 2024.03.04 |
[프로젝트] 회복탄력성(CircuitBreaker와 Retry) (0) | 2024.03.02 |
[프로젝트] 상품 오픈시간에 같은 사용자의 여러 요청을 대비하자 (0) | 2024.02.26 |
[프로젝트] redis로 동시성 문제를 접근해보자 (0) | 2024.02.26 |