주문 엔티티 개발
@Entity
@Getter
@Setter
@Table(name = "orders")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id
@Column(name = "order_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
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;
//== 연관관계 메서드 ==//
public void addMember(Member member) {
this.member = member;
member.getOrders().add(this);
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void addDelivery(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() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
생성 메서드
밖에서 Order order = new Order() 객체 생성 후 setter을 사용하여 값을 채우는 방식이 아닌 Order 엔티티 안에 생성 메서드로 객체를 생성하는 방식이다. Order을 생성하는 것과 관련된 거면 이 생성 메서드만 보면 된다. 변경지점을 한 곳으로 둬서 이것저것 찾아다닐 필요 없다.
생성 메서드( createOrder() ): 주문 엔티티를 생성할 때 사용한다.
주문 회원, 배송정보, 주문상품의 정보를 받아서 실제 주문 엔티티를 생성한다.
비즈니스 로직
주문 취소를 보면 이미 배송된 상품은 주문하지 못한다는 비즈니스 로직에 대한 체크 로직이 엔티티 안에 있다.
주문 취소( cancel()): 주문 취소 시 사용한다. 주문 상태를 취소로 변경하고 주문상품에 주문 취소를 알린다.
만약 이미 배송을 완료한 상품이면 주문을 취소하지 못하도록 예외를 발생시킨다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
외부에서 new Order()하여 객체 생성을 막기 위한 기능이다.
코드를 제약하는 식으로 짜는 게 좋은 설계와 유지 보수를 끌어갈 수 있다.
주문 서비스 개발
@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();
}
order만 save 하고 orderItem과 delivery는 save 하지 않았다.
하지만 order만 save 해도 나머지 두 객체도 저장되는 이유는 order에서 delviery와 orderItem에 대해 cascade ALL 옵션을 사용했기 때문이다. order만 delivery와 orderItem을 참조하고 관리한다면 cascade ALL 옵션을 사용하면 좋다. 만약에 다른 곳에서도 delivery와 orderItem을 참조해서 관리한다면 cascade ALL 옵션을 사용하지 않는 게 좋다.
참고
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.
서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴 (http://martinfowler.com/eaaCatalog/domainModel.html)이라 한다.
반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴(http://martinfowler.com/eaaCatalog/transactionScript.html)이라 한다.
주문 기능 테스트
@SpringBootTest
@Transactional
class OrderServiceTest {
@Autowired
EntityManager em;
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void 상품주문() throws Exception {
Member member = createMember();
Book book = createBook("book", 5000, 5);
int orderCount = 2;
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.ORDER, getOrder.getStatus(), "상품 주문시 상태는 ORDER");
assertEquals(getOrder.getOrderItems().size(), 1, "주문한 상품 종류 수가 정확해야 한다.");
assertEquals(getOrder.getTotalPrice(), 10000, "주문 가격은 가격 * 수량이다.");
assertEquals(book.getStockQuantity(), 3, "주문 수량만큼 재고가 줄어야 한다.");
}
@Test
public void 주문취소() throws Exception {
Member member = createMember();
Book item = createBook("book", 5000, 5);
int orderCount = 2;
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
orderService.cancelOrder(orderId);
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCEL 이다.");
assertEquals(item.getStockQuantity(), 5, "주문이 취소된 상품은 그만큼 재고가 증가해야한다.");
}
@Test
public void 상품주문_재고수량초과() throws Exception {
Member member = createMember();
Item item = createBook("book", 1000, 10);
int orderCount = 11;
assertThrows(NotEnoughStockException.class, () -> { orderService.order(member.getId(), item.getId(), orderCount); });
}
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;
}
}
@SpringBootTest
@Transactional
class MemberServiceTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void 회원가입() throws Exception {
Member member = new Member();
member.setName("memberA");
Long findMemberId = memberService.join(member);
assertEquals(member, memberRepository.findOne(findMemberId));
}
@Test
public void 중복_회원_예외() throws Exception {
//Given
Member member1 = new Member();
member1.setName("kim");
Member member2 = new Member();
member2.setName("kim");
//When
memberService.join(member1);
//Then
assertThrows(IllegalStateException.class, () -> { memberService.join(member2); });
}
}
주문 검색 기능 개발
JPA에서 동적 쿼리를 어떻게 해결해야 하는가?
public List<Order> findAll(OrderSearch orderSearch) {
return em.createQuery("select o from Order o join o.member m" +
" where o.status = :status " +
" and m.name like :name", Order.class)
.setParameter("status", orderSearch.getOrderStatus())
.setParameter("name", orderSearch.getMemberName())
.setMaxResults(1000)
.getResultList();
}
위와 같이 where절에 조건을 작성한 쿼리가 있다고 하자.
문제는 위 쿼리는 OrderSearch에 값이 다 있다는 가정하에 작성된 거다.
만약에 OrderSearch에 값이 없다면 select o from Order o join o.member m 이 부분만을 실행해서 값을 가져오고 싶을 수 있다. OrderSearch에 name 파라미터가 없다면 name 파라미터에 대한 필터링 조건을 쓰지 말고 데이터를 다 가져오는 경우 또는 OrderSearch에 OrderStatus 파라미터가 null이라면 where 절에 status 조건 사용하지 않고 데이터를 다 가져오는 경우가 있다.
최종적으로 아래와 같이 쿼리가 바뀌어야 한다. 즉 동적 쿼리가 필요해진다.
public List<Order> findAll(OrderSearch orderSearch) {
return em.createQuery("select o from Order o join o.member m", Order.class)
.setMaxResults(1000)
.getResultList();
}
JPQL로 처리
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();
}
JPQL 쿼리를 문자로 생성하기는 번거롭고, 실수로 인한 버그가 충분히 발생할 수 있다.
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<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이 제시했다.
'JPA > JPA 활용 1' 카테고리의 다른 글
7. 웹 계층 개발 (0) | 2024.08.22 |
---|---|
5. 상품 도메인 개발 (0) | 2024.08.20 |
4. 회원 도메인 개발 (0) | 2024.08.20 |
2. 도메인 분석 설계 (0) | 2024.08.20 |
1. 프로젝트 환경설정 (0) | 2024.08.20 |