이제 아이템을 주문할 때 창고에 있는 아이템 개수는 줄어들고, 주문목록엔 생겨나고, 장바구니 역할을 하는 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 추천함.
'CS > 김영한 스프링 강의' 카테고리의 다른 글
자바 ORM 표준 JPA 프로그래밍 - 기본편 - 섹션 2. JPA 시작하기 (0) | 2023.09.14 |
---|---|
실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션7. 웹 계층 개발 (0) | 2023.09.14 |
실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션5. 상품 도메인 개발 (0) | 2023.09.09 |
실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션4. 회원 도메인 개발 (1) | 2023.09.08 |
실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 - 섹션2. 도메인 분석 설계 (0) | 2023.09.07 |