1. 주문 엔티티 개발
// 생성 메서드
// 주문 생성과 관련된 모든 로직을 응집해놓은 메서드
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() { // 전체 주문 가격 반환
return orderItems.stream().mapToInt(OrderItem::getTotalPrice).sum();
}
- Order.class의 필드 아래에 작성했다.
1) 생성 메서드
- Order 인스턴스를 생성하는 정적 메서드 createOrder()이다.
- Order와 연관되어 있는 객체까지 한꺼번에 담기 위한 메서드다.
- 생성자와 유사해보이지만 메서드이기 때문에 좀 더 유연하게 사용하고 수정할 수 있을 것이다.
2) 비즈니스 로직
- 주문을 취소하는 cancel()이다.
- 만약 이미 배송이 완료된 상태라면 주문을 취소할 수 없으므로 예외를 발생시킨다.
- 주문 상태를 변경한다.
- 각 주문 상품에 대해서도 cancel()을 호출해야 한다.
- OrderItem에 상품의 가격뿐만 아니라 주문 수량에 대한 정보가 있기 때문에 거기서 재고를 원상 복구 해줘야 한다.
3) 조회 로직
- 하나의 주문에 엮인 모든 상품의 가격을 반환하는 getTotalPrice()이다.
- orderItems에 들어 있는 각각의 orderItem 인스턴스의 상품 가격과 주문 수량을 곱한 것을 모두 합쳐 반환한다.
- 자바의 stream을 활용해서 나타냈다.
- 직접 한 것은 아니고, 인텔리제이의 힘을 빌렸다.
2. 주문상품 엔티티 개발
// 생성 메서드
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
// 재고 변화
item.removeStockQuantity(count);
return orderItem;
}
// 비즈니스 로직
public void cancel() {
getItem().addStockQuantity(count);
}
// 조회 로직
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
1) 생성 메서드
- 주문상품을 생성하고 연관된 상품, 가격, 수량 등을 모두 set해서 돌려주는 createOrderItem()이다.
2) 비즈니스 로직
- Order에서 취소할 때 수량을 수정해주는 cancel()이다.
3) 조회 로직
- Order에서 각 주문 상품의 가격을 반환받을 때 사용하는 getTotalPrice()이다.
3. 주문 리포지토리 개발
@Repository
@RequiredArgsConstructor
public class OrderRepository {
private final EntityManager em;
public void save(Order order) {
em.persist(order);
}
public Order findOne(Long orderId) {
return em.find(Order.class, orderId);
}
// 회원명으로 관련된 주문 검색
// public List<Order> findAll(OrderSearch orderSearch) { }
}
- MemberRepository와 별반 차이가 없다.
- 다만 주석 처리한 부분을 보면 알 수 있듯 회원명을 가지고 주문을 검색하는 기능은 동적 쿼리를 사용해야 하므로 이후 강의로 밀린 상태다.
4. 주문 서비스 개발
@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); // 원래는 Delivery, OrderItem도 각 리포지토리의 save()를 호출해줘야
// 그러나 Order.class를 보면 두 객체에 cascade가 걸려 있어서 자동으로 같이 save()됨
return order.getId(); // cascade 범위의 기준은 한쪽이 다른쪽을 exclusively 참조할 때
}
- 클래스에 달리는 어노테이션은 Member에서와 동일하다.
- 그런데 스프링에서 주입 받는 Repository 객체가 Order뿐만 아니라, Member와 Item의 리포지토리까지 포함한다.
- order()를 보면 알겠지만, 이는 DB에서 id를 가지고 Member와 Item 객체를 조회하기 위해서 필요하다.
- order()에서 Delivery와 OrderItem 객체를 직접 생성하고 있다.
- 다른 객체가 이 둘을 참조하지 않기 때문에 Order가 이 둘의 라이프 사이클을 관리하는 것이 꽤나 합리적이다.
- 그런데 이 두 객체를 직접 persist()하는 로직은 찾을 수 없다.
@OneToMany( mappedBy = "order", cascade = CascadeType.ALL )
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne( fetch = FetchType.LAZY, cascade = CascadeType.ALL) // 일대일은 둘 다 연관관계의 주인이 될 수 있다
@JoinColumn( name = "delivery_id" ) // order에서 delivery를 찾을 경우가 많으므로 연관관계의 주인으로
private Delivery delivery;
- 이는 Order에서 이 둘에 대한 cascade 옵션을 ALL로 걸었기 때문에 가능한 것이었다.
- 즉, Order가 persist()됨과 동시에 Order가 의존하는 orderItems와 delivery가 함께 persist()된다.
- 참고로, cascade를 거는 기준은 명시되어 있는 것은 아니다.
- 다만, 아까 언급했던 것처럼 Delivery와 OrderItems는 Order가 배제적으로 참조한다는 점이 곧 기준이다.
참고!
@NoArgsConstructor( access = AccessLevel.PROTECTED )
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의 기본 생성자의 접근 제한자를 protected로 둠으로써 다른 클래스에서 접근하지 못하도록 막아야 한다.
- 이 때 Lombok의 @NoArgsConstructor에서 access 옵션을 PROTECTED로 두면, 생성자를 작성하지 않아도 된다.
( OrderService 계속 )
// 취소
@Transactional
public void cancelOrder(Long orderId) {
// 주문 엔티티 조회
Order order = orderRepository.findOne(orderId);
// 주문 취소
order.cancel();
}
- 취소 서비스는 매우 간단하다.
- orderId로 DB에서 조회해 받아온 Order 객체에 cancel()을 호출하면 된다.
- 그런데 이러한 간단한 로직 뒤에 숨은 JPA 기능이 있다.
- Order의 cancel()은 OrderItem의 cancel()을 호출하는데, 여기서 Item의 stockQuantity를 건드린다.
public void cancel() {
getItem().addStockQuantity(count);
}
- 그런데 이렇게 자바상에서 바뀐 재고 수량에 대해, OrderService 계층에서 수정 쿼리를 날려줄 필요가 없는 것이다.
- 즉 cancelOrder()에서 Item 꼬랑지도 보지 못했는데 알아서 쿼리가 날라간다는 뜻.
도메인 모델 패턴
@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); // 원래는 Delivery, OrderItem도 각 리포지토리의 save()를 호출해줘야
// 그러나 Order.class를 보면 두 객체에 cascade가 걸려 있어서 자동으로 같이 save()됨
return order.getId(); // cascade 범위의 기준은 한쪽이 다른쪽을 exclusively 참조할 때
}
- 다시 OrderService의 order()를 살펴 보자.
- 코드가 꽤 길어 보이지만 이는 Order에 엮여 있는 객체가 많아서 그렇지, 막상 Order와 관련한 연산은 전부 메서드 호출로 끝낸다.
- createOrder나 createOrderItem()이 그 예시다.
- 이렇듯 서비스 계층은 엔티티에서 이미 구현해놓은 기능들을 호출해서 연산을 그쪽에 위임하는 방식을 "도메인 모델 패턴"이라고 한다.
5. 주문 기능 테스트
@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
private Item createBook(String name, int orderPrice, int stockQuantity) {
Item book = new Book();
book.setName(name);
book.setPrice(orderPrice);
book.setStockQuantity(stockQuantity);
em.persist(book);
return book;
}
private Member createMember(String name, Address address) {
Member member = new Member();
member.setName(name);
member.setAddress(address);
em.persist(member);
return member;
}
@Test
public void 상품주문() throws Exception {
// given
Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
Item book = createBook("안나 카레니나", 10000, 100);
int count = 2;
// when
Long orderId = orderService.order(member.getId(), book.getId(), count);
// then
Order getOrder = orderRepository.findOne(orderId);
assertEquals("상품 주문시 상태는 ORDER", OrderStatus.ORDER, getOrder.getStatus());
assertEquals("주문한 상품 종료 수가 정확해야 한다.", 1, getOrder.getOrderItems().size());
assertEquals("주문 가격은 '가격*수량'이다.", 10000 * count, getOrder.getTotalPrice());
assertEquals("주문 수량만큼 재고가 줄어야 한다.", 98, book.getStockQuantity());
}
@Test( expected = NotEnoughStockException.class )
public void 상품주문_재고수량초과() throws Exception {
// given
Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
Item book = createBook("안나 카레니나", 10000, 100);
int count = 111;
// when
orderService.order(member.getId(), book.getId(), count);
// then
fail("재고 수량 부족 예외가 발생해야 한다.");
}
@Test
public void 주문취소() throws Exception {
// given
Member member = createMember("장군이", new Address("서울", "백범로", "111101"));
Item book = createBook("안나 카레니나", 10000, 100);
int count = 2;
Long orderId = orderService.order(member.getId(), book.getId(), count);
// when
orderService.cancelOrder(orderId);
// then
Order getOrder = orderRepository.findOne(orderId);
assertEquals("주문 취소시 상태는 CANCEL이다.", OrderStatus.CANCEL, getOrder.getStatus());
assertEquals("주문이 취소된 상품은 그만큼 재고가 복구되어야 한다.", 100, book.getStockQuantity());
}
}
- createMember()와 createBook()은 인텔리제이의 "메서드 추출" 기능을 활용했다.
- 단축키는 "command + option + M"이다.
- 여러 테스트에서 공통으로 호출할 수 있다.
- 스프링의 @Before를 써도 되지 않을까 ...
- 상품주문()은 Order 객체를 persist()한다.
- 주문 상태(자동으로 들어감)를 확인한다.
- 상품 종류 수가 1개인지 확인한다.
- 주문 가격이 정확해야 한다.
- 주문 후 재고가 정확하게 변경되어야 한다.
- 상품주문_재고수량초과()는 재고보다 많은 주문 수량을 집어넣어서 예외를 발생시킨다.
- @Test에 expected를 걸어서 예외가 발생할 것을 명시해준다.
- 만약 order()에서 예외가 발생하지 않으면, fail()까지 내려와서 테스트가 실패하게 된다.
- 주문취소()는 cancel()을 호출하여 올바른 결과가 발생하는지 확인한다.
- 주문 상태가 CANCEL로 변경되었는지 확인한다.
- 일치하는 Item의 재고가 수정되었는지 확인한다.
6. 주문 검색 기능 개발
- 회원명과 주문상태 두 조건을 가지고 검색할 수 있어야 한다.
- 그러려면 조회 쿼리에서 회원명과 주문 상태가 있는지 없는지도 따져야 하고, 있다면 값이 어떤지도 따져야 한다.
- 따라서 "동적 쿼리"가 필요하다.
검색 조건 파라미터 OrderSearch
@Getter @Setter
public class OrderSearch {
private String memberName; // 회원명
private OrderStatus orderStatus; // 주문 상태[ORDER, CANCEL]
}
- OrderSearch는 주문 검색에 필요한 회원명과 주문 상태를 공급하는 역할을 한다.
public List<Order> findAll(OrderSearch orderSearch)
public List<Order> findAll(OrderSearch orderSearch) {
orderSearch의 memberName 혹은 orderStatus가 null이라면? -> 동적 쿼리 필용
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) // 최대 1000건 조회
.getResultList();
}
- 직접 EntityManager를 통해서 쿼리를 날리는 쪽은 OrderRepository다.
- 위와 같은 createQuery()는 뭔가 완벽해보이지만 한 가지 부족한 점이 있다.
- 만약 orderSearch의 name 혹은 status가 비어있다면 모든 name 혹은 모든 status에 대해 검색해야 한다.
- 그러면 대충 생각해봐도 쿼리가 2(있거나 없거나) * 2 = 4가지 상황을 대비해야 하는 것이다.
- 따라서 위 쿼리는 정적쿼리라고 할 수 있다.
CASE 1 -> JPQL로 처리
public List<Order> findAllByString(OrderSearch orderSearch) {
//language = JPQL
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 문자열 변수에 기본적인 쿼리 문자열을 먼저 넣는다.
- 1차 if문을 통해 회원명과 주문 상태 각각이 비어 있지 않으면 where 조건절을 넣어준다.
- 엄밀히 말하면 둘 중 먼저 값이 있는 곳에선 where, 나중에 값이 있는 곳은 and로 엮인다.
- 2차 if문을 통해 회원명과 주문 상태 각각이 비어 있지 않으면 파라미터 바인딩을 해준다.
- 매우 복잡하고, 유지보수성이 떨어진다.
- 예를 들어, 주문을 검색하는 조건이 추가되거나 줄어들 경우 이에 맞도록 코드를 전부 바꿔줘야 한다.
CASE 2 -> 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();
}
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를 활용한 방법이다.
- Criteria를 따로 공부한 적이 없어서 문법은 전혀 모르겠고, 강의에서도 실무에서 사용하지 않는 방식이라 하나씩 설명을 듣진 못했다.
- 그래서 코드만 적고, 따로 설명을 넣진 않겠다.
CASE 3 -> QueryDSL
- QueryDSL을 쓰는 것이 솔루션이라고 알려줬으나, 단순히 검색 조건을 위해서 코드만 보여주기보다는 따로 강의를 할 예정이라고 한다.
- 그래서 코드도 제대로 알려주지 않았고, 어차피 Case 1과 Case 2 모두 목적에 맞게 작동하므로 그 둘을 우선 사용하면 될 것 같다.
'스프링 (인프런) > 스프링부트' 카테고리의 다른 글
API 개발 기본 (1) | 2023.07.13 |
---|---|
웹 계층 개발 (0) | 2023.07.08 |
상품 도메인 개발 (0) | 2023.07.06 |
애플리케이션 구현 준비 (0) | 2023.07.05 |
엔티티 클래스 개발 (0) | 2023.07.05 |