appling 프로젝트
[appling] 주문 서비스 만들기
studyingJuno
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
반응형