김영한 님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 보고 작성한 내용입니다.
public class Order {
...
// 생성 메서드 ( 연관관계가 복잡하기 때문에 별도의 생성 메서드가 있으면 편리 )
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;
}
}
Order 는 연관관계가 복잡하기 때문에 Order 를 생성하는 생성 메서드를 정의합니다. 해당 메서드가 호출된 후에 연관관계도 다 정리되고, status 와 주문시간까지 다 세팅된 Order 객체가 반환됩니다.
참고로 ...
은 자바의 가변 인자를 나타냅니다. 메서드의 매개변수 선언에서 사용되며, 가변 인자를 통해 메서드를 호출할 때 여러 개의 값을 전달할 수 있습니다. 메서드 내에서는 이 가변 인자를 배열로 다룰 수 있습니다.
public class OrderItem {
...
// 생성 메서드
public static OrderItem crateOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStockQuantity(count);
return orderItem;
}
}
마찬가지로 OrderItem 역시 위와 동일한 방식으로 생성 메서드를 작성합니다. 다만 OrderItem 은 item 의 수량을 주문 개수만큼 감소시키는 코드가 필요합니다.
그래서 Order 가 생성되기 전, OrderItem 을 생성하면서 Item 의 재고가 감소된 후에 Order 의 생성 메서드로 OrderItem 이 전달됩니다.
다른 사람과 함께 작업할 때 누군가는 createOrder()
와 같은 메서드를 사용하고, 누군가는 new Order()
를 사용할 수 있습니다.
new
로 객체를 생성하는 것을 막기 위해서 빈 생성자를 만들고 protected 를 사용하면 new
로 해당 객체를 생성할 수 없게 됩니다.
롬복을 사용한다면 @NoArgsConstructor(access = AccessLevel.PROTECTED)
로 동일한 기능을 수행할 수 있습니다.
JPA 를 사용할 때 아무것도 없는 기본 생성자를 protected 까지는 만들어 놓아야 합니다. ( private 는 안됨 ) 왜냐하면 JPA 프록시를 사용할 때 객체를 만들어야 하는데 private 로 하면 만들 수 없기 때문입니다.
public class Order {
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송 완료된 상품은 취소가 불가능합니다");
}
this.setStatus(OrderStatus.CANCEL);
// 여러 개 상품을 한 번에 주문할 수 있기 때문에 각 OrderItem 에 대해서 cancel
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
}
주문을 취소할 때는 우선 현재 배송 상태를 보고, 배송이 완료된 상태라면 취소가 불가능하다는 예외 메세지를 던집니다.
만약 가능하다면 현재 주문의 상태를 CANCEL
로 변경합니다. 그 후 Order 가 가진 OrderItem 리스트인 orderItems 를 반복하면서 각각의 주문 상품에 대해서 cancel()
을 호출합니다.
한 번에 여러 종류의 상품을 주문할 수 있기 때문에 반복문을 통해 각 OrderItem 에 대해서 처리합니다.
public class OrderItem {
public void cancel() {
getItem().addStockQuantity(count);
}
}
OrderItem 에서 cancel()
메서드는 주문이 취소되었기 때문에 각 Item 의 재고를 주문한 수량만큼 증가시키는 역할을 합니다.
MyBatis 나 Jdbc Template 과 같이 SQL 을 직접 다루는 라이브러리를 사용하면 위처럼 주문 상태나 수량을 변경했을 때 Update 쿼리를 날려야 합니다.
하지만 JPA 는 Entity 안의 데이터를 변경하면 변경 내역 감지가 일어나면서 변경된 내용을 찾아 DB 에 Update 쿼리가 자동으로 날라가게 됩니다.
public class Order {
public int getTotalPrice() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
전체 가격을 조회하는 것 역시, 한 번의 주문에 여러 종류의 상품을 주문할 수 있기 때문에 OrderItem 을 반복하며 진행합니다.
하나의 OrderItem 의 가격은 getTotalPrice()
라는 메서드를 호출하여 가져오도록 합니다.
public class OrderItem {
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
getTotalPrice()
에서는 각 상품의 가격과 수량을 곱해 현재 OrderItem 에 대한 전체 가격을 계산해서 반환하게 됩니다.
@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);
}
}
@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) {
// entity 조회
Member member = memberRepository.findOne(memberId);
Item item = itemRepository.findOne(itemId);
// 배송정보 생성
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
// 주문상품 생성
OrderItem orderItem = OrderItem.crateOrderItem(item, item.getPrice(), count);
// 주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
// 주문 저장
orderRepository.save(order);
return order.getId();
}
}
Order 를 생성하기 위해서는 OrderItem 과 Delivery 가 필요합니다. OrderItem 은 Item 이 필요하고, Delivery 는 memebr 의 address 가 필요합니다.
때문에 OrderRepository 뿐만 아니라 MemberRepository 와 ItemRepository 가 함께 필요합니다.
원래대로라면 Delivery 도 따로 저장해야 하고, OrderItem 도 따로 저장한 후에 Order 를 저장해야 하는데 앞에서 CascadeType.ALL
로 설정했기 때문에 order 를 저장하면 delivery 와 orderItem 도 저장되게 됩니다.
CascadeType.ALL
는 private owner 인 경우에만 사용하는 것이 좋다고 합니다.
현재 연관관계를 보면 Order 가 OrderItem 을 관리하고, Order 가 Delivery 를 관리합니다. 즉, Order 외에는 OrderItem 이나 Delivery 를 참조하는 Entity 가 존재하지 않습니다.
이처럼 해당 Entity 를 참조하는 Entity 가 하나 밖에 없고, 라이프 사이클에 대해서 동일하게 관리할 때 CascadeType.ALL
가 의미가 있게 됩니다.
만약 Delivery 나 OrderItem 을 다른 Entity 에서 참조하고 있다면 따로 Repository 를 생성해서 persist()
를 별도로 하는 것이 좋습니다.
public class OrderService {
...
@Transactional
public void cancelOrder(Long orderId) {
Order order = orderRepository.findOne(orderId);
order.cancel();
}
}
주문 취소의 경우 orderId 에 해당하는 Order 를 찾아 cancel()
을 호출해주면 됩니다.
cancel()
메서드는 3-1-2 에서 상태 변경과 재고 증가와 같은 로직을 구현해놓았습니다. 또한 JPA 를 사용하기 때문에 Entity 내부에 변경이 일어나면 자동으로 Update 를 해주게 됩니다.
주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있고 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 합니다.
이처럼 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인 모델 패턴이라고 합니다. 반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴이라고 합니다.
@Getter
@Setter
public class OrderSearch {
// 검색 조건
private String memberName; // 회원이름
private OrderStatus orderStatus; // 주문 상태 : ORDER, CANCEL
}
OrderSearch 라는 클래스를 만들어 검색할 수 있는 필드들을 생성합니다. 예제에서는 회원의 이름과 주문 상태로 검색할 수 있습니다.
public class OrderRepository {
...
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 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 class OrderRepository {
...
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class); // Order.class 는 응답 타입
Root<Order> o = cq.from(Order.class); // 어떤 Entity 로부터 조회할 것인지, 앞에있는 o 가 alias
Join<Order, Member> m = o.join("member", JoinType.INNER); // 회원과 조인
List<Predicate> criteria = new ArrayList<>(); // Predicate 가 조건
// 주문 상태 검색
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();
}
}
Criteria API 를 사용하면 문자열 기반이 아닌, Java 코드를 사용하여 JPQL 쿼리를 작성할 수 있으며 동적쿼리를 조금 더 쉽게 작성할 수 있습니다.
하지만 JPA Criteria 는 JPA 표준 스펙이지만 실무에서 사용하기에 너무 복잡합니다. 그래서 보통 QueryDSL 을 사용하여 동적 쿼리를 작성하는 방법을 사용합니다.
페이징을 구현하면서 createQuery()
를 사용할 때 setFirstResult()
로 가져올 데이터의 시작 위치를 지정할 수 있습니다. 또 setMaxResults()
로 한 페이지 당 가져올 최대 데이터 수를 지정할 수 있습니다.