안녕하세요! delay100입니다.
드디어 e-commerce에서 가장 어렵고 재밌었던 상품 주문API를 다뤄볼까 합니다. 고민을 가장 많이 했던 부분이라, 글이 많이 길어지지는 않을까 걱정이 됩니다..ㅎㅎ 그래도 고민했던 과정을 잘 정리해서 작성해보겠습니다!
서론
아래와 같은 흐름으로 포스팅이 진행됩니다.
1. 상품 주문(기본)
가장 먼저 작성한 기본적인 상품 주문 코드입니다.
2. 동시성 제어 적용
한번에 많은 사람들이 주문 요청을 하는 경우 멀티쓰레드인 Spring App에서는 동시성 이슈가 발생합니다. 이를 해결한 방법에 대해 다룹니다.
3. Redis로 상품 재고 관리하기
상품주문에서 가장 중요한 재고관리에 대해 Redis를 도입해 구매자들이 더 빠르게 주문할 수 있도록 했습니다.
4. Redis 동시성 제어하기
싱글쓰레드의 배신?!
싱글쓰레드인 Redis에서도 동시성 이슈가 발생했는데, 이를 해결한 방법 2가지에 대해 다룹니다.
1. 상품 주문(기본)
1-1. 고민
상품 주문을 어떻게 구현 할 것인가?
어렵게 생각하지 말고 가볍게 생각해보았습니다.
사용자에게 주문 정보를 받아서, DB에 값을 저장한다.
위와 같이 했을 때 문제되는게 없을 것이라 판단했고 구현을 하기로 마음먹었습니다. 또한 개인 프로젝트다보니까 실제 결제창에 갈 수가 없기에 결제 요청에 대한 API는 따로 만들지 않았고, 결제가 완료된 시점에 실행되는 API를 구현했습니다.
요청 JSON
- productList(id, 수량)
- 배송비
- 총 결제금액
- 결제 카드사
- 배송지
- 배송요청사항
RequestBody로 위의 JSON 정보를 받아와서 서버에서 주문DB에 레코드를 생성하면 될 것이라 생각했습니다.
한가지 더 고민해야할 점은 현재 ERD가 아래와 같습니다. 주문을 하기 위해서는 주문 하나만 하면 안되고, 주문상품도 만들어야합니다. 무조건 주문 1개에 주문상품 1개 이상이 있어야 합니다.
주문 테이블 - 카드사, 배송지 정보 등
주문상품 테이블 - 주문 상품에 대한 정보
1-2. 동작 방식
작성했던 코드를 하나하나 살펴봅시다. 전체코드는 1-3에서 확인하실 수 있습니다.
// 토큰 내의 유저 추출
userToken = jwtUtil.substringToken(userToken);
String memberId = jwtUtil.getMemberInfoFromToken(userToken).getSubject();
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
이 시점에는 SpringSecurity에 대한 이해도가 적어서, CookieValue 어노테이션을 이용해 AccessToken을 추출해 사용자 정보를 이용했습니다. (Token 관련 코드는 점차 수정됩니다.)
먼저 주문 테이블을 만듭니다.
// 주문 엔티티 생성 및 저장
OrderRequestDto orderRequestDto = OrderRequestDto.builder()
.orderDate(LocalDateTime.now()) // 주문 날짜를 현재 시각으로 설정
.orderStatus(OrderStatusEnum.PAYING) // 초기 주문 상태를 결제 중으로 설정
.orderPayYN(false) // 결제 완료 여부를 false로 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 주문 총 가격 설정
.orderCardCompany(OrderCardCompanyEnum.valueOf(orderProductPayRequestDto.getOrderCardCompany().toUpperCase())) // 카드 회사 설정
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 배송 요청사항 설정
.member(member) // 주문자 정보를 member 객체로 설정
.build();
Order order = Order.from(orderRequestDto); // OrderRequestDto 객체를 Order 엔티티로 변환
orderRepository.save(order); // Order 엔티티를 데이터베이스에 저장
주문 엔티티를 생성하고, DB에 저장했습니다. Order 엔티티를 생성하기 위해 Dto를 넣어주었습니다. Order.from 메소드는 더보기를 참고해주세요.
[Order.from 메소드]
사용이유 1.
Order.from은 Order builder를 Service에서 단순하게 작성하기 위해 만들어졌습니다. Service가 Entity에 대한 책임을 갖지 않고, Entity가 자신의 메소드를 호출하여 책임지도록 구현했습니다.
사용이유 2.
특정 DTO를 사용하여 Order.from 메소드를 통해 Order 객체를 생성하면, 생성자에 대한 복잡성을 줄일 수 있습니다.
이는 객체 생성 시 필요한 필드들을 명확하게 지정할 수 있으며, 객체의 상태를 일관성 있게 유지하는 데 도움이 됩니다. 또한, DTO를 사용함으로써 데이터 변환 및 검증을 더 쉽게 처리할 수 있으며, 코드의 가독성과 유지보수성을 높이는 데 기여합니다.
// Order
@Builder
@Getter
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "`order`")
public class Order extends Timestamped {
// Order시 처음에 백엔드에서 넣어줘야 하는 값
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long orderId;
@Column(nullable = false)
private LocalDateTime orderDate;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OrderStatusEnum orderStatus;
// 백 -> 프 -> 백
@Column(nullable = false)
private int orderShippingFee;
@Column(nullable = false)
private int orderPrice;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private OrderCardCompanyEnum orderCardCompany;
// 결제 완료 시 사용할 주소 정보
@Column(nullable = false)
private String orderMemberName;
@Column(nullable = false)
private int orderZipCode;
@Column(nullable = false)
private String orderAddress;
@Column(nullable = false)
private String orderPhone;
@Column(nullable = false)
private String orderReq;
// 결제한 아이디
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="member_id", nullable = false)
private Member member;
public static Order from(OrderRequestDto order) {
return Order.builder()
.orderDate(order.getOrderDate())
.orderStatus(order.getOrderStatus())
.orderShippingFee(order.getOrderShippingFee())
.orderPrice(order.getOrderPrice())
.orderCardCompany(order.getOrderCardCompany())
.orderMemberName(order.getOrderMemberName())
.orderZipCode(order.getOrderZipCode())
.orderAddress(order.getOrderAddress())
.orderPhone(order.getOrderPhone())
.orderReq(order.getOrderReq())
.member(order.getMember())
.build();
}
public void setOrderStatus(OrderStatusEnum orderStatusEnum) {
this.orderStatus = orderStatusEnum;
}
}
다음으로 try-catch문입니다. 위에서는 주문 레코드를 만들었다면, 주문 상품 레코드를 만들어야합니다.
try {
// 주문 상품 엔티티 생성 및 저장
List<OrderProduct> orderProducts = orderProductPayRequestDto.getOrderProducts().stream().map(orderProductDto -> {
Product product = productRepository.findById(orderProductDto.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다.")); // 상품 조회
ProductOption productOption = null;
int optionPrice = 0;
if (orderProductDto.getProductOptionId() != null) {
productOption = product.getProductOptions().stream()
.filter(option -> option.getProductOptionId().equals(orderProductDto.getProductOptionId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다.")); // 상품 옵션 조회
optionPrice = productOption.getProductOptionPrice(); // 상품 옵션 가격 설정
}
return OrderProduct.from(OrderProductRequestDto.builder()
.order(order) // 주문 정보 설정
.product(product) // 상품 정보 설정
.orderProductQuantity(orderProductDto.getQuantity()) // 주문 상품 수량 설정
.orderProductPrice(product.getProductPrice()) // 주문 상품 가격 설정
.orderProductOptionId(productOption != null ? productOption.getProductOptionId() : 0) // 주문 상품 옵션 설정
.orderProductOptionPrice(optionPrice) // 주문 상품 옵션 가격 설정
.build()); // OrderProduct 엔티티 생성
}).collect(Collectors.toList());
orderProductRepository.saveAll(orderProducts); // OrderProduct 엔티티를 데이터베이스에 저장
크게보면, 요청으로 들어온 주문 상품 정보(orderProductPayRequestDto)에 대해 가공해서 주문 상품테이블에(orderProductRepository) 저장해야합니다.
주문으로 들어온 상품, 상품 옵션 ID가 실제 DB에 존재하는지 for문으로 DB를 계속 호출하면서 확인했었습니다. 지금봐도 코드 효율이 아주 좋지 않네요..
또한 상품당 옵션이 없을 수도 있다고 가정하고 있었던 터라, ProductOption값이 null값인지 확인하면서, 모든 상품 옵션이 존재하는 경우에만 최종적으로 주문 상품을 추가할 수 있도록 했습니다.
// 결제 성공 시 주문 상태 업데이트
order.setOrderStatus(OrderStatusEnum.PAYMENT_COMPLETED); // 주문 상태를 결제 완료로 업데이트
order.setOrderPayYN(true); // 결제 완료 여부를 true로 설정
orderRepository.save(order); // 업데이트된 주문 정보를 데이터베이스에 저장
return OrderProductPayResponseDto.builder()
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 응답 DTO에 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 응답 DTO에 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 응답 DTO에 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 응답 DTO에 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 응답 DTO에 배송 요청사항 설정
.orderCardCompany(orderProductPayRequestDto.getOrderCardCompany()) // 응답 DTO에 카드 회사 설정
.totalOrderPrice(orderProductPayRequestDto.getTotalOrderPrice()) // 응답 DTO에 총 주문 가격 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 응답 DTO에 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 응답 DTO에 최종 주문 가격 설정
.paymentStatus(OrderStatusEnum.Status.PAYMENT_COMPLETED) // 결제 성공 상태 설정
.build();
모든 주문이 가능하면 저장 후 사용자에게 주문 상품 정보를 반환했습니다.
그리고 이 시점에서 생각했던 것은 아래의 2가지입니다. 따라서 아래의 catch문의 동작이 필요했습니다.
- 결제 성공/실패 여부에 상관없이 주문 정보를 항상 저장
- 재고 업데이트 시 재고가 부족한 경우에도 주문 상태를 결제 실패로 업데이트
} catch (Exception e) {
// 결제 실패 시 주문 상태 업데이트
order.setOrderStatus(OrderStatusEnum.PAYMENT_FAILED); // 주문 상태를 결제 실패로 업데이트
orderRepository.save(order); // 업데이트된 주문 정보를 데이터베이스에 저장
return OrderProductPayResponseDto.builder()
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 응답 DTO에 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 응답 DTO에 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 응답 DTO에 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 응답 DTO에 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 응답 DTO에 배송 요청사항 설정
.orderCardCompany(orderProductPayRequestDto.getOrderCardCompany()) // 응답 DTO에 카드 회사 설정
.totalOrderPrice(orderProductPayRequestDto.getTotalOrderPrice()) // 응답 DTO에 총 주문 가격 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 응답 DTO에 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 응답 DTO에 최종 주문 가격 설정
.paymentStatus(OrderStatusEnum.Status.PAYMENT_FAILED) // 결제 실패 상태 설정
.build();
}
}
1-3. 상품 주문(기본) 구현
전체코드입니다.
// OrderController
// 상품 주문
@PostMapping("/order/pay")
public ApiResponse<OrderProductPayResponseDto> postOrderProductPay(
@CookieValue("${AUTHORIZATION_HEADER}") String userToken,
@RequestBody OrderProductPayRequestDto orderProductPayRequestDto
) {
return ApiResponse.createSuccess(orderService.postOrderProductPay(userToken, orderProductPayRequestDto));
}
// OrderService
@Transactional
public OrderProductPayResponseDto postOrderProductPay(String userToken, OrderProductPayRequestDto orderProductPayRequestDto) {
// 1. 토큰에서 사용자 정보 추출
userToken = jwtUtil.substringToken(userToken);
String memberId = jwtUtil.getMemberInfoFromToken(userToken).getSubject();
Member member = memberRepository.findByMemberId(memberId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다."));
// 2. 주문 엔티티 생성 및 저장
OrderRequestDto orderRequestDto = OrderRequestDto.builder()
.orderDate(LocalDateTime.now()) // 주문 날짜를 현재 시각으로 설정
.orderStatus(OrderStatusEnum.PAYING) // 초기 주문 상태를 결제 중으로 설정
.orderPayYN(false) // 결제 완료 여부를 false로 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 주문 총 가격 설정
.orderCardCompany(OrderCardCompanyEnum.valueOf(orderProductPayRequestDto.getOrderCardCompany().toUpperCase())) // 카드 회사 설정
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 배송 요청사항 설정
.member(member) // 주문자 정보를 member 객체로 설정
.build();
Order order = Order.from(orderRequestDto); // OrderRequestDto 객체를 Order 엔티티로 변환
orderRepository.save(order); // Order 엔티티를 데이터베이스에 저장
try {
// 3. 주문 상품 엔티티 생성 및 저장
List<OrderProduct> orderProducts = orderProductPayRequestDto.getOrderProducts().stream().map(orderProductDto -> {
// 4. 상품 조회
Product product = productRepository.findById(orderProductDto.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
// 5. 옵션이 있는 경우 옵션 조회
ProductOption productOption = null;
int optionPrice = 0;
if (orderProductDto.getProductOptionId() != null) {
productOption = product.getProductOptions().stream()
.filter(option -> option.getProductOptionId().equals(orderProductDto.getProductOptionId()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
optionPrice = productOption.getProductOptionPrice(); // 상품 옵션 가격 설정
}
// 6. OrderProduct 엔티티 생성
return OrderProduct.from(OrderProductRequestDto.builder()
.order(order) // 주문 정보 설정
.product(product) // 상품 정보 설정
.orderProductQuantity(orderProductDto.getQuantity()) // 주문 상품 수량 설정
.orderProductPrice(product.getProductPrice()) // 주문 상품 가격 설정
.orderProductOptionId(productOption != null ? productOption.getProductOptionId() : 0) // 주문 상품 옵션 설정
.orderProductOptionPrice(optionPrice) // 주문 상품 옵션 가격 설정
.build());
}).collect(Collectors.toList());
orderProductRepository.saveAll(orderProducts); // OrderProduct 엔티티를 데이터베이스에 저장
// 7. 결제 성공 시 주문 상태 업데이트 및 저장
order.setOrderStatus(OrderStatusEnum.PAYMENT_COMPLETED); // 주문 상태를 결제 완료로 업데이트
order.setOrderPayYN(true); // 결제 완료 여부를 true로 설정
orderRepository.save(order); // 업데이트된 주문 정보를 데이터베이스에 저장
// 8. 결제 완료 응답 반환
return OrderProductPayResponseDto.builder()
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 응답 DTO에 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 응답 DTO에 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 응답 DTO에 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 응답 DTO에 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 응답 DTO에 배송 요청사항 설정
.orderCardCompany(orderProductPayRequestDto.getOrderCardCompany()) // 응답 DTO에 카드 회사 설정
.totalOrderPrice(orderProductPayRequestDto.getTotalOrderPrice()) // 응답 DTO에 총 주문 가격 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 응답 DTO에 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 응답 DTO에 최종 주문 가격 설정
.paymentStatus(OrderStatusEnum.Status.PAYMENT_COMPLETED) // 결제 성공 상태 설정
.build();
} catch (Exception e) {
// 9. 결제 실패 시 주문 상태 업데이트 및 저장
order.setOrderStatus(OrderStatusEnum.PAYMENT_FAILED); // 주문 상태를 결제 실패로 업데이트
orderRepository.save(order); // 업데이트된 주문 정보를 데이터베이스에 저장
// 10. 결제 실패 응답 반환
return OrderProductPayResponseDto.builder()
.orderMemberName(orderProductPayRequestDto.getOrderMemberName()) // 응답 DTO에 주문자 이름 설정
.orderZipCode(orderProductPayRequestDto.getOrderZipCode()) // 응답 DTO에 주문자 우편번호 설정
.orderAddress(orderProductPayRequestDto.getOrderAddress()) // 응답 DTO에 주문자 주소 설정
.orderPhone(orderProductPayRequestDto.getOrderPhone()) // 응답 DTO에 주문자 전화번호 설정
.orderReq(orderProductPayRequestDto.getOrderReq()) // 응답 DTO에 배송 요청사항 설정
.orderCardCompany(orderProductPayRequestDto.getOrderCardCompany()) // 응답 DTO에 카드 회사 설정
.totalOrderPrice(orderProductPayRequestDto.getTotalOrderPrice()) // 응답 DTO에 총 주문 가격 설정
.orderShippingFee(orderProductPayRequestDto.getOrderShippingFee()) // 응답 DTO에 배송비 설정
.orderPrice(orderProductPayRequestDto.getOrderPrice()) // 응답 DTO에 최종 주문 가격 설정
.paymentStatus(OrderStatusEnum.Status.PAYMENT_FAILED) // 결제 실패 상태 설정
.build();
}
}
2. 동시성 제어
2-1. 고민
DB 접근이 많은 것에 대한 해결방안 고민
DB 접근이 너무 많다 => Map으로 해결?!
현재 작성된 코드는 DB접근이 너무 많습니다. for문마다 repository 접근을 하기 때문에 성능이 매우 떨어지고 있습니다.(더보기1 참고)
[더보기1] - 현재 코드
- 각각의 상품과 옵션을 개별적으로 조회:
- for 루프 내에서 각 상품과 상품 옵션을 개별적으로 조회하여 처리합니다. 이를 통해 상품 및 옵션의 재고를 확인하고 업데이트합니다.
- 상품 및 옵션의 재고를 업데이트한 후에는 개별적으로 저장합니다.
- 재고 복원 처리:
- 결제 실패 시 try-catch 블록 내에서 개별적으로 상품 및 옵션의 재고를 원래 상태로 복원합니다.
- 동시성 문제:
- HashMap을 사용하여 원래 재고를 저장하고, 이를 통해 재고를 복원하지만 동시성 문제를 방지하는 특별한 처리가 없습니다.
@Transactional
public OrderProductPayResponseDto postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
// 1. 주문 생성 및 저장
Order order = Order.from(OrderRequestDto.from(orderProductPayRequestDto, OrderStatusEnum.PAYING, aes256Encoder, member));
orderRepository.save(order);
// 2. 주문 제품 리스트 및 원래 재고 기록할 맵 초기화
List<OrderProduct> orderProductList = new ArrayList<>();
Map<OrderProduct, Integer> originalProductStocks = new HashMap<>();
Map<OrderProduct, Integer> originalOptionStocks = new HashMap<>();
// 3. 주문 제품 리스트를 순회하면서 처리
for (OrderProductResponseDto orderProductDto : orderProductPayRequestDto.getOrderProducts()) {
// 4. 제품을 DB에서 조회
Product product = productRepository.findById(orderProductDto.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
// 5. 옵션이 있는 경우 옵션을 DB에서 조회
ProductOption productOption = null;
if (orderProductDto.getProductOptionId() != null) {
productOption = productOptionRepository.findById(orderProductDto.getProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
}
// 6. 주문 제품 리스트에 추가
orderProductList.add(OrderProduct.from(OrderProductRequestDto.from(order, product, orderProductDto.getQuantity(), productOption)));
}
// 7. 주문 제품 리스트를 DB에 저장
orderProductRepository.saveAll(orderProductList);
try {
// 8. 재고 업데이트
orderProductList.forEach(orderProduct -> {
Product product = orderProduct.getProduct();
originalProductStocks.put(orderProduct, product.getProductStock()); // 원래 product 재고 기록
if (orderProduct.getOrderProductOptionId() != 0) {
ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
originalOptionStocks.put(orderProduct, productOption.getProductOptionStock()); // 원래 product option 재고 기록
// 9. 옵션 재고 부족 검사 및 차감
if (productOption.getProductOptionStock() < orderProduct.getOrderProductQuantity()) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
productOption.setProductOptionStock(productOption.getProductOptionStock() - orderProduct.getOrderProductQuantity());
productOptionRepository.save(productOption);
} else {
// 10. 제품 재고 부족 검사 및 차감
if (product.getProductStock() < orderProduct.getOrderProductQuantity()) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
product.setProductStock(product.getProductStock() - orderProduct.getOrderProductQuantity());
productRepository.save(product);
}
});
// 11. 결제 성공 시 주문 상태 업데이트 및 저장
order.setOrderStatus(OrderStatusEnum.PAYMENT_COMPLETED); // 주문 상태를 결제 완료로 업데이트
order.setOrderPayYN(true); // 결제 완료 여부를 true로 설정
orderRepository.save(order); // 업데이트된 주문 정보를 데이터베이스에 저장
// 12. 결제 완료 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_COMPLETED
);
} catch (Exception e) {
// 13. 결제 실패 시 재고 원상 복구 및 상태 업데이트
orderProductList.forEach(orderProduct -> {
Product product = orderProduct.getProduct();
if (originalProductStocks.containsKey(orderProduct)) {
product.setProductStock(originalProductStocks.get(orderProduct)); // product 재고 원상 복구
productRepository.save(product);
}
if (orderProduct.getOrderProductOptionId() != 0) {
ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
if (originalOptionStocks.containsKey(orderProduct)) {
productOption.setProductOptionStock(originalOptionStocks.get(orderProduct)); // product option 재고 원상 복구
productOptionRepository.save(productOption);
}
}
});
// 14. 주문 상태를 결제 실패로 업데이트 및 저장
order.setOrderStatus(OrderStatusEnum.PAYMENT_FAILED);
orderRepository.save(order);
// 15. 결제 실패 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_FAILED
);
}
}
이를 해결하고자, 주문 시 최초에 repository에서 필요한 값을 받아놓고 Map으로 캐싱할 수 있을 것 같은데, 이러면 다른 사용자가 데이터를 중간에 변경시킨 경우 동시성 제어 이슈가 발생합니다.(더보기2 참고)
[더보기2] - Map 적용
- 상품 및 옵션의 미리 로드:
- 첫 번째 for 루프에서 모든 필요한 상품과 옵션을 미리 로드하여 캐시에 저장합니다. 이를 통해 후속 재고 업데이트 단계에서 각 상품 및 옵션을 빠르게 참조할 수 있습니다.
- 재고 업데이트와 복원 메서드 분리:
- updateStocks 메서드를 통해 재고를 일괄적으로 업데이트하고, restoreStocks 메서드를 통해 재고를 일괄적으로 복원합니다.
- 재고를 업데이트하거나 복원할 때, 캐시된 값을 사용하여 변경된 데이터를 일괄적으로 저장합니다.
- 동시성 문제 해결:
- HashMap을 사용하여 캐시를 구현하였으나, 동시성 문제를 해결하는 특별한 처리가 없습니다. 다만, 캐시를 사용하여 필요한 데이터를 미리 로드하고 일괄 처리하는 방식은 일부 성능 개선을 기대할 수 있습니다.
@Transactional
public OrderProductPayResponseDto postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
try {
// 1. 주문 생성 및 저장
Order order = Order.from(OrderRequestDto.from(orderProductPayRequestDto, OrderStatusEnum.PAYING, aes256Encoder, member));
orderRepository.save(order);
// 2. 주문 제품 리스트 및 캐시 초기화
List<OrderProduct> orderProductList = new ArrayList<>();
Map<OrderProduct, Integer> originalProductStocks = new HashMap<>();
Map<OrderProduct, Integer> originalOptionStocks = new HashMap<>();
Map<Long, Product> productCache = new HashMap<>();
Map<Long, ProductOption> productOptionCache = new HashMap<>();
// 3. 필요한 모든 제품과 옵션 미리 로드
for (OrderProductResponseDto orderProductDto : orderProductPayRequestDto.getOrderProducts()) {
// 4. 제품을 캐시에서 찾거나 DB에서 조회
Product product = productCache.computeIfAbsent(orderProductDto.getProductId(), id ->
productRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."))
);
// 5. 옵션을 캐시에서 찾거나 DB에서 조회
ProductOption productOption = null;
if (orderProductDto.getProductOptionId() != null) {
productOption = productOptionCache.computeIfAbsent(orderProductDto.getProductOptionId(), id ->
productOptionRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."))
);
}
// 6. 주문 제품 리스트에 추가
orderProductList.add(OrderProduct.from(OrderProductRequestDto.from(order, product, orderProductDto.getQuantity(), productOption)));
}
// 7. 주문 제품 리스트 저장
orderProductRepository.saveAll(orderProductList);
try {
// 8. 재고 업데이트
updateStocks(orderProductList, originalProductStocks, originalOptionStocks, productOptionCache, productCache);
// 9. 주문 상태 업데이트 및 저장
order.setOrderStatus(OrderStatusEnum.PAYMENT_COMPLETED);
order.setOrderPayYN(true);
orderRepository.save(order);
// 10. 결제 완료 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_COMPLETED
);
} catch (Exception e) {
// 11. 재고 복원 및 주문 상태 업데이트
restoreStocks(orderProductList, originalProductStocks, originalOptionStocks, productOptionCache, productCache);
order.setOrderStatus(OrderStatusEnum.PAYMENT_FAILED);
orderRepository.save(order);
// 12. 결제 실패 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_FAILED
);
}
} catch (Exception e) {
System.out.println("e = " + e);
// 13. 최종적으로 결제 실패 예외 던짐
throw new IllegalArgumentException("상품 주문 실패");
}
}
private void updateStocks(List<OrderProduct> orderProductList, Map<OrderProduct, Integer> originalProductStocks, Map<OrderProduct, Integer> originalOptionStocks, Map<Long, ProductOption> productOptionCache, Map<Long, Product> productCache) {
// 1. 주문 제품 리스트 순회하면서 재고 업데이트
orderProductList.forEach(orderProduct -> {
Product product = orderProduct.getProduct();
originalProductStocks.put(orderProduct, product.getProductStock());
if (orderProduct.getOrderProductOptionId() != 0) {
ProductOption productOption = productOptionCache.get(orderProduct.getOrderProductOptionId());
originalOptionStocks.put(orderProduct, productOption.getProductOptionStock());
// 2. 옵션 재고 부족 시 예외 발생
if (productOption.getProductOptionStock() < orderProduct.getOrderProductQuantity()) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
// 3. 옵션 재고 차감
productOption.setProductOptionStock(productOption.getProductOptionStock() - orderProduct.getOrderProductQuantity());
} else {
// 4. 제품 재고 부족 시 예외 발생
if (product.getProductStock() < orderProduct.getOrderProductQuantity()) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
// 5. 제품 재고 차감
product.setProductStock(product.getProductStock() - orderProduct.getOrderProductQuantity());
}
});
// 6. 변경된 데이터를 일괄 저장
productRepository.saveAll(productCache.values());
productOptionRepository.saveAll(productOptionCache.values());
}
private void restoreStocks(List<OrderProduct> orderProductList, Map<OrderProduct, Integer> originalProductStocks, Map<OrderProduct, Integer> originalOptionStocks, Map<Long, ProductOption> productOptionCache, Map<Long, Product> productCache) {
// 1. 주문 제품 리스트 순회하면서 재고 복원
orderProductList.forEach(orderProduct -> {
Product product = orderProduct.getProduct();
if (originalProductStocks.containsKey(orderProduct)) {
product.setProductStock(originalProductStocks.get(orderProduct));
}
if (orderProduct.getOrderProductOptionId() != 0) {
ProductOption productOption = productOptionCache.get(orderProduct.getOrderProductOptionId());
if (originalOptionStocks.containsKey(orderProduct)) {
productOption.setProductOptionStock(originalOptionStocks.get(orderProduct));
}
}
});
// 2. 변경된 데이터를 일괄 저장
productRepository.saveAll(productCache.values());
productOptionRepository.saveAll(productOptionCache.values());
}
차이점 정리:
- 데이터 접근 방식:
- 더보기1은 각각의 상품 및 옵션을 개별적으로 조회하고 처리합니다.
- 더보기2는 미리 모든 데이터를 로드하고 캐시를 통해 처리합니다.
- 코드 구조:
- 더보기1은 재고 업데이트와 복원을 try-catch 블록 내에서 직접 처리합니다.
- 더보기2는 재고 업데이트와 복원을 별도의 메서드로 분리하여 처리합니다.
- 동시성 문제 처리:
- 두 코드 모두 동시성 문제를 명시적으로 해결하지 않습니다. 하지만 두 번째 코드는 데이터 접근을 미리 로드한 캐시를 통해 처리하여 일부 성능 개선을 기대할 수 있습니다.
- 성능 및 효율성:
- 더보기2는 데이터를 미리 로드하고 캐시를 통해 처리하여, 반복적인 데이터베이스 접근을 줄임으로써 성능을 개선할 수 있습니다.
- 더보기1은 데이터베이스 접근이 반복적으로 발생할 수 있어 성능 면에서 비효율적일 수 있습니다.
결론적으로, 더보기2는 더보기1에 비해 구조적으로 더 깔끔하고, 성능 개선을 위한 캐싱 기법을 사용하고 있습니다. 하지만 동시성 문제를 해결하기 위한 추가적인 조치가 필요할 수 있습니다.
동시성 이슈 해결방안 고민
동시에 여러개의 주문이 들어오면 재고가 안맞는다 => 락 적용
2-1-1. DB Lock을 건다.(비관적 락/PESSIMISTIC_WRITE 락) => 채택!
- 구현 방법: Spring Data JPA에서 @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용하여 구현합니다.
- 장점: 간단하게 데이터베이스 수준에서 동시성 이슈를 해결할 수 있습니다. 다른 트랜잭션이 동시에 같은 데이터를 수정할 수 없도록 막습니다.
- 단점: 트랜잭션이 직렬적으로 처리되기 때문에 성능 저하가 발생할 수 있습니다. 특히, 대기 시간이 길어질 수 있습니다. 이는 결국 Spring의 병렬 처리 능력을 활용하지 않겠다는 의미로, 직렬 처리로 인해 성능이 저하될 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
public Optional<Product> findByIdWithLock(Long id);
2-1-2. DB Lock을 건다.(낙관적 락/@Version이용)
- 구현 방법:2 @Version 어노테이션을 사용하여 엔티티에 버전 필드를 추가합니다. 버전 번호를 통해 동시성 충돌을 감지하고 처리합니다.
- 장점: 비관적 락에 비해 성능이 좋습니다. 트랜잭션이 충돌하는 경우에만 재시도 로직을 추가하면 됩니다.
- 단점: 동시성 충돌이 빈번하게 발생하는 경우 재시도 로직으로 인해 서버 부하가 증가할 수 있습니다. 첫 번째 요청은 성공하고 동시에 들어온 나머지 요청에 대해서는 에러를 발생시키는 것이기 때문에, 재시도 로직이 필수적입니다. 서버에 계속 부하를 일으킬 수 있습니다.
@Entity
public class Product {
@Id
private Long id;
@Version
private Long version;
}
2-1-3. 상품 재고를 redis로 관리한다.
- 구현 방법: Redis의 싱글 스레드 특성을 활용하여 동시성 문제를 해결합니다.
- 장점: Redis는 초당 20만 건의 데이터를 처리할 수 있어 사실상 문제가 없어질 수 있습니다.
- 단점: Redis는 메모리 기반 저장소이기 때문에 데이터 유실의 위험이 있습니다. 이를 해결하기 위해 데이터의 지속성을 보장하는 추가적인 설정이 필요합니다. Redis는 싱글 스레드기 때문에 동시에 접근이 불가능하지만, 이는 Redis 서버가 절대 터지면 안 된다는 전제를 갖습니다. 데이터 유실 방지를 위해 AWS 엘라스틱 캐시 등에서 서버리스 옵션을 활용할 수 있습니다.
[참고 자료]
- 비관적락, 낙관적락: https://velog.io/@chullll/JPA-잠금의-종류
- 비관적락 적용: https://youtube.com/watch?v=rdbCwjct7h0&si=PKC56m9DHe34eD-h
- 낙관적락 적용: https://devoong2.tistory.com/entry/JPA-에서-낙관적-락Optimistic-Lock을-이용해-동시성-처리하기
- Redis, 우아한테크세미나: https://youtube.com/watch?v=mPB2CZiAkKM&si=a6zVKlsO7jy_kjnx
- Redis, 우아한 10분 테크톡: https://youtu.be/tVZ15cCRAyE?si=7xxWgcgQH8YpPsPf
- 번외) 정책으로 해결하기 등: https://youtube.com/live/44SD9IKuK3w?si=zOBn-q4O66QNSmVk
2-2. 동작 방식
해결 방안 및 구현 정리
- DB 접근이 많은 것에 대한 해결 방안 고민:
- Map으로 캐싱: DB 접근을 줄이기 위해 캐싱을 고려했으나, 이는 의미가 없을 것 같아 채택하지 않았습니다.
- 동시성 이슈 해결 방안 고민:
- 비관적 락(Pessimistic Lock): 비관적 락을 사용하여 동시성 문제를 해결하는 방안을 채택했습니다.
구현 내용
- 상품, 상품 옵션 하나하나에 락을 걸어 재고 및 옵션 재고 변경:
- 각 상품 및 상품 옵션에 대해 락을 걸고, 재고와 옵션 재고를 변경하도록 구현했습니다.
- 상품의 재고를 상품과 상품 옵션 테이블 2곳에서 관리를 하고 있기 때문에 락을 2번 걸도록 했었습니다.
- 결제 실패 시 에러 반환:
- 기존에는 결제 실패에 대해 DB에 건건이 저장했으나, 트랜잭션으로 묶인 상황에서 결제 실패 부분은 DB에 저장할 필요가 없다고 판단하여, 결제 실패 시 에러를 반환하도록 변경했습니다.
- Exception 처리 문제:
- throw exception이 실행될 때 catch문에서 따로 throw하지 않으면 Setter로 변경한 값이 원래대로 돌아오지 않는 문제를 발견했습니다.
2-3. 동시성 제어 비관적 락 구현
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.productId = :productId")
Optional<Product> findByIdForUpdate(@Param("productId") Long productId);
}
public interface ProductOptionRepository extends JpaRepository<ProductOption, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM ProductOption p WHERE p.productOptionId = :productOptionId")
Optional<ProductOption> findByIdForUpdate(@Param("productOptionId") Long productOptionId);
}
@Transactional
public OrderProductPayResponseDto postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
try {
// 1. 주문 제품 리스트 생성
List<OrderProduct> orderProductList = new ArrayList<>();
// 2. 요청된 주문 제품 리스트를 순회하면서 처리
orderProductPayRequestDto.getOrderProductList().forEach(orderProduct -> {
// 3. 해당 제품을 DB에서 락을 걸어 조회
Product product = productRepository.findByIdForUpdate(orderProduct.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
// 4. 제품 재고가 주문 수량보다 적으면 예외 발생
if (product.getProductStock() < orderProduct.getQuantity()) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
ProductOption productOption = null;
// 5. 제품 옵션이 있는 경우 처리
if(orderProduct.getProductOptionId() != null) {
// 6. 해당 제품 옵션을 DB에서 락을 걸어 조회
productOption = productOptionRepository.findByIdForUpdate(orderProduct.getProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
// 7. 제품 옵션 재고가 주문 수량보다 적으면 예외 발생
if (productOption.getProductOptionStock() < orderProduct.getQuantity()) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
// 8. 제품 옵션 재고 차감
productOption.setProductOptionStock(productOption.getProductOptionStock() - orderProduct.getQuantity());
}
// 9. 제품 재고 차감
product.setProductStock(product.getProductStock() - orderProduct.getQuantity());
// 10. 주문 제품 리스트에 추가
orderProductList.add(OrderProduct.from(OrderProductRequestDto.from(product, orderProduct.getQuantity(), productOption)));
});
// 11. 주문 생성 및 DB에 저장
Order order = Order.from(OrderRequestDto.from(orderProductPayRequestDto, OrderStatusEnum.PAYMENT_COMPLETED, aes256Encoder, member));
orderRepository.save(order);
// 12. 주문 제품 리스트에 주문 정보 설정
orderProductList.forEach(orderProduct -> orderProduct.setOrder(order));
// 13. 주문 제품 리스트를 DB에 저장
orderProductRepository.saveAll(orderProductList);
// 14. 결제 완료 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_COMPLETED
);
} catch (Exception e) {
System.out.println("e = " + e);
// 15. 예외 발생 시 결제 실패 응답 반환
throw new IllegalArgumentException("상품 주문 실패");
}
}
2-4. 구현 결과
5,000건의 주문에 대해 최종적으로 약 4분이 경과되었습니다.
3. Redis 재고관리
3-1. 고민
- 재고 관리 문제:
- 상품 재고와 상품 옵션 재고를 따로 관리하다 보니, 대량의 요청이 들어왔을 때 같은 상품에 대해 다른 옵션에 대한 주문이 들어오는 경우 재고 계산이 제대로 되지 않고 재고가 더 차감되는 상황이 발생했습니다.
- 재고 데이터 정합성 문제:
- 상품 재고와 옵션 재고를 이중으로 관리하면 데이터 정합성을 맞춰야 하므로 로직이 복잡해집니다. 따라서 상품 테이블에서 재고 컬럼을 삭제하고, 옵션 재고만으로 관리하도록 변경하는 작업이 필요합니다.
- 응답 시간 문제:
- 사용자에게 응답되는 시간이 너무 느리다는 문제(5,000건에 대해 4분)가 있었습니다. 이를 해결하기 위해 Redis를 도입했습니다. Redis는 싱글 스레드로 동작하기 때문에 동시성 문제가 없으며, 재고를 Redis에서 차감한 후 실제 데이터베이스 업데이트는 비동기로 처리하여 응답 속도를 크게 향상시킬 수 있습니다.
3-2. 동작 방식
1. 요청된 주문 상품 리스트를 ProductOption으로만 재고 관리하며 처리:
- orderProductPayRequestDto.getOrderProductList().forEach(orderProduct -> {...});를 사용하여 요청된 각 주문 상품을 순회하면서 처리합니다.
- 재고 관리는 ProductOption 테이블을 기준으로만 수행하여, 옵션별 재고를 정확하게 관리합니다.
2. Redis에서 재고 차감:
- redisService.deductStock 메서드를 호출하여 Redis에서 재고를 차감합니다.
- (key) productOptionId : {id} (value) productOptionStock: {stock}
- 이를 통해 사용자에게 빠른 응답을 제공합니다. 만약 재고가 부족한 경우 IllegalArgumentException 예외를 발생시킵니다.
3. 비동기로 주문 상품 결제 처리:
- 실제 데이터베이스에 주문 정보를 반영하는 작업은 orderProductService.postOrderProductPay(member, orderProductPayRequestDto);를 비동기로 호출하여 처리합니다.
- 이를 통해 메인 프로세스의 응답 속도를 향상시키고, 사용자에게 빠른 응답을 제공하면서 백그라운드에서 안전하게 데이터베이스 작업을 수행합니다.
4. 결제 완료 응답 반환:
- OrderProductPayResponseDto.from 메서드를 사용하여 결제 완료 정보를 담은 응답 객체를 생성하고 반환합니다.
5. 예외 발생 시 에러 로그 출력 및 예외 다시 던짐:
- 예외가 발생한 경우 에러 로그를 출력하고, 예외를 다시 던져 트랜잭션 롤백을 유도하여 데이터 일관성을 유지합니다.
Redis에서 재고 차감을 통해 사용자에게 빠른 응답을 제공합니다. 실제 데이터베이스 업데이트는 비동기로 처리하여 메인 프로세스의 응답 속도를 향상시킵니다. 이를 통해 사용자 경험을 개선하면서도 백그라운드에서 안전하게 데이터베이스 작업을 수행합니다.
3-3. Redis 구현
// OrderService
@Transactional
public OrderProductPayResponseDto postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
try {
// 1. 요청된 주문 상품 리스트를 순회하며 처리
orderProductPayRequestDto.getOrderProductList().forEach(orderProduct -> {
// 2. Redis에서 재고 차감
if (!redisService.deductStock(orderProduct.getProductOptionId(), orderProduct.getQuantity())) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
});
// 3. 비동기로 주문 상품 결제 처리
orderProductService.postOrderProductPay(member, orderProductPayRequestDto);
// 4. 결제 완료 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_COMPLETED
);
} catch (Exception e) {
// 5. 예외 발생 시 에러 로그 출력 및 예외 다시 던짐
System.out.println("e = " + e);
throw e;
}
}
// OrderProductService
@Async
@Transactional
public void postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
try {
// 1. 주문 상품 리스트 초기화
List<OrderProduct> orderProductList = new ArrayList<>();
// 2. 요청된 주문 상품 리스트를 순회하며 처리
orderProductPayRequestDto.getOrderProductList().forEach(orderProduct -> {
// 3. 상품 옵션 조회 및 재고 확인
ProductOption productOption = productOptionRepository.findByIdForUpdate(orderProduct.getProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
if (productOption.getProductOptionStock() < orderProduct.getQuantity()) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
// 4. 상품 옵션 재고 업데이트
productOptionRepository.updateStock(orderProduct.getProductOptionId(), productOption.getProductOptionStock() - orderProduct.getQuantity());
// 5. 상품 조회
Product product = productRepository.findByProductId(orderProduct.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
// 6. 주문 상품 리스트에 추가
orderProductList.add(OrderProduct.from(OrderProductRequestDto.from(product, orderProduct.getQuantity(), productOption)));
});
// 7. 주문 엔티티 생성 및 저장
Order order = Order.from(OrderRequestDto.from(orderProductPayRequestDto, OrderStatusEnum.PAYMENT_COMPLETED, aes256Encoder, member));
orderRepository.save(order);
// 8. 각 주문 상품에 주문 정보 설정
orderProductList.forEach(orderProduct -> orderProduct.setOrder(order));
// 9. 주문 상품 리스트 저장
orderProductRepository.saveAll(orderProductList);
} catch (Exception e) {
// 10. 예외 발생 시 에러 로그 출력 및 예외 다시 던짐
System.out.println("e = " + e);
throw e;
}
}
4. Redis 동시성 제어
4-1. Redissen
4-1-1. 고민
처음에는 Redis를 사용했는데, 자꾸 DB보다 Redis에 값이 덜 빠지는 문제가 발생했습니다. Redis에도 락을 걸어야 할 것 같았습니다. Redis가 싱글 스레드로 동작한다고 해서 완전히 동시성 문제를 보장해주지 않는 것 같습니다.
왜 안됐을까? (Redis의 특징)
Redis의 특징을 다시 살펴보았습니다.
- 싱글 스레드: Redis는 기본적으로 싱글 스레드로 동작합니다. 이는 명령어의 실행이 순차적으로 이루어져서 데이터의 일관성을 유지하는 데 유리합니다.
- Event Loop 사용: Redis는 Event Loop를 이용하여 비동기적으로 요청을 처리합니다.
- 멀티플렉싱: 실제 명령어 처리 시 커널 I/O 레벨에서는 멀티플렉싱을 통해 여러 클라이언트의 요청을 동시에 처리할 수 있습니다.
- 동시성 문제: 유저 레벨에서는 싱글 스레드로 동작하지만, 커널 I/O 레벨에서는 스레드 풀을 이용합니다. 동시성을 보장한다는 것은 여러 요청이 동시에 처리될 수 있음을 의미하며, 이로 인해 동시성 문제가 발생할 수 있습니다. 따라서 동시성 문제에 대한 처리가 필요했습니다.
Redisson 적용으로 해결
동시성 문제를 해결하기 위해 Redis에도 락을 걸어야 한다고 판단하여, Redisson을 도입하게 되었습니다. Redisson은 Redis 기반의 분산 락을 제공하여 동시성 문제를 효과적으로 처리할 수 있게 해줍니다.
Redisson을 도입한 이유:
- 분산 락: Redisson은 Redis를 이용한 분산 락을 제공하여, 여러 프로세스에서 동시에 접근하는 문제를 해결할 수 있습니다.
- 신뢰성: 분산 환경에서 신뢰성 있는 락 메커니즘을 제공하여 데이터의 일관성을 유지합니다.
- 간편성: Redisson을 사용하면 복잡한 동시성 문제를 간편하게 해결할 수 있습니다.
Redis의 싱글 스레드 특성만으로는 동시성 문제를 완전히 해결할 수 없었습니다. Redis의 내부 동작 방식으로 인해 동시성 문제가 발생할 수 있었고, 이를 해결하기 위해 Redisson을 도입하여 Redis에 락을 걸어 동시성 문제를 해결하는 방식을 구상했습니다. Redisson을 통해 분산 락을 구현함으로써, 여러 요청이 동시에 처리될 때 발생하는 재고 차감 문제를 효과적으로 해결할 수 있다고 판단했습니다.
[참고 자료]
Redis 분산락 적용: https://velog.io/@korea3611/Spring-boot-좋아요수-증가-분산락을-이용하여-동시성-제어하기-redis활용하기
Redisson 분산락: https://innovation123.tistory.com/185
Redisson, Spring: https://velog.io/@profoundsea25/Spring에서-Redis-분산-락-적용하기-Redisson-사용
4-1-2. 동작 방식
- Redis 초기 재고 설정 및 관리:
- RedisService에서 Redis에 상품 옵션의 초기 재고를 설정하고, 현재 재고를 조회하며, 재고를 차감합니다.
- Redisson 클라이언트 설정:
- RedisConfig에서 Redis 서버에 연결하기 위한 RedissonClient를 설정하고 생성합니다.
- 주문 처리:
- OrderService에서 주문 처리 로직을 수행합니다.
- 요청된 주문 상품 리스트를 순회하면서 Redis에서 재고를 차감합니다.
- Redis에서 재고 차감이 성공하면 비동기로 데이터베이스의 재고를 업데이트하고 주문 객체를 생성합니다.
- 주문 엔티티를 생성하여 데이터베이스에 저장하고, 각 주문 상품에 주문 정보를 설정합니다.
- 최종적으로 결제 완료 응답을 반환합니다.
- 비동기 처리:
- updateProductOptionStockAsync 메서드를 통해 데이터베이스의 상품 옵션 재고를 비동기로 업데이트합니다.
- createOrderProductAsync 메서드를 통해 비동기로 주문 상품 객체를 생성합니다.
Redis를 사용하여 빠른 응답을 제공하고, Redisson을 통해 동시성 문제를 해결합니다. Redis에서 재고 차감 후 비동기로 데이터베이스를 업데이트하여 주문 처리를 효율적으로 수행합니다.
4-1-3. Redissen 적용
@Configuration
public class RedisConfig {
// 1. REDIS_HOST 값을 애플리케이션 설정 파일에서 불러옴
@Value("${REDIS_HOST}")
private String host;
// 2. REDIS_PORT 값을 애플리케이션 설정 파일에서 불러옴
@Value("${REDIS_PORT}")
private int port;
// 3. Redis 연결 주소의 접두사로 사용할 상수 선언
private static final String REDISSON_HOST_PREFIX = "redis://";
// 4. RedissonClient 빈 생성 메서드
@Bean
public RedissonClient redissonClient(){
// 5. Redisson 설정 객체 생성
Config config = new Config();
// 6. 단일 서버 구성 사용 및 Redis 서버 주소 설정
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
// 7. RedissonClient 생성 및 반환
return Redisson.create(config);
}
// 5. RedisTemplate 코드(생략)
}
@Service
public class RedisService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 1. 초기 재고 설정 메서드
public void setInitialStock(Long productOptionId, int stock) {
// 2. Redis에 초기 재고 설정
redisTemplate.opsForValue().set("productOption:" + productOptionId, String.valueOf(stock));
}
// 3. 현재 재고 조회 메서드
public int getStock(Long productOptionId) {
// 4. Redis에서 재고 값을 가져와서 정수로 변환하여 반환
return Integer.parseInt(redisTemplate.opsForValue().get("productOption:" + productOptionId));
}
// 5. 재고 설정 메서드
public void setStock(Long productOptionId, int stock) {
// 6. Redis에 재고 값을 설정
redisTemplate.opsForValue().set("productOption:" + productOptionId, String.valueOf(stock));
}
// 7. 재고 차감 메서드
public boolean deductStock(Long productOptionId, int quantity) {
// 8. Redis에서 재고 키 생성
String key = "productOption:" + productOptionId;
// 9. 현재 재고를 Redis에서 가져와서 정수로 변환
int currentStock = Integer.parseInt(redisTemplate.opsForValue().get(key));
// 10. 재고가 충분한지 확인
if (currentStock >= quantity) {
// 11. 재고 차감
redisTemplate.opsForValue().set(key, String.valueOf(currentStock - quantity));
return true;
} else {
return false;
}
}
}
[OrderService에서 Redissen 사용]
@Service
public class OrderService {
@Autowired
private RedisService redisService;
@Autowired
private ProductOptionRepository productOptionRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderProductRepository orderProductRepository;
@Transactional
public OrderProductPayResponseDto postOrderProductPay(Member member, OrderProductPayRequestDto orderProductPayRequestDto) {
try {
// 1. 주문 상품 리스트 초기화
List<OrderProduct> orderProductList = new ArrayList<>();
// 2. 요청된 주문 상품 리스트를 순회하며 처리
for (OrderProductRequestDto orderProduct : orderProductPayRequestDto.getOrderProductList()) {
Long productOptionId = orderProduct.getProductOptionId();
int quantity = orderProduct.getQuantity();
// 3. Redis에서 재고 차감
if (!redisService.deductStock(productOptionId, quantity)) {
throw new IllegalArgumentException("상품 옵션의 재고가 부족합니다.");
}
// 4. 비동기로 DB 재고 업데이트
updateProductOptionStockAsync(productOptionId, quantity);
// 5. 비동기로 주문 객체 생성
OrderProduct orderProductEntity = createOrderProductAsync(orderProduct);
orderProductList.add(orderProductEntity);
}
// 6. 주문 엔티티 생성 및 저장
Order order = Order.from(OrderRequestDto.from(orderProductPayRequestDto, OrderStatusEnum.PAYMENT_COMPLETED, aes256Encoder, member));
orderRepository.save(order);
// 7. 각 주문 상품에 주문 정보 설정
orderProductList.forEach(orderProduct -> orderProduct.setOrder(order));
// 8. 주문 상품 리스트 저장
orderProductRepository.saveAll(orderProductList);
// 9. 결제 완료 응답 반환
return OrderProductPayResponseDto.from(
orderProductPayRequestDto.getTotalOrderPrice(),
orderProductPayRequestDto.getOrderShippingFee(),
orderProductPayRequestDto.getOrderPrice(),
OrderStatusEnum.PAYMENT_COMPLETED
);
} catch (Exception e) {
// 10. 예외 발생 시 주문 실패 처리
System.out.println("e = " + e);
throw e;
}
}
// 11. 비동기로 DB 재고 업데이트 메서드
@Async
public void updateProductOptionStockAsync(Long productOptionId, int quantity) {
// 12. 상품 옵션 조회
ProductOption productOption = productOptionRepository.findById(productOptionId)
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
// 13. 상품 옵션 재고 차감
productOption.setProductOptionStock(productOption.getProductOptionStock() - quantity);
// 14. 상품 옵션 저장
productOptionRepository.save(productOption);
}
// 15. 비동기로 주문 객체 생성 메서드
@Async
public OrderProduct createOrderProductAsync(OrderProductRequestDto orderProductRequestDto) {
// 16. 상품 조회
Product product = productRepository.findById(orderProductRequestDto.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
// 17. 상품 옵션 조회
ProductOption productOption = productOptionRepository.findById(orderProductRequestDto.getProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
// 18. 주문 상품 객체 생성 및 반환
return OrderProduct.from(OrderProductRequestDto.from(product, orderProductRequestDto.getQuantity(), productOption));
}
}
4-2. INC
4-2-1. 고민
현재 재고 차감 로직은 Redisson을 사용하여 락을 걸고 진행됩니다:
- 순서:
- 락 획득 (lock)
- 재고 조회 (get)
- 재고 설정 (set)
- 락 해제 (unlock)
그런데, 락을 꼭 걸어야 하는 상황인가? 아니다! 굳이 락을 걸지 않아도 됩니다. Redis에서 제공하는 INCR 명령어를 사용하면 됩니다.
Redis INCR 명령어 사용의 장점
- 단일 명령어로 동작:
- INCR 명령어는 원자적(atomic)으로 동작하여 동시성 문제를 해결합니다.
- Redis는 싱글 스레드로 동작하기 때문에 INCR은 안전하게 실행됩니다.
- 락이 필요 없음:
- 락을 걸지 않아도 Redis 자체의 특성으로 인해 동시성 문제가 발생하지 않습니다.
- 원자적 연산:
- INCR 명령어는 하나의 트랜잭션으로 동작하여 get과 set을 분리해서 사용하는 경우 발생할 수 있는 동시성 문제를 방지합니다.
- 음수 값을 파라미터로 넣어 재고를 차감하는 형태로 사용하여, 재고 차감을 원자적으로 처리할 수 있습니다.
❗️ 분산 락은 꼭 필요할 때만 써야 합니다.
최근 취준생분들의 포트폴리오에 분산락이 많이 보입니다.
학습용으로 사용해 본건 좋지만 문제 해결 관점에서
분산락을 어필하기란 쉽지 않을겁니다.
락을 건다는 것은 병목 지점을 만드는 것입니다.
물론 손 쉽게 동시성 문제를 해결할 수 있지만,
락을 걸지 않을 방법이 있다면 쓰지 않는게 낫습니다.
개인적으론 난이도와 성능의 트레이드오프라 생각합니다.
4-2-2. 동작 방식
- 초기 재고 설정: setInitialStock 메서드를 통해 상품 옵션의 초기 재고를 Redis에 설정합니다.
- 재고 차감: deductStock 메서드를 통해 재고를 차감하며, 재고가 부족한 경우 원래 상태로 복원합니다.
두 메서드는 Redis의 원자적 연산을 활용하여 재고 관리의 동시성 문제를 해결하면서도 간단하고 효율적인 방식으로 구현되었습니다.
4-2-3. INC 적용
@Service
@RequiredArgsConstructor
public class RedisService {
private final RedisTemplate<String, String> redisTemplate;
// 1. 초기 재고 설정 메서드
public void setInitialStock(Long productOptionId, int stock) {
// 2. Redis에 초기 재고 설정
redisTemplate.opsForValue().set("productOption:" + productOptionId, String.valueOf(stock));
}
// 3. 재고 차감 메서드
public boolean deductStock(Long productOptionId, int quantity) {
String stockKey = "productOption:" + productOptionId;
// 4. 트랜잭션 없이 INCRBY 명령어를 사용하여 재고 감소
Long stock = redisTemplate.opsForValue().increment(stockKey, -quantity);
// 5. 감소된 결과가 0보다 작은 경우 재고가 부족한 것으로 간주하고 원래 상태로 복원
if (stock != null && stock < 0) {
// 6. 재고 복원
redisTemplate.opsForValue().increment(stockKey, quantity);
return false;
}
return true;
}
}
4-3. 구현 결과
5,000건의 주문에 대해 최종적으로 약 10초가 경과되었습니다.
5. 결과 및 예제(API)
5-1. 결과
5-1-1. DB Rock만 적용
5-1-2. Redis Rock적용
5-1-3. Redis INC적용
5-1-4. 전체 취합
5-2. 예제(API)
아래의 링크에서 더 자세히 보실 수 있습니다.
API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ
6. 이후 추가하고 싶은 기능
6-1. Redis 재고 캐싱 TTL 설정
이전에 상품 게시글에서도 언급했었지만, 재고를 Redis에 캐싱해야합니다. 현재 상품을 추가할 때, 상품의 재고를 바로 Redis에 올리고 있는데, 이는 Redis를 캐싱의 용도가 아니라 DB의 용도로 사용하고 있다고 볼 수 있습니다.
DB의 용도가 아닌, 누군가 상품 주문 요청을 보냈을 때 Redis에 값을 올려두도록 해야합니다.
긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.
'Study > SpringBoot' 카테고리의 다른 글
[SpringBoot] Java로 웹 크롤러 구현하기(feat.네이버 쇼핑 검색 데이터 크롤링하기) (3) | 2024.11.28 |
---|---|
[e-commerce 프로젝트] 7. 상품 주문 - 1(GET 메소드들, DB스케줄링) (1) | 2024.07.30 |
[e-commerce 프로젝트] 6. 위시리스트, 장바구니 (0) | 2024.07.29 |
[e-commerce 프로젝트] 5. 상품(AWS S3, DB Scheduling, Redis) (0) | 2024.07.26 |
[e-commerce 프로젝트] 4. 마이페이지 정보 업데이트 (4) | 2024.07.25 |