Jpa와 도메인 모델 패턴 예시

성장에 몰입 중
주문, 주문상품 엔티티 개발
주문 엔티티 코드
package jpabook.jpashop.domain; import static javax.persistence.FetchType.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import javax.persistence.CascadeType; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.OneToMany; import javax.persistence.OneToOne; import javax.persistence.Table; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Table(name = "orders") @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) //static 생성 메서드 사용 강제를 위해 설정 public class Order { @Id @GeneratedValue @Column(name = "order_id") private Long id; @ManyToOne(fetch = LAZY) //default가 EAGER @JoinColumn(name = "member_id") private Member member; //JPQL select o From order o; -> SQL select * from order (order를 조회할 때 멤버를 중복 조회하면 n+1 문제!) @OneToMany(mappedBy = "order", // default가 LAZY cascade = CascadeType.ALL) // 관련된 update(persist) 전파 private List<OrderItem> orderItems = new ArrayList<>(); @OneToOne(fetch = LAZY, cascade = CascadeType.ALL) @JoinColumn(name = "delivery_id") private Delivery delivery; private LocalDateTime orderDate; //주문시간 @Enumerated(EnumType.STRING) private OrderStatus status; //주문상태 [ORDER, CANCEL] //==연관관계 편의 메서드==// //DB에서는 연관관계 주인 기준으로 단방향 매핑되지만, 어플리케이션에서 논리적으로 양방향 연관관계 매핑해기 위해 정의 //항상 직접 비즈니스 로직에서 매핑해주기보다 Entity에서 정의 //두 연관관계 중 자주 조회하는 기준으로 정의 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); Arrays.stream(orderItems).forEach(order::addOrderItem); order.setStatus(OrderStatus.ORDER); order.setOrderDate(LocalDateTime.now()); return order; } //==비즈니스 로직==// /** * 주문 취소 */ public void cancel() { if (this.delivery.getStatus() == DeliveryStatus.COMP) { throw new IllegalStateException("이미 배송완료된 상품은 취소가 불가능합니다."); } this.setStatus(OrderStatus.CANCEL); this.orderItems.forEach(OrderItem::cancel); } //==조회 로직==// /** * 전체 주문 가격 조회 */ public int getTotalPrice() { return this.orderItems.stream() .mapToInt(OrderItem::getTotalPrice) .sum(); } }기능설명
생성 메서드( createOrder() ): 주문 엔티티를 생성할 때 사용한다. 주문 회원, 배송정보, 주문상품의 정보 를 받아서 실제 주문 엔티티를 생성한다.
주문 취소( cancel() ): 주문 취소시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린 다. 만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
전체 주문 가격 조회: 주문 시 사용한 전체 주문 가격을 조회한다. 전체 주문 가격을 알려면 각각의 주문상품 가격을 알아야 한다. 로직을 보면 연관된 주문상품들의 가격을 조회해서 더한 값을 반환한다. (실무에서는 주로 주문에 전체 주문 가격 필드를 두고 역정규화 한다.)
주문상품 엔티티 코드
package jpabook.jpashop.domain; import static javax.persistence.FetchType.*; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import jpabook.jpashop.domain.item.Item; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; @Entity @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) //static 생성 메서드 사용 강제를 위해 설정 public class OrderItem { @Id @GeneratedValue @Column(name = "order_item_id") private Long id; @ManyToOne(fetch = LAZY) @JoinColumn(name = "item_id") private Item item; @ManyToOne(fetch = 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(this.count); } //==조회 로직==// /** * 주문상품 전체 가격 조회 */ public int getTotalPrice() { return getOrderPrice() * getCount(); } }기능설명
기능 설명 생성 메서드( createOrderItem() ): 주문 상품, 가격, 수량 정보를 사용해서 주문상품 엔티티를 생성한다. 그리고 item.removeStock(count) 를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
주문 취소( cancel() ): getItem().addStock(count) 를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
주문 가격 조회( getTotalPrice() ): 주문 가격에 수량을 곱한 값을 반환한다.
주문 리포지토리 개발
주문 리포지토리 코드
package jpabook.jpashop.repository; import javax.persistence.EntityManager; import org.springframework.stereotype.Repository; import jpabook.jpashop.domain.Order; import lombok.RequiredArgsConstructor; @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) { // // } }
주문 서비스 개발
주문 서비스 코드
package jpabook.jpashop.service; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import jpabook.jpashop.domain.Delivery; import jpabook.jpashop.domain.Member; import jpabook.jpashop.domain.Order; import jpabook.jpashop.domain.OrderItem; import jpabook.jpashop.domain.item.Item; import jpabook.jpashop.repository.ItemRepository; import jpabook.jpashop.repository.MemberRepository; import jpabook.jpashop.repository.OrderRepository; import lombok.RequiredArgsConstructor; @Service @Transactional(readOnly = true) @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final MemberRepository memberRepository; private final ItemRepository itemRepository; /** * 주문 */ @Transactional //Order 엔티티에서 cascade = CascadeType.ALl이라 persist 전파 됨 //따라서 order만 save해도 orderItem, delivery 자동 persist //연관된 객체를 나만 참조할 때 cascade를 사용할만함(그게 아니면 cascade 사용에 매우 주의해야 함!!) 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); // } }주문 서비스는 주문 엔티티와 주문 상품 엔티티의 비즈니스 로직을 활용해서 주문, 주문 취소, 주문 내역 검색 기능을 제공한다.
참고: 예제를 단순화하려고 한 번에 하나의 상품만 주문할 수 있다.
기능설명
주문( order() ): 주문하는 회원 식별자, 상품 식별자, 주문 수량 정보를 받아서 실제 주문 엔티티를 생성한 후 저장한다.
주문 취소( cancelOrder() ): 주문 식별자를 받아서 주문 엔티티를 조회한 후 주문 엔티티에 주문 취소를 요청한다.
주문 검색( findOrders() ): OrderSearch 라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다. 자세한 내용은 다음에 나오는 주문 검색 기능에서 알아보자.
참고: 주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다. 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
이처럼 엔티티가 비즈니스 로직을 가지고 객체 지 향의 특성을 적극 활용하는 것을
도메인 모델 패턴(http://martinfowler.com/eaaCatalog/domainModel.html)이라 한다.반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분 의 비즈니스 로직을 처리하는 것을
트랜잭션 스크립트 패턴(http://martinfowler.com/eaaCatalog/transactionScript.html)이라 한다.
주문 기능 테스트
상품 주문 테스트 코드
재고 수량 초과 테스트 코드
주문 취소 테스트 코드
package jpabook.jpashop.service; import static org.junit.Assert.*; import javax.persistence.EntityManager; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.transaction.annotation.Transactional; import jpabook.jpashop.domain.Address; import jpabook.jpashop.domain.Member; import jpabook.jpashop.domain.Order; import jpabook.jpashop.domain.OrderStatus; import jpabook.jpashop.domain.item.Book; import jpabook.jpashop.domain.item.Item; import jpabook.jpashop.exception.NotEnoughStockException; import jpabook.jpashop.repository.OrderRepository; //단위테스트가 좋지만 JPA 강의니까 DB와 연동해서 테스트 코드 작성 @RunWith(SpringRunner.class) @SpringBootTest @Transactional public class OrderServiceTest { @Autowired EntityManager em; @Autowired OrderService orderService; @Autowired OrderRepository orderRepository; @Test public void 상품주문() throws Exception { //given final int ORDER_COUNT = 2; final int BOOK_PRICE = 10000; final int BOOK_STOCK_QUANTITY = 10; Member member = createMember(); Book book = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY); //when Long orderId = orderService.order(member.getId(), book.getId(), ORDER_COUNT); //then Order getOrder = orderRepository.findOne(orderId); assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus()); assertEquals("주문한 상품 종류 수가 정확해야 한다.", 1, getOrder.getOrderItems().size()); assertEquals("주문 가격은 가격 * 수량이다.", BOOK_PRICE * ORDER_COUNT, getOrder.getTotalPrice()); assertEquals("주문 수량만큼 재고가 줄어야 한다.", BOOK_STOCK_QUANTITY - ORDER_COUNT, book.getStockQuantity()); } @Test(expected = NotEnoughStockException.class) public void 상품주문_재고수량초과() throws Exception { //given final int ORDER_COUNT = 11; final int BOOK_PRICE = 10000; final int BOOK_STOCK_QUANTITY = 10; Member member = createMember(); Item item = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY); //when orderService.order(member.getId(), item.getId(), ORDER_COUNT); //then fail("재고 수량 부족 예외가 발생해야 한다."); } @Test public void 주문취소() throws Exception { //given final int ORDER_COUNT = 2; final int BOOK_PRICE = 10000; final int BOOK_STOCK_QUANTITY = 10; Member member = createMember(); Item item = createBook("JPA", BOOK_PRICE, BOOK_STOCK_QUANTITY); Long orderId = orderService.order(member.getId(), item.getId(), ORDER_COUNT); //when orderService.cancelOrder(orderId); //then Order getOrder = orderRepository.findOne(orderId); assertEquals("주문 취소시 상태는 CANCLE이다", OrderStatus.CANCEL, getOrder.getStatus()); assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", BOOK_STOCK_QUANTITY, item.getStockQuantity()); } 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("수원", "영통", "100")); em.persist(member); return member; } }
주문 검색 기능 개발
주문 검색 기능 코드
package jpabook.jpashop.repository; import java.util.ArrayList; import java.util.List; import javax.persistence.EntityManager; import javax.persistence.TypedQuery; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Join; import javax.persistence.criteria.JoinType; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; import jpabook.jpashop.domain.Member; import jpabook.jpashop.domain.Order; import lombok.RequiredArgsConstructor; @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); } //JPQL로 처리(실무에서는 Querydsl 사용) public List<Order> findAllByString(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(); } //JPA Criteria로 처리(실무에서는 Querydsl 사용) 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<Order, Member> 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); //최대 1000 건 return query.getResultList(); } }JPA Criteria는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡하다. 결국 다른 대안이 필요하다. 많 은 개발자가 비슷한 고민을 했지만, 가장 멋진 해결책은 Querydsl이 제시했다. Querydsl 소개장에서 간 단히 언급하겠다. 지금은 이대로 진행하자.
참고: JPA Criteria에 대한 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 책을 참고하자
출처
![[프론트] 노코드 툴 FlutterFlow로 개발하기](https://cdn.hashnode.com/res/hashnode/image/upload/v1716882498562/d3df0e95-80b8-446d-84a8-784dca1e1607.png)

