ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [appling] 주문 서비스 만들기
    appling 프로젝트 2024. 9. 19. 11:29
    728x90
    반응형

    🔴 주문 만들기

    🟠 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
    반응형
Designed by Juno.