장바구니 및 주문 기능 구현하기
대략적인 erd
장바구니 API & 주문 API
POST 장바구니 추가 /api/v1/carts Authorization : JWT 토큰 (일반사용자만 접근 가능)
GET 장바구니 목록 조회 /api/v1/carts Authorization : JWT 토큰 (일반사용자만 접근 가능)
PATCH 장바구니 수정 /api/v1/carts/{cartId} Authorization : JWT 토큰 (일반사용자만 접근 가능)
DELETE 장바구니 삭제 /api/v1/carts/{cartId} Authorization : JWT 토큰 (일반사용자만 접근 가능)
POST 주문 등록 /api/v1/orders Authorization : JWT 토큰
GET 주문단건조회 /api/v1/orders/{orderId} Authorization : JWT 토큰
GET 주문전체조회(자신이 한 주문) /api/v1/orders Authorization : JWT 토큰
PATCH 주문수락 /api/v1/orders/{orderId}/accept Authorization : JWT 토큰
PATCH 주문거절 /api/v1/orders/{orderId}/reject Authorization : JWT 토큰
PATCH 주문취소 /api/v1/orders/{orderId}/cancel Authorization : JWT 토큰
PATCH 배달중 /api/v1/orders/{orderId}/delivering Authorization : JWT 토큰
PATCH 배달완료 /api/v1/orders/{orderId}/complete Authorization : JWT 토큰
장바구니 및 주문 프로세스
1. 사용자가 장바구니에 메뉴를 담는다
- 장바구니 테이블에 insert
- 이때 다른 가게의 메뉴인지 확인하고, 장바구니에 담은 가게의 메뉴와 다르면 예외 처리
- 장바구니에 담은 메뉴의 개수가 재고보다 작다면 예외 처리
2. 사용자가 주문 요청을 한다
- 주문 테이블을 먼저 생성 : 사용자 id, 장바구니에 있는 첫번째 메뉴를 가지고 와서 해당 가게 id insert
- 장바구니 테이블에서 사용자의 id로 목록을 조회하고 해당 값들을 주문 상세 테이블에 넣는다
- 마지막에 주문 상세 테이블에 있는 메뉴의 가격을 더해서 주문 테이블에 총 주문 금액을 넣어준다
3. 주문 변경은 정해진 순서대로 가능하다
- 사용자가 주문을 하면 "주문요청" 상태
- 사용자는 아직 "주문요청" 상태라면 "주문 취소"가 가능
- 사장님은 사용자가 "주문요청" 상태인 경우에 "주문 수락" 혹은 "주문 거절" 가능
- 사장님은 "주문수락" 상태라면 "배달중"으로 변경 가능 -> 이때 메뉴의 재고가 감소
- 사장님은 "배달중" 상태에서 "배달완료"로 변경 가능
주문 상태는 enum으로 관리
converter
public class OrderStatusConverter implements AttributeConverter<OrderStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(OrderStatus attribute) {
return attribute.getCode();
}
@Override
public OrderStatus convertToEntityAttribute(Integer dbData) {
return OrderStatus.of(dbData);
}
}
orderstatus
@Getter
public enum OrderStatus {
ORDER_REQUESTED(0, "주문요청"),
ORDER_ACCEPTED(1, "주문수락"),
ORDER_REJECTED(2, "주문거절"),
ORDER_CANCELED(3, "주문취소"),
DELIVERING(4, "배달중"),
DELIVERED(5, "배달완료");
private final Integer code;
private final String description;
OrderStatus(Integer code, String description) {
this.code = code;
this.description = description;
}
public static OrderStatus of(Integer code) {
return Arrays.stream(OrderStatus.values())
.filter(c -> c.getCode().equals(code))
.findFirst()
.orElseThrow(() -> new RuntimeException("데이터가 없습니다."));
}
// 배달완료 상태 확인
public boolean isReviewable() {
return this == DELIVERED;
}
}
fetch join 사용
장바구니 및 주문 관련된 기능을 개발하다보니 조인을 생각보다 많이 해야 해서 N+1문제 해결을 위해서 fetch join을 열심히 사용했다!
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.store s " +
"LEFT JOIN FETCH o.user u " +
"WHERE o.user.id = :userId")
List<Order> findByUserId(@Param("userId") Long userId);
@Query("SELECT o FROM Order o " +
"LEFT JOIN FETCH o.store s " +
"LEFT JOIN FETCH o.user u " +
"WHERE o.user.id = :userId")
Page<Order> findByUserIdPaged(Pageable pageable, @Param("userId") Long userId);
API에 대한 고민
주문을 할때 가게의 아이디를 패스로 안 넣고 그냥 서비스 단에서 사용자의 아이디로 장바구니를 찾고 해당 장바구니의 가게를 찾는 식으로 구현을 했다. 그래서 서비스 코드를 보면 이렇게 조인을 3번을 타고 가게의 정보를 가져오게 된다
@Transactional
public OrderResponseDto save(AuthUser authUser) {
// 일반 회원인지 검증
isValidCustomer(authUser);
// 사용자 아이디로 장바구니에 있는 목록을 조회해서 첫번째 메뉴를 통해서 가게 아이디를 뽑아옴
User user = userRepository.findById(authUser.getId()).orElseThrow(() -> new InvalidRequestException(ErrorCode.USER_NOT_FOUND));
List<Cart> carts = cartRepository.findByUserId(authUser.getId());
Long storeId = carts.get(0).getMenu().getStore().getId();
Store store = storeRepository.findById(storeId).orElseThrow(() -> new InvalidRequestException(ErrorCode.STORE_NOT_FOUND));
...
}
문득 든 생각은 과연 이렇게 조인을 3번이나 하는 것은 성능에 부담이 가지 않을까라는 걱정이 들었다.
그래서 결국 애초에 api를 설계했을 때 주문 등록시에는 가게 id를 path로 보냈어야 하는 고민과 아니면 데이터베이스에서 장바구니 필드에 가게 id도 외래키로 넣었어야 했나싶은 생각이 들었다.
하지만 튜터님께 여쭤보고 들은 결과 결론은 (알고보니 배민 개발자셨다 ㅎㅎㅎㅎ)
1. 테이블을 3개 조인 하는 것과 조인을 하지 않고 select를 1번 하는 더 하는 것은 성능적으로 큰 차이가 없다
select를 1번 더 하게 되면 어쨌든 1번의 데이터베이스 IO가 발생하게 되므로 이것 또한 비용이 되고, 오히려 조인을 3번해서 데이터를 가져오는 것이 성능적으로 더 이점이 될 수도 있다
즉, 테이블 조인을 2개 하고 조회를 한번 더 하나, 아니면 테이블 조인을 3개하고 조회를 한번 더 하지 않거나 둘 다 거기서 거기라는 것이다
2. 주문 api에 /api/v1/stores/{storeId}/orders를 하기 위해서는 클라이언트가 storeId값을 알아야 한다
이런식으로 api를 짜게 된다면 클라이언트는 가게의 id를 알고 있어야 하기 때문에 이런 점들은 함부로 백엔드 개발자가 독단적으로 고려할 수 없다는 것이다. 그렇기에 내가 기존에 짠대로 가는 것이 옳은 방향이었다!!
3. 상태변경 api는 하나로 만들고 그 내부에서 검증하면 된다.
실제로 실무에서 변경되는 상태는 굉장히 여러가지이고 api하나로 만들어서 모두 검증해도 된다고 하셨기에 수정할 계획이다!
예외처리 메서드 분리에 대한 고민
장바구니와 주문, 주문 상태변경에 관한 기능을 맡게 되니 예외 처리할 부분들이 생각보다 매우 많았다
장바구니 예외 처리 리스트
주문 예외 처리 리스트
위와 같은 메서드를 모두 구현하려 보니 예외 처리 메서드가 상당히 많이 생기게 되었다..
등등.. 그래서 너무 자잘하게 예외 처리에 대한 부분들을 메서드로 나눈 것은 아닌가 싶은 생각이 들었다
간단한 if문만 사용해서 검증하는 부분인데도 투머치로 간 것이 아닌지에 대한 생각을 지울 수 없었다..ㅎ
하지만 튜터님께 피드백을 받은 결과
1. 예외처리를 메서드로 분리해서 나눈 것은 문제가 되지 않음
2. 하지만 가게가 열었는지 확인하는 검증 메서드는 가게 서비스로 분리시켜도 될 것 같다 (주문시에 가게가 영업중인지 확인하는 부분이 필요했다)
3. 주문 상태 변경의 경우 enum 클래스 내부에 메서드를 만들어서 그 안에서 검증해도 충분할 것 같다!
였다!
추후에 상태변경 검증 메서드를 enum안에 넣도록 수정해야겠다
튜터님의 추가적인 피드백
- 회사마다 다르지만 사실 실무에서는 장바구니를 서버의 데이터베이스에 저장하지는 않는다 (그래도 이번은 과제니까 저장하는게 맞음)
- 예를 들어 수량을 올릴 때마다 서버에 요청을 한다면 수량을 100개 올린다면 그만큼 응답 속도가 오래 걸리게 된다
- 하지만 쿠팡은 어떤 메뉴가 장바구니에 많이 담겼는지 조사하기 위해서 저장한다고 한다
- 주로 프론트에서 정보를 저장하고 주문시에만 서버로 요청이 간다
- 장바구니에는 사용자가 주문당시에 담은 가격도 들어가야 한다
- 주문당시에는 15000원이었으나 주문버튼을 눌렀을 때 그 사이에 사장이 가격을 17000원으로 올린다면 낭패
정리하자면
1. 도메인의 주제에서 벗어난 키값은 패스에 함부로 넣으면 안됨
2. 조인테이블을 여러개 하나 추가적인 조회를 한 번 하나 성능은 거기서 거기
3. 상태변경은 하나의 api로 할 것
4. 상태변경 검증 로직은 이넘클래스 내부에서 메서드 만들어서 할 것
5. 장바구니에는 당시의 가격도 넣어야 함
6. 서비스 로직에서 검증에 관한 부분을 보고 그 부분이 다른 서비스에 적합하다고 생각하면 책임을 분리할 것
7. 실무에서는 장바구니는 디비에 저장하지 않는다
느낀점
- 장바구니 엔티티 짜는 것부터 걱정이 많이 됐는데 그래도 어찌저찌 성공하게 되서 다행이었다
- 설계가 정말 어려운 것 같다는 생각이 들었고 이넘 클래스를 사용해서 개발 해 볼 수 있어서 좋았다
- 예외처리를 생각하는 것이 정말 쉽지 않다는 것을 느꼈다