스프링 부트와 JPA 활용 1 - 웹 애플리케이션 개발 수업을 듣고 정리한 내용입니다.
구현 기능
순서
Order
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
@Table(name="orders")
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(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@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() {
// 전체 주문 가격
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
생성 메서드(createOrder())
주문 취소( cancel()
)
전체 주문 가격 조회
OrderItem
package jpabook.jpashop.domain;
import jpabook.jpashop.domain.Item.Item;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
@Entity
@Table(name = "order_item")
@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()
)
item.removeStock(count)
를 호출해서 주문한 수량만큼 상품의 재고를 줄인다.
주문 취소( cancel()
)
getItem().addStock(count)
를 호출해서 취소한 주문 수량만큼 상품의 재고를 증가시킨다.
주문 가격 조회( getTotalPrice()
)
OrderRepository
package jpabook.jpashop.repository;
import jpabook.jpashop.domain.Order;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
@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) { ... }
}
save()
)하고 검색(findOne(), findAll()
)하는 기능이 있다.
OrderService
package jpabook.jpashop.service;
import jpabook.jpashop.domain.Delivery;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderItem;
import jpabook.jpashop.repository.ItemRepository;
import jpabook.jpashop.repository.MemberRepository;
import jpabook.jpashop.repository.OrderRepository;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@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.findOne(orderSearch);
// }
}
주문( order()
)
주문 취소( cancelOrder()
)
주문 검색( findOrders()
)
OrderSearch
라는 검색 조건을 가진 객체로 주문 엔티티를 검색한다.
✔️ 생성자 소스를 적을 필요가 없을 때
@NoArgsConstructor(access = AccessLevel.PROTECTED)
을 사용할 시, 클래스 내부에 new 생성자를 호출할 시 오류가 발생한다.
내부에 생성자를 호출할 필요 없다. 약간 생성자와는 다르게 생성하는 메서드가 존재할 때 이를 사용하면 좋다.
➡️ 코드를 제약하는 스타일로 만들시, 좋은 개발 유지보수를 끌어 갈 수 있다.
✔️ 비즈니스 로직에서 코드가 변경될 때
비즈니스 로직에서 변경된 사항이 있어 코드가 변경될 때, 연관된 코드를 일일히 다 변경해야 한다. (귀찮다.)
그런데, JPA에서는 엔티티 내부에 있는 데이터만 바꾸면 jpa가 변경 내역을 감지하여 변경된 내용들을 update 쿼리로 실행하여 db에 반영한다.(JPA 장점)
💡 참고
도메인 모델 패턴
- 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것
- 주문 서비스의 주문(
order()
)과 주문 취소(cancelOrder()
) 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다.- 서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할을 한다.
트랜잭션 스크립트 패턴
- 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것
✔️ 테스트 요구사항
✔️ 상품 주문 테스트 코드
OrderServiceTest
package jpabook.jpashop.service;
import jpabook.jpashop.domain.Address;
import jpabook.jpashop.domain.Item.Book;
import jpabook.jpashop.domain.Item.Item;
import jpabook.jpashop.domain.Member;
import jpabook.jpashop.domain.Order;
import jpabook.jpashop.domain.OrderStatus;
import jpabook.jpashop.exception.NotEnoughStockException;
import jpabook.jpashop.repository.OrderRepository;
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 javax.persistence.EntityManager;
import static org.junit.Assert.*;
@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 = 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(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws
// ...
}
@Test
public void 주문취소() throws Exception {
// ...
}
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;
}
}
Given
절에서 테스트를 위한 회원과 상품을 만들고 When
절에서 실제 상품을 주문하고 Then
절에서 주문 가격이 올바른지, 주문 후 재고 수량이 정확히 줄었는지 검증한다.
실행 결과
현재 주문을 2건 했기에 남아있는 재고는 8이다.
✔️ 재고 수량 초과 테스트
- 재고 수량을 초과해서 상품을 주문해보자. 이때는
NotEnoughStockException
예외가 발생해야 한다.
@Test(expected = NotEnoughStockException.class)
public void 상품주문_재고수량초과() throws Exception {
// given
Member member = createMember();
Item item = createBook("시골 JPA", 10000, 10);
int orderCount = 11;
// when
orderService.order(member.getId(), item.getId(), orderCount);
// item 엔티티의 removeStock 메서드에서 수량이 0보다 작을 시 오류를 발생한다.
// then
fail("재고 수량 부족 예외가 발행해야 한다.");
}
orderCount = 11
로 재고보다 1권 더 많은 수량을 주문했다. 주문 초과로 다음 로직에서 예외가 발생한다. public abstract class Item {
//...
/**
* stock 감소
*/
public void removeStock(int quantity) {
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity = restStock;
}
}
✔️ 주문 취소 테스트
주문 취소 테스트 코드를 작성하자! 주문을 취소하면 그만큼 재고가 증가해야 한다.
@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());
}
Given
절에서 주문하고 When
절에서 해당 주문을 취소했다. Then
절에서 주문상태가 주문 취소 상태인지( CANCEL
), 취소한 만큼 재고가 증가했는지 검증한다.
최종 실행 결과
OrderSearch
: 검색 조건 파라미터
package jpabook.jpashop.domain;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class OrderSearch {
private String memberName; // 회원 이름
private OrderStatus orderStatus; // 주문 상태[ORDER, CANCEL]
}
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);
}
public List<Order> findAll(OrderSearch orderSearch){
//... 검색 로직
}
}
findAll(OrderSearch orderSearch)
메서드는 검색 조건에 동적으로 쿼리를 생성해서 주문 엔티티를 조회한다.
밑에 있는 2개의 소스는 거의 사용하지 않음!
✔ JPQL로 처리
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();
}
✔️ 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<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
이 제시했다.
💡 참고
프로젝트할 때 사용해야하는 기술
- springboot
- spring data jpa
- Querydsl
➡️ 이럴 경우 생산성을 극대화할 수 있다.