실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션6. 주문 도메인 개발
CS/김영한 스프링 강의

실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션6. 주문 도메인 개발

이제 아이템을 주문할 때 창고에 있는 아이템 개수는 줄어들고, 주문목록엔 생겨나고, 장바구니 역할을 하는 OrderItem의 총 가격 등을 계산하고.. 같은 핵심 비즈니스를 해볼거다.

 

@Entity
@Table(name = "orders")
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // protected로 생성자를 막음. createOrder를 사용하도록 함.
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) // ORDINAL은 숫자로 들어감. 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) { // ...으로 받은 orderItems를 for문으로 돌림.
            order.addOrderItem(orderItem);
        }
        order.setStatus(OrderStatus.ORDER); // 주문상태를 ORDER로 설정.
        order.setOrderDate(LocalDateTime.now()); // 주문시간을 현재시간으로 설정.
        return order;
    }

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

    //==조회 로직==//
    /**
     * 전체 주문 가격 조회
     */
    public int getTotalPrice() { // 전체 주문 가격을 구하는 메서드.
        // orderItems를 stream으로 돌림.
        // mapToInt로 OrderItem의 getTotalPrice를 뽑아냄. sum으로 더함.
        return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();

    }

}

엔터티의 속성들이라 깊게 연관되어 있어서 엔터티 함수로 해야되는데 복잡한거면 static를 사용해서 언제든 사용할 수 있게 하는게 좋음. 여기선 주문 만들기인데 그래서 주문 만들 땐 생성자 따로 쓰지 말고 저걸 사용해서 하기. 그래서 아얘 못사용하게 생성자를 protected로 막는게.

 

@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 orderItem = new OrderItem();
        orderItem.setItem(item); // OrderItem에 Item을 넣음.
        orderItem.setOrderPrice(orderPrice); // OrderItem에 주문 가격을 넣음.
        orderItem.setCount(count); // OrderItem에 주문 수량을 넣음.

        item.removeStock(count); // Item에서 주문 수량만큼 재고를 뺌.
        return orderItem;
    }

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

    public int getTotalPrice() {
        return getOrderPrice() * getCount();
    }
}
@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(orderSearch);
//    }
}

서비스의 간단한 함수지만 주목해야 할 건 두가지가 있다.

하나는 분명 OrderItem, Order, Delivery들은 각각의 테이블을 만들기 때문에 각각 persist로 save를 해줘야 할 것 같은데, 그냥 order의 save 하나로 저 3가지가 모두 테이블 db에 저장된다는 것. 이유는 cascadeType.ALL을 설정해주었기 때문임. 저 함수를 실행하는 동안 변화를 감지하고 기억했다가 저장할 때 한번에 다 같이 저장한다. 그러나 cascade로 묶여 있기 때문에 삭제할 때도 한번에 삭제되서 만약 다른곳에서도 참고하고 있는 내용이었는데 갑자기 사라지면 곤란해질 수 있다. 그러니 처음엔 안쓰다가 익숙해지면 쓰는걸 추천한다.

또 하나는 서비스는 그냥 함수 순서대로 쭉 불러와서 실행하는것 밖에 없어서 사실상 비즈니스 핵심 기능은 언티티 도메인에 몰빵되어 있는데 이른 도메인 모델 패턴이라고 함. 반대로 서비스에 집중되어 있는걸 트랜잭션 스크립트 패턴이라고 한다. 어느게 더 좋다는게 아니고 자기 프로젝트에 따라 뭐가 더 좋은지는 다르고, 둘 다 양립할 수도 있다.

 

이제 이렇게 작성한 걸 테스트하는 테스트 코드를 작성해보자.

@RunWith(SpringRunner.class)
@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);

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

    @Test
    public void 상품주문_재고수량초과() {
        // given
        Member member = createMember();
        Book book = createBook("시골 JPA", 10000, 10);

        int orderCount = 11;

        // when
        Assertions.assertThatThrownBy(() -> {
            orderService.order(member.getId(), book.getId(), orderCount);
        }).isInstanceOf(NotEnoughStockException.class);

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

    }

    @Test
    public void 주문취소() throws Exception {
        // given
        Member member = createMember();
        Book 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);

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

    }

    @Test
    public void 상품주문_예산초과() throws Exception {
        // given

        // when

        // then
    }


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

    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }
}

사실 이건 강의용이고 전체적으로 잘 돌아가는지 통합 테스트라서 그렇지, 유닛테스트 별로 독립적으로 나눠서 테스트 하는게 좋다.

 

검색 기능을 추가 하려고 해보자.

일단 이렇게 적었는데 조건이 없으면?? 그래서 동적쿼리를 해야하는데 if문을 짜서 만약 조건이 없으면 어쩌구~ 해서 string을 만들던지, 표준 스택인 jpa creteria을 해서 하던지 하는 방법이 있는데 둘 다 사람이 할 게 아니다. 그래서 querydsl 추천함.