2일 차 진행상황
원래는 로그인 User를 받아와야 하는데 아직 구현이 안돼있어서 빠르게 null로 넣고 기능 구현을 하기로 했다.
순서는 장바구니 -> 주문 순으로 구현을 진행했다. 구현하고 있던 중 튜터님의 피드백을 받았는데 우리의 Response 부분의 문서가 제각각이어서 통일하라고 피백을 받았다. 감이 안 잡혀서 튜터님께 여쭤보고.. 공통 Response를 만들고 data는 제네릭으로 처리하면 된다는 피드백을 들었는데.. 순간 띠용 했다. 이렇게 만들 수도 있구나 하고 얼른 아이디어를 채택했다. 확실히 프런트 쪽에서 본다면 이렇게 하는 게 훨씬 편할 것 같다고 극히 공감했다. 왜 이런 생각을 못했었지...
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ResponseDto<T>{
public int statusCode;
public String message;
public T data;
public static <T> ResponseDto<T> of(int statusCode, String message, T data) {
return new ResponseDto<T>(statusCode, message, data);
}
public static <T> ResponseDto<T> of(int statusCode, T data) {
return new ResponseDto<T>(statusCode, "", data);
}
public static <T> ResponseDto<T> of(int statusCode, String message) {
return new ResponseDto<T>(statusCode, message, null);
}
public static <T> ResponseDto<T> of(int statusCode) {
return new ResponseDto<T>(statusCode, "", null);
}
public static <T> ResponseDto<T> of(HttpStatus statusCode, String message, T data) {
return new ResponseDto<T>(statusCode.value(), message, data);
}
public static <T> ResponseDto<T> of(HttpStatus statusCode, T data) {
return new ResponseDto<T>(statusCode.value(), "", data);
}
public static <T> ResponseDto<T> of(HttpStatus statusCode, String message) {
return new ResponseDto<T>(statusCode.value(), message, null);
}
public static <T> ResponseDto<T> of(HttpStatus statusCode) {
return new ResponseDto<T>(statusCode.value(), "", null);
}
}
구현은 위와 같이했다. 사용법은 ResponseEntity에 만들어서 넣어주면 된다!
/**
* 장바구니에 물품 추가
*/
@PostMapping("/items")
public ResponseEntity<ResponseDto<CartItemInfo>> addItem(@Auth AuthUser authUser, @RequestBody AddMenuRequestDto requestDto) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ResponseDto.of(HttpStatus.CREATED, cartService.addMenu(authUser, requestDto)));
}
구현을 하면서 연관관계가 점점 많아지고 Lazy인 부분을 호출하면서 생기는 N+1 문제를 이번에는 해결하기로 했다. 저번 프로젝트에서는 사실 신경을 안썼었다. 나는 JPQL을 이용해서 JOIN FETCH 하는 방식을 채택했다. 아직은 쿼리들이 복잡하지 않아서 쿼리 dsl은 사용하지 않았다.
@Query("SELECT ci FROM CartItem ci JOIN FETCH ci.cart c JOIN FETCH c.user u JOIN FETCH ci.menu m JOIN FETCH m.store WHERE u = :user")
List<CartItem> findAllByCart_User(@Param("user") User user);
JPQL은 처음 써보는데 진짜 쿼리문을 작성하는 것 같아서 재밌었다. 장바구니에서 핵심은 장바구니안에는 동일한 가게의 음식만 담을 수 있어야 했다.
장바구니에 물품을 추가할 때 들어있는 가게와 다르다면 현재 장바구니에 있는 모든 물건들을 삭제 후 새로운 물건을 추가해 주었다.
@Override
public CartItemInfo addMenu(AuthUser auth, AddMenuRequestDto requestDto) {
User user = userRepository.findByIdOrElseThrow(auth.getId());
Store store = storeRepository.findById(requestDto.getStoreId())
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 가게입니다."));
Menu menu = menuRepository.findById(requestDto.getMenuId())
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 메뉴입니다."));
Cart cart = cartRepository.findByUser(user)
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 장바구니입니다."));
// 카트의 주인 유저가 아니면 throw
if(cart.getUser() != user) {
throw new AccessDeniedException("요청한 장바구니의 소유자가 아닙니다.");
}
// 장바구니에 물품이 있을 때
if(!cart.getCartItems().isEmpty()){
Long storeId = cart.getCartItems()
.get(0)
.getMenu()
.getStore()
.getStoreId();
// 가게 아이디가 다르면 현재 물품은 전부 삭제
if(!Objects.equals(storeId, requestDto.getStoreId())){
cartItemRepository.deleteAllByCart_User(user);
}
}
long menuPrice = menu.getMenuPrice();
CartItem cartItem = new CartItem(1L, menuPrice, cart, menu);
cartItem = cartItemRepository.save(cartItem);
return CartItemInfo.builder()
.cartId(cart.getId())
.cartItemId(cartItem.getId())
.price(menuPrice)
.menuId(menu.getMenu_id())
.menuName(menu.getMenuName())
.quantity(1L)
.totalPrice(cartItem.getTotalPrice())
.build();
}
나머지 기능은 그리 어렵지 않았다. 주문 부분도 현재 프로젝트에서는 많이 어렵지 않았는데 큰 프로젝트 할 때에는 어려울 것 같다.. 우리의 주문 프로세스는 장바구니에 담고 -> 주문을 하는 형식이다. 따라서 장바구니에 물품이 없을 때 주문을 보내는 예외처리는 기본적으로 해주었다. 결제 시스템은 구현하지 않지만 금액과 관련된 부분이다 보니 수량과 금액 부분에서 예외처리도 진행해 주었다.
@Override
public OrderCreateResponseDto createOrder(AuthUser auth) {
User user = userRepository.findByIdOrElseThrow(auth.getId());
List<CartItem> cartItems = cartItemRepository.findAllByCart_User(user);
if (cartItems.isEmpty()) {
throw new InvalidRequestException("장바구니에 상품이 존재하지 않습니다.");
}
Store store = storeRepository.findById(cartItems.get(0).getMenu().getStore().getStoreId())
.orElseThrow(() -> new InvalidRequestException("존재하지 않는 가게입니다."));
LocalTime now = LocalDateTime.now().toLocalTime();
if(now.isBefore(store.getOpenTime()) && now.isAfter(store.getCloseTime())) {
throw new InvalidRequestException("주문 가능 시간이 아닙니다.");
}
int totalPrice = 0;
long userCartId = user.getCart().getId();
for(CartItem cartItem : cartItems) {
if(cartItem.getCart().getId() != userCartId) {
throw new AccessDeniedException("요청한 장바구니는 현재 유저의 장바구니가 아닙니다.");
}
totalPrice += cartItem.getPrice();
}
if(totalPrice < 0) {
throw new InvalidRequestException("총 금액은 0보다 커야합니다.");
}
if(store.getOrderAmount() < totalPrice){
throw new InvalidRequestException("주문 금액이 최소 주문 금액보다 낮습니다.");
}
Order order = new Order(user, store, OrderStatus.WAITING);
order = orderRepository.save(order);
// 주문 생성 후 주문 상세 테이블 생성
List<OrderItem> orderItems = new ArrayList<>();
for (CartItem cartItemInfo : cartItems) {
Long menuId = cartItemInfo.getMenu().getMenu_id();
Menu menu = menuRepository.findById(menuId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 메뉴입니다."));
OrderItem orderItem = new OrderItem(order, menu, cartItemInfo.getQuantity(),
cartItemInfo.getPrice(), cartItemInfo.getTotalPrice());
orderItems.add(orderItem);
}
orderItemRepository.saveAll(orderItems);
// 장바구니 목록 초기화
cartItemRepository.deleteAllByCart_User(user);
// response dto 생성
List<OrderItemInfo> orderItemInfos = orderItems.stream()
.map(OrderItemInfo::new)
.toList();
return OrderCreateResponseDto.builder()
.storeId(store.getStoreId())
.userId(user.getId())
.orderId(order.getId())
.status(order.getStatus())
.createdAt(order.getCreatedAt())
.items(orderItemInfos)
.build();
}
또 중요한 것은 추가 기능 구현에 매출액, 주문 건 수 통계와 주문 상황 트래킹을 구현하려고 해서 완료된 주문은 OrderHistory테이블에 넣어주고 나중에 매출액, 주문 건 수 조회 시 사용할 수 있게 했다. 주문 상황 트래킹은 SseEmitter를 사용했다. 프런트가 없어서 테스트하기 불편했는데 그냥 chatGpt를 돌려가면서 내가 프런트도 만들어서 비주얼적으로 보이게 구현했다.
클라이언트가 커넥션을 요청하면 오더 트래커라는 클래스가 SseEmitter를 주고 orderId를 키값으로 HashMap으로 관리하고 있다.
public SseEmitter createEmitter(Long orderId) throws IOException {
Order order = orderRepository.findById(orderId)
.orElse(null);
SseEmitter emitter = new SseEmitter(0L);
if (order == null) {
emitter.send(SseEmitter.event()
.name("error")
.data("주문을 찾을 수 없습니다.")
.reconnectTime(3000));
emitter.completeWithError(new IllegalArgumentException("Order not found"));
return emitter;
}
if (order.getStatus() == OrderStatus.COMPLETED) {
emitter.send(SseEmitter.event()
.name("error")
.data("이미 완료된 주문입니다.")
.reconnectTime(3000));
emitter.completeWithError(new IllegalArgumentException("Order already completed"));
return emitter;
}
if (emitters.containsKey(orderId)) {
return emitters.get(orderId);
}
setEmitterConfig(orderId, emitter);
emitters.put(orderId, emitter);
return emitter;
}
private void setEmitterConfig(Long key, SseEmitter emitter) {
emitter.onCompletion(() -> {
emitters.remove(key);
});
emitter.onTimeout(() -> {
emitters.remove(key);
});
emitter.onError((err) -> {
log.info(err.getMessage());
emitters.remove(key);
});
}
emitter를 생성할 때 타임아웃, 완료, 에러 발생 시 자동으로 맵에서 삭제하게 해 놨다. 또 트래커 클래스 내부에
public void onOrderStatusChanged(Long orderId, OrderStatus status) {
Order order = orderRepository.findByIdOrElseThrow(orderId);
if (emitters.containsKey(orderId)) {
SseEmitter emitter = emitters.get(orderId);
try {
emitter.send(SseEmitter.event()
.data(status.name()));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
위 함수를 만들어서 주문 상태가 변경되는 쪽에서 호출해 주었다.
@Override
public void changeOrderStatus(AuthUser auth, Long storeId, Long orderId,
OrderStatusChangeRequestDto requestDto) {
Store store = storeRepository.findById(storeId).orElseThrow(() -> new InvalidRequestException("존재하지 않는 가게입니다."));
User user = userRepository.findByIdOrElseThrow(auth.getId());
if(store.getUser() == null){
throw new InvalidRequestException("가게의 사장 정보가 없습니다.");
}
if(!user.equals(store.getUser())){
throw new AccessDeniedException("가게의 주인이 아닙니다.");
}
Order order = orderRepository.findByIdOrElseThrow(orderId);
order.updateStatus(requestDto.getOrderStatus());
orderStatusTracker.onOrderStatusChanged(order.getId(), order.getStatus());
// 주문이 완료되면 기록 저장
if(order.getStatus().equals(OrderStatus.COMPLETED)) {
List<OrderItem> items = orderItemRepository.findAllByOrder(order);
List<OrderHistory> historyList = new ArrayList<>();
for(OrderItem item : items){
OrderHistory history = OrderHistory.builder()
.soldDate(LocalDateTime.now())
.orderId(item.getOrder().getId())
.storeId(item.getOrder().getStore().getStoreId())
.userId(item.getOrder().getUser().getId())
.menuId(item.getMenu().getMenu_id())
.menuName(item.getMenu().getMenuName())
.quantity(item.getQuantity())
.soldPrice(item.getPrice())
.soldTotalPrice(item.getTotalPrice())
.build();
historyList.add(history);
}
if(!historyList.isEmpty()){
orderHistoryRepository.saveAll(historyList);
}
orderItemRepository.deleteAll(items);
orderRepository.delete(order);
}
}
실제 테스트 결과 잘 작동되는 것 같다. 이제 남은 건 테스트 코드 작성이다. 열심히 한 번 달려서 커버리지를 50 이상 찍어봐야겠다.
아래는 chatgpt선생님과 함께 만든 실제 페이지이다.
포스트맨만 쓰다가 이렇게 시각적으로 보이니까 더 뿌듯한 것 같다. 나중에 프런트도 배우고 싶은걸 다 배운 후에 시간이 남으면 도전해 봐야겠다ㅎㅎㅎㅎ.