안녕하세요! delay100입니다.
오늘은 본격적으로 상품 주문 API를 다루기 전에 결제 이전 및 이후에 동작하는 GET 메소드들과, 날짜에 따른 상품 상태를 변경하는 메소드들을 다루겠습니다.
실제로 상품 주문 요청을 다루는 것 외에 부가적으로 만들었던 기능들에 대해 먼저 살펴볼 예정입니다.
이 내용은 1번을 먼저 보고, 8번 포스팅 이후에 3번과 4번을 보는 것을 추천드립니다!
서론
상품 주문의 주요 API별로 구성했습니다. 글 내용을 1, 3, 4를 먼저 작성했는데 생각보다 길어져서 주문의 핵심 내용인 2번은 다음 포스팅으로 미루게 되었습니다..!
[결제 이전]
1. 배송지 입력 및 주문 폼 접근
2. 상품 주문 -> 다음 포스팅에서 다룸
[결제 이후]
3. 상품 취소, 반품
4. 주문 내역 리스트, 상세
1. 배송지 입력 및 주문 폼 접근
1-1. 동작 방식
배송지 입력 및 주문 폼에 접근하는 API입니다. 다시봐도 API 네이밍 센스가 좋지 않네요.. 아래의 사진과 같이 결제창 이전에 정보 확인하는 창에 접근하는 GET요청에 대한 API입니다.
GET요청 시 기본으로 반환되는 값
- 상품 및 상품 옵션 정보: 상품 옵션, 상품 정보
- 배송지 정보: 현재 회원(member)에 저장되어있는 기본 배송지(주소, 우편번호 등)
- 배송비를 제외한 금액: 배송비 제외 상품, 상품 옵션 추가금 비용 리스트
- 배송비: 주문 금액이 3만원이 넘는 경우 0원, 넘지 못하는 경우 3000원
- 전체 상품 가격: 상품, 상품 옵션, 배송비 등이 적용된 총 가격
1-2. 배송지 입력 및 주문 폼 접근 구현
Github 주소
- OrderProductAllInfoRequestDto
- OrderProductInfoRequestDto
- OrderProductAllInfoResponseDto
- OrderProductResponseDto
- OrderController
- OrderService
// OrderController
/**
* GET
* 배송지 입력 및 결제확인
* @param userDetails security의 회원 정보
* @param orderProductAllInfoRequestDto 주문 상품 요청 객체
* @return 회원의 상품 주문 정보 객체 DTO
*/
@GetMapping(BASE_ORDER + "/info")
public ApiResponse<OrderProductAllInfoResponseDto> getOrderProductAllInfo(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestBody OrderProductAllInfoRequestDto orderProductAllInfoRequestDto
) {
return ApiResponse.createSuccess(orderService.getOrderProductAllInfo(userDetails.getMember(), orderProductAllInfoRequestDto));
}
// OrderService
@Transactional(readOnly = true)
public OrderProductAllInfoResponseDto getOrderProductAllInfo(Member member, OrderProductAllInfoRequestDto orderProductAllInfoRequestDto) {
member = memberRepository.findByMemberId(member.getMemberId())
.orElseThrow(() -> new IllegalArgumentException("사용자 정보가 없습니다."));
List<OrderProductResponseDto> orderProducts = orderProductAllInfoRequestDto.getOrderProducts().stream().map(orderProduct -> {
Product product = productRepository.findById(orderProduct.getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
int productPrice = product.getProductPrice();
ProductOption productOption = productOptionRepository.findByProductOptionId(orderProduct.getProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
int productOptionPrice = productOption.getProductOptionPrice();
int productTotalPrice = (productPrice + productOptionPrice) * orderProduct.getQuantity();
return OrderProductResponseDto.from(product, orderProduct.getQuantity(), productOption, productPrice, productTotalPrice);
}).collect(Collectors.toList());
// 총 결제 금액 계산
int productTotalPrice = orderProducts.stream().mapToInt(OrderProductResponseDto::getProductTotalPrice).sum();
int orderShippingFee = productTotalPrice >= 30000 ? 0 : 3000;
int orderPrice = productTotalPrice + orderShippingFee;
return OrderProductAllInfoResponseDto.from(member ,aes256Encoder, orderProducts, productTotalPrice, orderShippingFee, orderPrice);
}
// OrderProductAllInfoResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderProductAllInfoResponseDto {
// 주문할 상품
private List<OrderProductResponseDto> orderProducts;
// 기본으로 사용할 주소 정보 - user에서 빼오기
private String orderMemberName;
private int orderZipCode;
private String orderAddress;
private String orderPhone;
// 결제 금액
private int productTotalPrice;
private int orderShippingFee;
private int orderPrice; // 총금액
public static OrderProductAllInfoResponseDto from(
Member member,
AES256Encoder aes256Encoder,
List<OrderProductResponseDto> orderProducts,
int productTotalPrice,
int orderShippingFee,
int orderPrice
) {
return OrderProductAllInfoResponseDto.builder()
.orderMemberName(aes256Encoder.decodeString(member.getMemberName()))
.orderZipCode(member.getZipCode())
.orderAddress(aes256Encoder.decodeString(member.getAddress()))
.orderPhone(aes256Encoder.decodeString(member.getPhone()))
.orderProducts(orderProducts)
.productTotalPrice(productTotalPrice)
.orderShippingFee(orderShippingFee)
.orderPrice(orderPrice)
.build();
}
}
// OrderProductResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderProductResponseDto {
private Long productId;
private String productTitle;
private int quantity;
private Long productOptionId;
private String productOptionTitle;
private int productPrice; // 각 제품 가격
private int productOptionPrice; // 각 제품의 옵션 가격
private int productTotalPrice; // 각 제품의 총 결제 금액(수량*가격)
public static OrderProductResponseDto from(
Product product,
int quantity,
ProductOption productOption,
int productPrice,
int productTotalPrice
) {
return OrderProductResponseDto.builder()
.productId(product.getProductId())
.productTitle(product.getProductTitle())
.quantity(quantity)
.productOptionId(productOption.getProductOptionId())
.productOptionTitle(productOption.getProductOptionTitle())
.productPrice(productPrice)
.productOptionPrice(productOption.getProductOptionPrice())
.productTotalPrice(productTotalPrice)
.build();
}
}
2. 상품 취소, 반품
2-1. 동작 방식
OrderStatusUpdateService클래스와 OrderService클래스 내의 2개의 메소드를 이용합니다.
2-1-1. 주문 상태 스케쥴링(OrderStatusUpdateService) 클래스
- 만든 이유: 실제로 택배를 보낼 수 없기 때문에 배송중, 배송완료 여부를 알 수 없음
- 하는 일: 자정(00시)마다 스케쥴링 진행하면서 아래의 조건 확인
- 주문 이후 1일 후에 주문 상태를 "배송중"으로 변경
- 주문 이후 2일 후에 "배송완료"로 변경
- 상태가 "반품요청"인 건에 대해 3일 후에 "반품 완료"로 변경
2-1-2. 상품 취소(updateOrderStatusCancel) 메소드
사용자가 상품 취소 요청을 보낸 경우에 대한 메소드입니다.
1. 취소가능 상태(주문 상태가 "결제완료" 또는 "배송준비중")인지 확인
2. 상품 옵션 확인 후 상품 재고 추가(Redis에는 재고 추가를 하지 않고 있음)
=> 1, 2를 모두 충족하면 상품 취소 가능
여기서 고민해봐야할 점은 Redis에는 재고 추가를 어느 시점에 할 것인가?
2-1. 상품 수량이 추가된 즉시
2-2. 캐시로 올라갈 때
-> 상품이 현재 Redis에 캐시 되어있으면 Redis에 즉시 반영, 없으면 DB에만 반영
(블로그 글을 쓰다가 방법이 갑자기 떠올랐습니다!?!? 다음에 기능들 추가할 때 이 방법으로 하면 될 것 같아요~!)
2-1-3. 상품 반품(updateOrderStatusReturn) 메소드
사용자가 상품 반품 요청을 보낸 경우에 대한 메소드입니다.
1. 반품가능 상태(주문 상태가 "배송 완료")인지 확인
2. 배송 완료 후 1일 이내인지 확인
=> 1, 2를 모두 충족하면 상품 반품 가능
2-2. 상품 취소, 반품 구현
Github 주소
// OrderStatusUpdateService
@Service
@RequiredArgsConstructor
public class OrderStatusUpdateService {
private final OrderRepository orderRepository;
private final OrderProductRepository orderProductRepository;
private final ProductOptionRepository productOptionRepository;
@Scheduled(cron = "0 0 0 * * ?") // 자정(00시)마다 스케줄 실행
@Transactional
public void updateOrderStatuses() {
List<Order> orders = orderRepository.findAll();
LocalDateTime now = LocalDateTime.now();
for (Order order : orders) {
// 배송 상태 업데이트
if (order.getOrderDate().plusDays(1).isBefore(now) && order.getOrderStatus() == OrderStatusEnum.PAYMENT_COMPLETED) {
order.setOrderStatus(OrderStatusEnum.SHIPPING);
} else if (order.getOrderDate().plusDays(2).isBefore(now) && order.getOrderStatus() == OrderStatusEnum.SHIPPING) {
order.setOrderStatus(OrderStatusEnum.DELIVERY_COMPLETED);
}
// 반품 처리
// 반품한 상품은 반품 신청 후 D+1에 재고에 반영 됨. 재고에 반영된후 상태는 반품완료로 변경됨
if (order.getOrderStatus() == OrderStatusEnum.RETURN_REQUESTED && order.getOrderDate().plusDays(3).isBefore(now)) {
List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(order.getOrderId());
for (OrderProduct orderProduct : orderProducts) {
ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
.orElseThrow(() -> new RuntimeException("상품 옵션을 찾지 못했습니다."));
productOption.setProductOptionStock(productOption.getProductOptionStock() + orderProduct.getOrderProductQuantity());
productOptionRepository.save(productOption);
}
order.setOrderStatus(OrderStatusEnum.RETURN_COMPLETED);
}
}
orderRepository.saveAll(orders);
}
}
// OrderController
/**
* GET
* 한 주문 내 상품 전체 취소
* @param userDetails security의 회원 정보
* @param orderId 주문 아이디
* @return 취소된 주문 내역에 대한 정보 DTO
*/
@PatchMapping(BASE_MYPAGE + "/order/cancel")
public ApiResponse<OrderCancelResponseDto> updateOrderStatusCancel(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam Long orderId
) {
return ApiResponse.createSuccess(orderService.updateOrderStatusCancel(userDetails.getMember(), orderId));
}
/**
* 한 주문 내 상품 전체 반품
* @param userDetails security의 회원 정보
* @param orderId 주문 아이디
* @return 반품된 주문 내역에 대한 정보 DTO
*/
@PatchMapping(BASE_MYPAGE + "/order/return")
public ApiResponse<OrderReturnResponseDto> updateOrderStatusReturn(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam Long orderId
) {
return ApiResponse.createSuccess(orderService.updateOrderStatusReturn(userDetails.getMember(), orderId));
}
// OrderService
@Transactional
public OrderCancelResponseDto updateOrderStatusCancel(Member member, Long orderId) {
Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
.orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));
// 취소 가능 상태인지 확인
if (!order.getOrderStatus().isCancellable()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "취소 가능 상태가 아닙니다.");
}
order.setOrderStatus(OrderStatusEnum.ORDER_CANCELLED);
List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(orderId);
for (OrderProduct orderProduct : orderProducts) {
ProductOption productOption = productOptionRepository.findById(orderProduct.getOrderProductOptionId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));
productOption.setProductOptionStock(productOption.getProductOptionStock() + orderProduct.getOrderProductQuantity());
productOptionRepository.save(productOption);
}
orderRepository.save(order);
return OrderCancelResponseDto.from(order);
}
@Transactional
public OrderReturnResponseDto updateOrderStatusReturn(Member member, Long orderId) {
Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
.orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));
// 반품 가능 상태인지 확인
if (!order.getOrderStatus().isReturnable()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "반품 가능 상태가 아닙니다.");
}
// 반품 가능 기간인지 확인 (배송 완료 후 1일 이내)
if (order.getUpdatedAt().plusDays(1).isBefore(LocalDateTime.now())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "반품 가능 기간이 아닙니다. (배송 완료 후 1일 이내)");
}
order.setOrderStatus(OrderStatusEnum.RETURN_REQUESTED);
orderRepository.save(order);
return OrderReturnResponseDto.from(order);
}
3. 주문 내역 리스트, 상세
3-1. 동작 방식
위에서 주문한 정보에 대해 상세 주문 정보와 리스트 정보를 보여줍니다. 간단하게 GET 메소드 2개로 구현했습니다.
3-1-1. 주문 내역 리스트
주문 내역 리스트를 반환할 때 상세 리스트 전체를 가져올 필요가 없다고 판단하여, 각 주문의 제목을 최상단의 상품 1개만 (Limit 1) 가져오도록 했습니다. 대신, 해당 주문에 포함된 총 상품 개수도 함께 반환하여 프론트엔드에서 "반팔 외 3건"과 같은 형식으로 처리할 수 있게 했습니다.
// 묶음 상품에 대해서 대표 상품에 대한 정보만 출력함
Product product = productRepository.findById(orderProducts.getFirst().getProduct().getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
String productTitle = product.getProductTitle();
int orderProductCount = orderProducts.size(); // 총 orderProduct 수
3-1-2. 주문 내역 상세
현재 주문에 대한 모든 주문 상품(OrderProduct)을 가져와서 List형식으로 보여줍니다.
예시 응답데이터는 위와 같습니다. 응답데이터는 주문 정보와 주문 상품 정보가 모두 포함되어 있습니다.
3-2. 주문 내역 리스트, 상세 구현
Github 주소
// OrderController
/**
* GET
* 주문 내역 리스트
* @param userDetails security의 회원 정보
* @param page 페이지 번호
* @param size 한 페이지에 띄울 수
* @return 주문 정보 리스트 DTO
*/
@GetMapping(BASE_MYPAGE + "/orderlist")
public ApiResponse<Page<OrderListResponseDto>> getOrderList(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam int page,
@RequestParam int size
) {
return ApiResponse.createSuccess(orderService.getOrderList(userDetails.getMember(), page, size));
}
/**
* GET
* 주문내역 상세
* @param userDetails security의 회원 정보
* @param orderId 주문 아이디
* @return 하나의 주문 내역에 대한 정보 DTO
*/
@GetMapping(BASE_MYPAGE + "/order")
public ApiResponse<OrderDetailResponseDto> getOrderDetail(
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam Long orderId
) {
return ApiResponse.createSuccess(orderService.getOrderDetail(userDetails.getMember(), orderId));
}
// OrderService
@Transactional(readOnly = true)
public Page<OrderListResponseDto> getOrderList(Member member, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Order> orders = orderRepository.findByMemberMemberId(member.getMemberId(), pageable);
return orders.map(order -> {
List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(order.getOrderId());
if (orderProducts.isEmpty()) {
throw new IllegalArgumentException("해당 주문에 대한 상품이 없습니다.");
}
// 묶음 상품에 대해서 대표 상품에 대한 정보만 출력함
Product product = productRepository.findById(orderProducts.getFirst().getProduct().getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
String productTitle = product.getProductTitle();
int orderProductCount = orderProducts.size(); // 총 orderProduct 수
return OrderListResponseDto.from(order, productTitle, orderProductCount);
});
}
@Transactional(readOnly = true)
public OrderDetailResponseDto getOrderDetail(Member member, Long orderId) {
Order order = orderRepository.findByMemberMemberIdAndOrderId(member.getMemberId(), orderId)
.orElseThrow(() -> new IllegalArgumentException("해당 주문이 없습니다."));
List<OrderProduct> orderProducts = orderProductRepository.findByOrderOrderId(orderId);
if (orderProducts.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "잘못된 주문이 존재:" + orderId);
}
List<OrderProductDetailResponseDto> orderProductDetailResponseDto = orderProducts.stream()
.map(orderProduct -> {
Product product = productRepository.findById(orderProduct.getProduct().getProductId())
.orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));
String productOptionTitle = productOptionRepository.findProductOptionTitleById(orderProduct.getOrderProductOptionId());
return OrderProductDetailResponseDto.from(orderProduct, product.getProductTitle(), productOptionTitle);
}).collect(Collectors.toList());
return OrderDetailResponseDto.from(order, orderProductDetailResponseDto, aes256Encoder);
}
// OrderDetailResponseDto
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderDetailResponseDto {
private Long orderId;
private LocalDateTime orderDate;
private OrderStatusEnum orderStatus;
private int orderShippingFee;
private OrderCardCompanyEnum orderCardCompany;
private int orderPrice;
private String orderMemberName;
private int orderZipCode;
private String orderAddress;
private String orderPhone;
private String orderReq;
private List<OrderProductDetailResponseDto> orderProductDetailResponseDto;
public static OrderDetailResponseDto from(Order order, List<OrderProductDetailResponseDto> orderProductDetailResponseDto, AES256Encoder aes256Encoder) {
return OrderDetailResponseDto.builder()
.orderId(order.getOrderId())
.orderDate(order.getOrderDate())
.orderStatus(order.getOrderStatus())
.orderShippingFee(order.getOrderShippingFee())
.orderPrice(order.getOrderPrice())
.orderCardCompany(order.getOrderCardCompany())
.orderMemberName(aes256Encoder.decodeString(order.getOrderMemberName()))
.orderZipCode(order.getOrderZipCode())
.orderAddress(aes256Encoder.decodeString(order.getOrderAddress()))
.orderPhone(aes256Encoder.decodeString(order.getOrderPhone()))
.orderReq(aes256Encoder.decodeString(order.getOrderReq()))
.orderProductDetailResponseDto(orderProductDetailResponseDto)
.build();
}
}
4. 결과 및 예제(API)
아래의 링크에서 더 자세히 보실 수 있습니다.
API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ
5. 이후 추가하고 싶은 기능
5-1. 상품 취소, 반품 (+ 재고 추가)시 Redis에 반영하는 시점 반영
1. 취소가능 상태(주문 상태가 "결제완료" 또는 "배송준비중")인지 확인
2. 상품 옵션 확인 후 상품 재고 추가(Redis에는 재고 추가를 하지 않고 있음)
여기서 앞으로 고민해봐야할 점은 Redis에 재고 추가를 어느 시점에 할 것인가?
2-1. 상품 수량이 추가된 즉시: 상품이 현재 Redis에
2-2. 캐시로 올라갈 때
-> 상품이 현재 Redis에 캐시 되어있으면 Redis에 즉시 반영, 없으면 DB에만 반영
3-1-2에서 위와 같은 결론을 냈었고, 해당 부분을 반하려 합니다.
긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.
'Study > SpringBoot' 카테고리의 다른 글
[SpringBoot] Java로 웹 크롤러 구현하기(feat.네이버 쇼핑 검색 데이터 크롤링하기) (3) | 2024.11.28 |
---|---|
[e-commerce 프로젝트] 8. 상품 주문 - 2(동시성 처리 및 재고 Redis 관리) (3) | 2024.08.05 |
[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 |