mojo's Blog

주문 도메인 개발 본문

Spring

주문 도메인 개발

_mojo_ 2022. 9. 6. 00:28

 

주문, 주문상품 엔티티 개발

 

구현 기능

- 상품 주문

- 주문 내역 조회

- 주문 취소

 

순서

 

  1.  주문 엔티티, 주문 상품 엔티티 개발
  2. 주문 리포지토리 개발
  3. 주문 서비스 개발
  4. 주문 검색 기능 개발
  5. 주문 기능 테스트

 

Order 와 OrderItem 의 관계는 다음과 같다.

 

 

Order 클래스

@Entity
@Table(name = "orders")
@Getter @Setter
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; // 주문시간

    @Enumerated(EnumType.STRING)
    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();
        }
    }

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

 

- createOrder : 필요한 객체(member, delivery, orderItems)들을 받아와서 해당 주문의

   멤버, 배송 주소 및 상태, 그리고 주문한 아이템들을  추가하고 현재 주문상태와

   주문한 현재 날짜등을 세팅하여 최종으로 세팅된 객체를 반환하는 메서드이다.

 

- cancel : 배달이 완료된 상태일 때 예외를 던진다.

   완료가 아닌 상태일 때 현재 주문 상태를 cancel 상태로 변환하여 현재 주문한 아이템들

   에 대한 모든 cancel 작업을 적용한다. (orderItem 의 cancel 은 OrderItem 에 설명)

 

- getTotalPrice : 주문한 아이템들의 가격들을 합하여 반환하는 메서드이다.

 

OrderItem 클래스

@Entity
@Getter @Setter
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);
    }

    // == 조회 로직 == //

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

 

- createOrderItem : 아이템과 주문 가격 및 갯수 등을 파라메터로 받아온다.

   그리고 orderItem 객체를 생성하여 파라메터에 대한 세팅 작업을 수행하고,

   현재 아이템에서 파라메터로 받아온 갯수를 제거하여 반환한다.

 

-  cancel : 취소한 경우 아이템의 물량이 증가해야 하므로, 현재 주문 아이템의

   갯수를 추가하도록 반영한다.

 

- getTotalPrice : 주문한 아이템의 가격과 주문한 아이템의 갯수를 곱하여 반환한다.

 

 

주문 리포지토리 개발

 

OrderRepository 클래스

@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);
    }
   
}

 

- @RequiredArgsConstructor : 엔티티매니저에 대한 의존관계 주입을 받도록 해준다.

 

- save : 주문 객체에 대한 저장을 하는 메서드이다.

 

- findOne : 주문 id 를 파라메터로 받아와 해당하는 주문 객체를 반환하는 메서드이다.

 

 

주문 서비스 개발

 

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem { ... }

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order { ... }

 

- @NoArgsConstructor : 롬북을 활용하여 접근 수준을 protected 으로 설정한다.

   protected 수준이 되면 외부 클래스(상속관계 x)에서 new 연산을 수행할 때 에러를 발생한다.

   이는 new 연산을 통해 객체를 받아와서 set 작업을 하는 것이 아닌 다음과 같이 set 작업으로 통일하기 위함이다.

 

    // == 생성 메서드 == //
    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 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;
    }

 

위와 같이 static 타입으로 Order.createOrder(...), OrderItem.createOrderItem(...) 와 같은 방식으로

메서드 내에서 new 를 통해 파라메터에 따른 세팅 작업을 수행하여 객체를 반환하는 방식을 선호한다고 한다.

따라서 클래스 접근 수준을 protected 으로 하여 외부에서 new 작업을 수행하는게 아닌, 클래스의 생성 메서드를

통해 다이렉트로 받아올 수 있도록 한다.

 

OrderService 클래스

@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();
    }
}

 

- order : 파라메터로 받아온 멤버, 아이템 아이디를 가지고 멤버와 아이템을 조회한다.

   그리고 멤버의 주소를 통해 배송 정보를 세팅한다.

   배송정보 세팅이 완료되면 파라메터로 주문하고자 할 아이템의 갯수를 가지고 주문상품을

   생성하고, 주문

 

- cancelOrder : 파라메터로 받아온 orderId 로 주문 엔티티를 조회한다.

   그리고 위에서 구현했던 Order 클래스의 cancel 메서드를 수행한다.

 

 

※ 도메인 모델 패턴

 

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

서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.

이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴

이라고 한다.

반대로 엔티티에는 비즈니스 로직이 거의 없고, 서비스 계층에서 대부분의 비즈니스 로직을 처리하는

것을 트랜잭션 스크립트 패턴이라고 한다.

 

 

주문 기능 테스트

 

@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @Autowired
    EntityManager em;

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        // given
        Member member = getMember("회원1", new Address("서울", "강북", "1234"));
        Book book = getBook("JPA", 10000, 10);

        // when
        int orderCount = 2;
        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(expected = NotEnoughStockException.class)
    public void 상품주문_재고수량초과() throws Exception {
        // given
        Member member = getMember("회원1", new Address("서울", "강북", "1234"));
        Book book = getBook("JPA", 10000, 10);

        // when
        int orderCount = 11;

        orderService.order(member.getId(), book.getId(), orderCount);

        // then
        Assert.fail("재고 수량 부족 예외가 발생해야 한다.");
    }

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = getMember("회원1", new Address("서울", "강북", "1234"));
        Book book = getBook("JPA", 10000, 10);
        int orderCount = 2;
        Long orderId = orderService.order(member.getId(), book.getId(), orderCount);

        // when
        orderService.cancelOrder(orderId);

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

        Assert.assertEquals("주문 취소시 상태는 CANCEL", OrderStatus.CANCEL, getOrder.getStatus());
        Assert.assertEquals("주문이 취소된 상품은 그만큼 재고가 증가", 10, book.getStockQuantity());
    }

    private Member getMember(String name, Address address) {
        Member member = new Member();
        member.setName(name);
        member.setAddress(address);
        em.persist(member);
        return member;
    }

    private Book getBook(String name, int price, int stockQuantity) {
        Book book = new Book();

        book.setName(name);
        book.setPrice(price);
        book.setStockQuantity(stockQuantity);
        em.persist(book);
        return book;
    }
}

 

 

주문 검색 기능 개발

 

주문 상태와 회원 이름을 통해 주문 리스트를 확인하고 싶은 경우가 있다.

이때 리스트를 받아올 수 있는 방법은 여러가지가 있다.

우선 String 으로 sql 을 직접 다 작성하는 방법과 JPA Criteria 를 이용한 방법을 살펴보도록 하자.

 

그 전에 OrderSearch 클래스를 생성하여 주문 상태와 회원 이름을 따로 관리할 수 있는 클래스를 생성한다.

@Getter @Setter
public class OrderSearch {

    private String memberName; // 회원 이름
    private OrderStatus orderStatus; // 주문 상태[ORDER, CANCEL]
}

 

String 으로 sql 을 작성하는 방법은 아래와 같다.

/**
 * 문자열을 이용한 방법
 */
public List<Order> findAllByString(OrderSearch orderSearch) {
    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 liek :name";
    }

    TypedQuery<Order> query = em.createQuery(jpql, Order.class)
            .setMaxResults(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();
}

 

jpql 문자열을 가지고 주문 상태와 회원 이름에 따라 jpql 의 문자열을 추가하는 방식으로 구현이 되어있다.

그리고 TypedQuery 객체 query 에 대해서 조건문에서 설정된 파라메터에 대한 바인딩을 해줘야 한다.

따라서 status, name 에 따라 주문 상태, 회원 이름이 있을 때 파라메터 바인딩을 해주면 된다.

최종적으로 만들어진 쿼리에 대해 getResultList 를 반환하면 된다.

그러나 이 방법은 문자열 처리를 하기 위한 많은 노력이 들어간 코드로 잘못 타이핑하면 에러를 찾기 많이

힘들 것으로 보인다.

 

JPA Criteria 를 이용한 방법은 아래와 같다.

/**
 * JPA Criteria
 */
public List<Order> findAllByCriteria(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();
}

 

문자열을 이용하는 방법보다는 약간 간단해진 것 같다.

그러나 이 방법도 현업에서는 잘 쓰이지 않는 방법이라고 한다.

 

 

※ Querydsl

 

실무에서는 조건에 따라서 실행되는 쿼리가 달라지는 동적 쿼리를 많이 사용한다.

위에서 사용한 방법을 대체한 방법으로 많이 간단하며 위 예제를 Querydsl 로 바꿔보도록 하자.

 

public List<Order> findAll(OrderSearch orderSearch) {
	QOrder order = QOrder.order;
    QMember member = QMember.member;
    
    return query
    	.select(order)
        .from(order)
        .join(order.member, member)
        .where(statusEq(orderSearch.getOrderStatus()),
        		nameLike(orderSearch.getMemberName()))
        .limit(1000)
        .fetch();
}

private BooleanExpression statusEq(OrderStatus statusCond) {
	if (statusCond == null) {
    	return null;
    }
    return order.status.eq(statusCond);
}

private BooleanExpression nameLike(String nameCond) {
	if (!StringUtils.hasText(nameCond)) {
    	return null;
    }
    return member.name.like(nameCond);
}

 

확실히 실무에서 많이 쓰일법하게 간단해진 것을 확인할 수 있다.

'Spring' 카테고리의 다른 글

웹 계층 개발 - (2)  (0) 2022.09.08
웹 계층 개발 - (1)  (2) 2022.09.06
상품 도메인 개발  (0) 2022.09.02
회원 도메인 개발  (1) 2022.09.02
애플리케이션 구현 준비  (2) 2022.09.02
Comments