-
[appling] 주문 서비스 만들기appling 프로젝트 2024. 9. 19. 11:29728x90반응형
🔴 주문 만들기
🟠 ERD 그리기
🟠 주문 도메인 정의
@Entity @Table(name = "orders") @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Getter public class OrderEntity extends CommonEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "order_id") private Long orderId; @Enumerated(EnumType.STRING) private OrderStatus orderStatus; private String orderName; private String orderContact; private String orderAddress; private String orderAddressDetail; private String orderZipcode; private String recipientName; private String recipientContact; private String recipientAddress; private String recipientAddressDetail; private String recipientZipcode; private int orderAmount; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderProductEntity> orderProductList; @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderDeliveryEntity> orderDeliveryEntityList; }
@Entity @Table(name = "order_product") @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Getter public class OrderProductEntity extends CommonEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long orderProductId; private String orderProductName; private String orderProductOptionName; private int quantity; private int price; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private OrderEntity order; @ManyToOne @JoinColumn(name = "product_option_id") private ProductOptionEntity productOption; }
@Entity @Table(name = "order_delivery") @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Builder @Getter public class OrderDeliveryEntity extends CommonEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long orderDeliveryId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private OrderEntity order; @Enumerated(EnumType.STRING) private OrderDeliveryStatus orderDeliveryStatus; private String invoice; }
public enum OrderStatus { TEMP, COMPLETE, }
public enum OrderDeliveryStatus { DELIVERY, COMPLETE, }
주문과 관련한 Entity와 Enum을 정의했다.
🟠 주문 서비스 로직
🟢 주문 요청과 반환
@Getter @Builder @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostOrderRequest { // 주문 상품을 dto로 리스트로 받아야 함. @NotNull(message = "주문 상품 리스트를 입력해주세요.") @Size(min = 1, max = 10, message = "1~10개의 주문 상품를 입력해세요.") @JsonProperty("order_product_list") @Schema(description = "주문 상품 리스트", example = "주문 상품 리스트") private List<PostOrderDto> orderProductList; @NotNull(message = "주문 상품 개수를 입력해주세요.") @JsonProperty("quantity") @Schema(description = "주문자 이름", example = "주문자") private String orderName; @NotNull @JsonProperty("order_contact") @Schema(description = "주문자 연락처", example = "010-1234-5678") private String orderContact; @NotNull @JsonProperty("order_address") @Schema(description = "주문자 주소", example = "경기도 성남시 분당구 판교역로 231") private String orderAddress; @NotNull @JsonProperty("order_address_detail") @Schema(description = "주문자 상세 주소", example = "H스퀘어 S동 5층") private String orderAddressDetail; @NotNull @JsonProperty("order_zipcode") @Schema(description = "주문자 우편주소", example = "12345") private String orderZipcode; @NotNull @JsonProperty("recipient_name") @Schema(description = "받는사람 이름", example = "받는이") private String recipientName; @NotNull @JsonProperty("recipient_contact") @Schema(description = "받는사람 연락처", example = "010-1234-5678") private String recipientContact; @NotNull @JsonProperty("recipient_address") @Schema(description = "받는사람 주소", example = "경기도 성남시 분당구 판교역로 231") private String recipientAddress; @NotNull @JsonProperty("recipient_address_detail") @Schema(description = "받는사람 상세 주소", example = "H스퀘어 S동 6층") private String recipientAddressDetail; @NotNull @JsonProperty("recipient_zipcode") @Schema(description = "받는사람 우편주소", example = "12345") private String recipientZipcode; }
@Getter @Builder @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PostOrderDto { // todo 주문 상품을 dto로 리스트로 받아야 함. @NotNull(message = "주문 상품 옵션 id를 입력해주세요.") @JsonProperty("product_option_id") @Schema(description = "상품 옵션 번호", example = "1") private Long productOptionId; @NotNull(message = "주문 상품 개수를 입력해주세요.") @JsonProperty("quantity") @Schema(description = "주문 상품 개수", example = "2") private int quantity; }
요청에 대한 정의고
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @Builder @Schema(description = "주문 등록 반환 데이터") public record PostOrderResponse ( @Schema(description = "주문번호", example = "1") Long orderId ){ public static PostOrderResponse from(OrderEntity orderEntity) { return PostOrderResponse.builder() .orderId(orderEntity.getOrderId()) .build(); } }
반환에 대한 정의를 했다.
여기서 요청에 대한 정의를 하다가 보니 controller에서 반환 부분을 리팩토링하게 되었다.
기존에 사용하던
ResponseData
를 수정하였는데@Builder @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record ResponseData<T>( String code, String message, T data ) { public static ResponseData from(ResponseDataCode responseDataCode, Object data) { return ResponseData.<Object>builder() .code(responseDataCode.code) .message(responseDataCode.message) .data(data) .build(); } }
Object로 받아서 사용하고 있기 때문에 타입에 대한 정의가 필요했고
@Builder @JsonInclude(JsonInclude.Include.NON_NULL) @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public record ResponseData<T>( String code, String message, T data ) { public static <T> ResponseData<T> from(ResponseDataCode responseDataCode, T data) { return ResponseData.<T>builder() .code(responseDataCode.code) .message(responseDataCode.message) .data(data) .build(); } }
다음과 같이 리팩토링하여 사용하였다.
🟢 주문 생성 로직
public interface OrderService { PostOrderResponse createOrder(PostOrderRequest postOrderRequest); }
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; private final ProductOptionRepository productOptionRepository; @Transactional @Override public PostOrderResponse createOrder(PostOrderRequest postOrderRequest) { // order 데이터 생성 OrderEntity order = OrderEntity.from(postOrderRequest); // orderProduct 데이터 생성 List<OrderProductEntity> orderProductEntityList = getOrderProductEntityList(postOrderRequest, order); // order product 추가 및 총 비용 계산 order.calculatorTotalAmount(orderProductEntityList); order.updateOrderProductList(orderProductEntityList); OrderEntity saveOrderEntity = orderRepository.save(order); return PostOrderResponse.from(saveOrderEntity); } /** * orderProductEntityList 생성 * @param postOrderRequest * @param order * @return */ private List<OrderProductEntity> getOrderProductEntityList(PostOrderRequest postOrderRequest, OrderEntity order) { List<OrderProductEntity> orderProductEntityList = new ArrayList<>(); List<PostOrderDto> orderProductList = postOrderRequest.getOrderProductList(); for (PostOrderDto dto : orderProductList) { ProductOptionEntity productOptionEntity = productOptionRepository.findById(dto.getProductOptionId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 상품입니다.")); OrderProductEntity orderProductEntity = OrderProductEntity.from(dto, order, productOptionEntity); orderProductEntityList.add(orderProductEntity); } return orderProductEntityList; } }
서비스 로직을 다음과 같이 정의했고 주문 리스트에 상품을 한개씩 조회하고 있는데 해당 부분은 리팩토링이 필요해보인다.
이미 상품 옵션과 상품을 조회하면서 n+1 문제가 발생하고 있다. 여기에 여러 옵션을 주문한다면
주문옵션수 * n+1
만큼 문제가 발생한다.이를 해결하기 위해서는 상품 옵션을 조회하는 부분을 리팩토링 해야한다.
🟢 주문 생성 로직 리팩토링
public interface ProductOptionCustomRepository { List<ProductOptionEntity> findAllByProductOptionId(List<Long> idList); }
@Repository @RequiredArgsConstructor public class ProductOptionCustomRepositoryImpl implements ProductOptionCustomRepository { private final JPAQueryFactory querydsl; @Override public List<ProductOptionEntity> findAllByProductOptionId(List<Long> idList) { QProductOptionEntity productOption = QProductOptionEntity.productOptionEntity; QProductEntity product = QProductEntity.productEntity; List<ProductOptionEntity> fetch = querydsl.selectFrom(productOption) .join(productOption.product, product) .fetchJoin() .where(productOption.productOptionId.in(idList)) .fetch(); return fetch; } }
상품 조회를 할때 product option id 값을 받아와서 한번에 조회하도록 하고 product를 바로 join하여 n+1 문제를 해결하려 한다.
@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; private final ProductOptionRepository productOptionRepository; private final ProductOptionCustomRepository productOptionCustomRepository; @Transactional @Override public PostOrderResponse createOrder(PostOrderRequest postOrderRequest) { // order 데이터 생성 OrderEntity order = OrderEntity.from(postOrderRequest); // orderProduct 데이터 생성 List<OrderProductEntity> orderProductEntityList = getOrderProductEntityList(postOrderRequest, order); // order product 추가 및 총 비용 계산 order.calculatorTotalAmount(orderProductEntityList); order.updateOrderProductList(orderProductEntityList); OrderEntity saveOrderEntity = orderRepository.save(order); return PostOrderResponse.from(saveOrderEntity); } /** * orderProductEntityList 생성 * @param postOrderRequest * @param order * @return */ private List<OrderProductEntity> getOrderProductEntityList(PostOrderRequest postOrderRequest, OrderEntity order) { List<OrderProductEntity> orderProductEntityList = new ArrayList<>(); List<PostOrderDto> orderProductList = postOrderRequest.getOrderProductList(); List<Long> productOptionIdList = orderProductList.stream().mapToLong(PostOrderDto::getProductOptionId).boxed().toList(); List<ProductOptionEntity> productOptionEntityList = productOptionCustomRepository.findAllByProductOptionId(productOptionIdList); checkOrderValidation(productOptionIdList, productOptionEntityList); // orderProductList, productOptionEntityList 모두 optionId 기준으로 정렬하기 Comparator<PostOrderDto> orderDtoComparator = Comparator.comparing(o -> o.getProductOptionId()); orderProductList = orderProductList.stream() .sorted(orderDtoComparator) .collect(Collectors.toList()); Comparator<ProductOptionEntity> productOptionEntityComparator = Comparator.comparing(o -> o.getProductOptionId()); productOptionEntityList = productOptionEntityList.stream() .sorted(productOptionEntityComparator) .collect(Collectors.toList()); for (int i=0; i<orderProductList.size(); i++) { PostOrderDto dto = orderProductList.get(i); ProductOptionEntity productOption = productOptionEntityList.get(i); OrderProductEntity orderProductEntity = OrderProductEntity.from(dto, order, productOption); orderProductEntityList.add(orderProductEntity); } return orderProductEntityList; } private void checkOrderValidation(List<Long> productOptionIdList, List<ProductOptionEntity> productOptionEntityList) { if (productOptionIdList.size() != productOptionEntityList.size()) { throw new IllegalArgumentException("유효하지 않은 상품이 포함되어 있습니다."); } } }
다음과 같이 n+1 문제를 해결하고 여러번 조회가 일어나지 않게되었다.
🟢 주문 Test Code
@SpringBootTest class OrderServiceImplTest { @Autowired private OrderServiceImpl orderService; @Autowired private ProductRepository productRepository; @Autowired private ProductOptionRepository productOptionRepository; @Autowired private OrderRepository orderRepository; @AfterEach void cleanUp() { orderRepository.deleteAll(); productOptionRepository.deleteAll(); productRepository.deleteAll(); } @Test @DisplayName("존재하지 않는 상품을 주문시 주문에 실패한다.") void createOrderFailByNonExistProduct() { //given PostOrderDto postOrderDto = PostOrderDto.builder() .productOptionId(1L) .quantity(1) .build(); PostOrderRequest postOrderRequest = PostOrderRequest.builder() .orderProductList(List.of(postOrderDto)) .build(); //when //then Assertions.assertThatExceptionOfType(IllegalArgumentException.class) .isThrownBy(() -> orderService.createOrder(postOrderRequest)) .withMessageContaining("유효하지 않은 상품"); } @Test @DisplayName("주문에 성공한다.") void createOrder() { //given PostProductOptionDto option = PostProductOptionDto.builder() .productOptionName("11-12과") .productOptionPrice(100000) .productOptionStatus(ProductOptionStatus.ON_SALE) .productOptionStock(100) .productOptionDescription("아리수 11-12과 입니다.") .productOptionSort(1) .build(); PostProductRequest productRequest = PostProductRequest.builder() .productName("아리수") .productType(ProductType.OPTION) .productOption(List.of(option)) .build(); ProductEntity productEntity = ProductEntity.from(productRequest); ProductOptionEntity productOptionEntity = ProductOptionEntity.from(option, productEntity); productEntity.getProductOptionList().add(productOptionEntity); ProductEntity saveProduct = productRepository.save(productEntity); PostOrderDto postOrderDto = PostOrderDto.builder() .productOptionId(saveProduct.getProductOptionList().get(0).getProductOptionId()) .quantity(1) .build(); PostOrderRequest postOrderRequest = PostOrderRequest.builder() .orderProductList(List.of(postOrderDto)) .orderName("주문자") .orderContact("010-1234-5678") .orderAddress("경기도 성남시 분당구 판교역로 231") .orderAddressDetail("H스퀘어 S동 5층") .orderZipcode("12345") .recipientName("받는이") .recipientContact("010-1234-5678") .recipientAddress("경기도 성남시 분당구 판교역로 231") .recipientAddressDetail("H스퀘어 S동 6층") .recipientZipcode("12345") .build(); //when PostOrderResponse order = orderService.createOrder(postOrderRequest); //then OrderEntity orderEntity = orderRepository.findById(order.orderId()).get(); Assertions.assertThat(orderEntity.getOrderName()).isEqualTo(postOrderRequest.getOrderName()); } }
테스트 코드는 리팩토링 전에 작성하여 리팩토링할때 테스트 코드가 잘 작동하는지 확인하였다.
🟠 주문 controller
🟢 주문 controller 추가
@ApiController @RequiredArgsConstructor @Tag(name = "Order", description = "Order API Documentation") public class OrderController { private final OrderService orderService; @PostMapping("/order") @Operation(summary = "주문 등록", description = "주문 등록 api") @ApiResponses(value = { @ApiResponse(responseCode = "201", description = "정상", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PostProductResponse.class))), @ApiResponse(responseCode = "500", description = "서버 에러", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), }) public ResponseEntity<ResponseData<PostOrderResponse>> order(@RequestBody @Validated PostOrderRequest postOrderRequest) { return ResponseEntity.status(HttpStatus.CREATED) .body(ResponseData.from(ResponseDataCode.CREATE, orderService.createOrder(postOrderRequest))); } }
🟢 주문 등록 Test Code
@SpringBootTest @AutoConfigureMockMvc @Transactional(readOnly = true) class OrderControllerTest { @Autowired private MockMvc mockMvc; @Autowired private OrderServiceImpl orderService; @Autowired private OrderRepository orderRepository; @Autowired private ProductRepository productRepository; @Autowired private ProductOptionRepository productOptionRepository; @Autowired private ObjectMapper objectMapper; @AfterEach void cleanUp() { orderRepository.deleteAll(); productOptionRepository.deleteAll(); productRepository.deleteAll(); } @Test @DisplayName("[POST] /api/v1/order") void postOrder() throws Exception { //given PostProductOptionDto option = PostProductOptionDto.builder() .productOptionName("11-12과") .productOptionPrice(100000) .productOptionStatus(ProductOptionStatus.ON_SALE) .productOptionStock(100) .productOptionDescription("아리수 11-12과 입니다.") .productOptionSort(1) .build(); PostProductRequest productRequest = PostProductRequest.builder() .productName("아리수") .productType(ProductType.OPTION) .productOption(List.of(option)) .build(); ProductEntity productEntity = ProductEntity.from(productRequest); ProductOptionEntity productOptionEntity = ProductOptionEntity.from(option, productEntity); productEntity.getProductOptionList().add(productOptionEntity); ProductEntity saveProduct = productRepository.save(productEntity); PostOrderDto postOrderDto = PostOrderDto.builder() .productOptionId(saveProduct.getProductOptionList().get(0).getProductOptionId()) .quantity(1) .build(); PostOrderRequest postOrderRequest = PostOrderRequest.builder() .orderProductList(List.of(postOrderDto)) .orderName("주문자") .orderContact("010-1234-5678") .orderAddress("경기도 성남시 분당구 판교역로 231") .orderAddressDetail("H스퀘어 S동 5층") .orderZipcode("12345") .recipientName("받는이") .recipientContact("010-1234-5678") .recipientAddress("경기도 성남시 분당구 판교역로 231") .recipientAddressDetail("H스퀘어 S동 6층") .recipientZipcode("12345") .build(); //when ResultActions perform = mockMvc.perform(post("/api/v1/order") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(postOrderRequest))); //then perform.andExpect(status().isCreated()); } }
728x90반응형'appling 프로젝트' 카테고리의 다른 글
[appling] 주문 상세 조회 (0) 2024.09.22 [appling] 주문 조회 (0) 2024.09.21 [appling] 상품 상세 기능 추가 (with. 코드리뷰, n+1 문제 해결) (1) 2024.09.13 [appling] Product Option 추가 (with. unit test code) (1) 2024.09.12 [appling] 전역 Log 처리 (with. AOP) (0) 2024.09.09