티스토리 뷰

 

도메인 분석 설계

회원 기능 회원 등록 회원 조회 상품 기능 상품 등록 상품 수정 상품 조회 주문 기능 상품 주문 주문 내역 조회 주문 취소 기타 요구사항 상품은 재고관리가 필요하다. 상품의 종류는 도서 , 음반

write-read.tistory.com

구현 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

주문  엔티티

@Entity
@Table(name = "orders")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
// 객체 생성시 생성자를 protected하고 createOrder를 사용하여 Order를 생성하게 한다.
public class Order {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order" , cascade = CascadeType.ALL)
    private List<OrderItem> orderItems = new ArrayList<>();

    @OneToOne(fetch = FetchType.LAZY , cascade = CascadeType.ALL)
    @JoinColumn(name = "delivery_id")
    private Delivery delivery;

    private LocalDateTime orderDate;

    private OrderStatus status; // 주문상태 [ORDER , CANCEL]

    //== 연관관계 메서드 ==
    public void setMember(Member member){
        this.member = member;
        member.getOrders().add(this);
    }

    public void addOrderItem(OrderItem orderItem){
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }

    public void setDelivery(Delivery delivery){
        this.delivery = delivery;
        delivery.setOrder(this);
    }

    // == 생성 메서드 ==
    public static Order createOrder(Member member , Delivery delivery , OrderItem... orderItems){
        Order order = new Order();
        order.setMember(member);
        order.setDelivery(delivery);
        for(OrderItem orderItem : orderItems){
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER);
        order.setOrderDate(LocalDateTime.now());
        return order;
    }

    // == 비즈니스 로직 ==
    /**
     * 주문 취소
     */
    public void cancel(){
        if(delivery.getStatus() == DeliveryStatus.COMP){
            throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다.");
        }
        this.setStatus(OrderStatus.CANCEL);
//        for(OrderItem orderItem : orderItems){
//            orderItem.cancel();
//        }
        orderItems.forEach(OrderItem::cancel);
    }

    // == 조회 로직 ==
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice(){
        int totalPrice = 0;
//        for(OrderItem orderItem : orderItems){
//            totalPrice += orderItem.getTotalPrice();
//        }
//        return totalPrice;
        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
    }
}

주문 상품 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
// 객체 생성시 생성자를 protected하고 createOrderItem을 사용하여 OrderItem을 생성하게 한다.
public class OrderItem {

    @Id @GeneratedValue
    @Column(name ="order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice;
    private int count;

    // == 생성 메서드 ==
    public static OrderItem createOrderItem(Item item , int orderPrice , int count){
        OrderItem orderItem = new OrderItem();
        orderItem.setItem(item);
        orderItem.setOrderPrice(orderPrice);
        orderItem.setCount(count);

        item.removeStock(count);
        return orderItem;
    }

    // == 비즈니스 로직 ==
    public void cancel() {
        getItem().addStock(count);
    }

    // == 조회 로직 ==

    /**
     * 주문 상품 전체 가격 조회
     * @return int
     */
    public int getTotalPrice() {
        return getOrderPrice() * count;
    }
}

주문 리포지토리

@Repository
@RequiredArgsConstructor
public class OrderRepository {

    private final EntityManager em;

    public void save(Order order){
        em.persist(order);
    }

    public Order findOne(Long id){
        return em.find(Order.class , id);
    }

//    public List<Order> findAll(OrderSearch orderSearch){
//    }
}

주문 서비스

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;


    /**
     * 주문
     */
    @Transactional
    public Long order(Long memberId , Long itemId , int count){
        // 엔티티 조회
        Member member = memberRepository.findOne(memberId);
        Item item = itemRepository.findOne(itemId);

        // 배송 정보 생성
        Delivery delivery = new Delivery();
        delivery.setAddress(member.getAddress());

        // 주문상품 생성
        OrderItem orderItem = OrderItem.createOrderItem(item , item.getPrice() , count);

        // 주문 생성
        Order order = Order.createOrder(member , delivery , orderItem);

        // 주문 저장
        orderRepository.save(order);

        return order.getId();
    }

    /**
     * 주문 취소
     */
    @Transactional
    public void cancelOrder(Long orderId){
        Order order = orderRepository.findOne(orderId);
        order.cancel();
    }

    /**
     * 주문 검색
     */
//    public List<Order> findOrders(OrderSearch orderSearch){
//        return orderRepository.findAll();
//    }
}

주문 기능 테스트

@SpringBootTest
@Transactional
class OrderServiceTest {

    @Autowired
    EntityManager em;
    @Autowired
    OrderService orderService;
    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        // given
        Member member = createMember();

        Book book = createBook("시골 JPA", 10000, 10);

        int orderCount = 2;

        // when
        Long orderId = orderService.order(member.getId() , book.getId() , orderCount);

        // then
        Order getOrder = orderRepository.findOne(orderId);

        Assert.assertEquals("상품 주문시 상태는 ORDER" , OrderStatus.ORDER , getOrder.getStatus());
        Assert.assertEquals("주문한 상품 종류 수가 정확해야 한다." , 1 , getOrder.getOrderItems().size());
        Assert.assertEquals("주문 가격은 가격 * 수량이다." , 10000 * orderCount , getOrder.getTotalPrice());
        Assert.assertEquals("주문 수량만큼 재고가 줄어야한다." , 8 , book.getStockQuantity());

    }

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 2;

        Long orderId = orderService.order(member.getId() , item.getId() , orderCount);
        // when
        orderService.cancelOrder(orderId);

        // then
        Order getOrder = orderRepository.findOne(orderId);

        Assert.assertEquals("주문 취소시 상태는 CANCEL이다" , OrderStatus.CANCEL , getOrder.getStatus());
        Assert.assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다." , 10 , item.getStockQuantity());
    }
    
    @Test
    public void 상품주문_재고수량초과() throws Exception {
        // given
        Member member = createMember();

        Item item = createBook("시골 JPA", 10000, 10);

        int orderCount = 11;
        // when

        // then
        final NotEnoughStockException notEnoughStockException = Assertions.assertThrows(NotEnoughStockException.class,
                () -> orderService.order(member.getId(), item.getId(), orderCount),
                "재고수량이 부족 하면 예외가 발생 한다."
        );
        //        System.out.println(notEnoughStockException.getMessage());//need more stock
        //        assertEquals(notEnoughStockException.getMessage(), "need more Stock");
    }

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울" , "은평구" , "145151"));
        em.persist(member);
        return member;
    }

    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }
}

주문 검색 기능

JPA에서 동적 쿼리를 어떻게 해결해야 하는가?

목표 쿼리 : SELECT o FROM Order o JOIN o.member m  WHERE o.status = :status AND m.name LIKE :name

    // Querydsl
    public List<Order> findAllByQuerydsl_BestPractice(OrderSearch orderSearch){
        // Querydsl 소개 강의에서 적용 예정
        return null;
    }

    // JPA Criteria - JPA가 제공
    public List<Order> findAllByCriteria_BadPractice(OrderSearch orderSearch){
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Order> cq = cb.createQuery(Order.class);

        Root<Order> o = cq.from(Order.class);
        Join<Object , Object> m = o.join("member" , JoinType.INNER);

        List<Predicate> criteria = new ArrayList<>();

        // 주문 상태 검색
        if(orderSearch.getOrderStatus() != null){
            Predicate status = cb.equal(o.get("status") , orderSearch.getOrderStatus());
            criteria.add(status);
        }
        // 회원 이름 검색
        if(StringUtils.hasText(orderSearch.getMemberName())){
            Predicate name = cb.like(m.<String>get("name") , "%" + orderSearch.getMemberName() + "%");
            criteria.add(name);
        }

        cq.where(cb.and(criteria.toArray(new Predicate[criteria.size()])));
        TypedQuery<Order> query = em.createQuery(cq).setMaxResults(1000);

        return query.getResultList();
    }

    public List<Order> findAllByString_BadPractice(OrderSearch orderSearch){
        //language=JPAQL
        String jpql = "select o From Order o join o.member m";
        boolean isFirstCondition = true;
        //주문 상태 검색
        if (orderSearch.getOrderStatus() != null) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " o.status = :status";
        }
        //회원 이름 검색
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            if (isFirstCondition) {
                jpql += " where";
                isFirstCondition = false;
            } else {
                jpql += " and";
            }
            jpql += " m.name like :name";
        }
        TypedQuery<Order> query = em.createQuery(jpql, Order.class)
                .setMaxResults(1000); //최대 1000건
        if (orderSearch.getOrderStatus() != null) {
            query = query.setParameter("status", orderSearch.getOrderStatus());
        }
        if (StringUtils.hasText(orderSearch.getMemberName())) {
            query = query.setParameter("name", orderSearch.getMemberName());
        }
        return query.getResultList();
    }
  • 위 BadPractice들의 대안으로 Querydsl이 제시 됐다.
  • Querydsl 소개 파트에서 적용 예정이다.

 

주문 서비스의 [주문] 과 [주문취소] 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.

서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴 이라 한다.

반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴 이라 한다.
Q. 왜 주문내역은 OrderService를 만들어서 save() 메소드에 적지 않고 Order에 직접 적어주는건가요?
Order, OrderRepository , OrderItem, OrderItemRepository 처럼 엔티티마다 리포지토리를 각각 만들어서 사용하지 않고, OrderItem은 왜 OrderItemRepository가 없고, Order엔티티를 통해서 관리했나요?

A.이번 프로젝트는 Order가 OrderItem을 관리하도록 설계했습니다.
OrderItem을 저장하거나 관리하려면 별도의 리포지토리가 아니라, 항상 Order를 통해서 관리하는 하도록 설계를 제약했습니다.

개념상 Order, OrderItem을 하나로 묶고(Aggregate), Order를 통해서만 OrderItem에 접근하게 강제했습니다.
이렇게 설계를 하면 외부에서는 Order, OrderItem 중에 Order만 알면 되기 때문에, 도메인을 좀 더 덜 복잡하게 설계할 수 있습니다. 이렇게 그룹을 대표하는 엔티티를 도메인 주도 설계(DDD)에서는 aggregate root(에그리게잇 루트) 엔티티라 합니다.

이제 OrderItem의 생명주기는 모두 Order에 달려 있습니다. 심지어 OrderItem은 리포지토리도 없습니다. 모두 Order를 통해서 관리되는 것이지요. 물론 이런 생명주기는 Cascade 기능을 통해서 관리됩니다.
이번 예제에서는 이런 개념을 코드로 보여드리려고 이렇게 설계를 했습니다.

 

 

 

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 인프런

실무에 가까운 예제로, 스프링 부트와 JPA를 활용해서 웹 애플리케이션을 설계하고 개발합니다. 이 과정을 통해 스프링 부트와 JPA를 실무에서 어떻게 활용해야 하는지 이해할 수 있습니다. 초급

www.inflearn.com

'기록 > 스프링 부트 와 JPA 활용' 카테고리의 다른 글

변경 감지와 병합 (준영속 엔티티)  (0) 2021.02.06
웹 계층 개발  (0) 2021.01.31
상품 도메인 개발  (0) 2021.01.30
회원 도메인 개발  (0) 2021.01.30
도메인 분석 설계  (0) 2021.01.27