실전 활용1 JPA _개발(주문)

링딩·2021년 7월 3일
0
post-thumbnail

1. 주문,주문상품 엔티티

*주문 도메인의 구현기능

1) 상품 주문 2) 주문 내역 조회 3) 주문 취소

👀연관관계 편의 메서드

✔ 어디에?
답) 보통 비즈니스 로직에서 중심이 되는 엔티티에서 사용하는게 좋음
⇨ Order엔티티와 Member 엔티티 사이의 관계로 주문이 중심이 되기 때문에 Order엔티티에 써준다.
✔ 왜?
답) 양방향이므로 양쪽 모두에 값을 다 넣어주기 위해서

추가) setXXX()메서드와 addXXX()메서드

서비스 로직을 작성할때 더 이해하기 쉬운 것을 둘 중에서 하나 골라쓴다


ex)

(1)

//Delivery와 Order 관계
//delivery.getOrder()시 ->delivery 엔티티에서  order 객체를 반환함
    public void setDelivery(Delivery delivery) { 
        this.delivery = delivery; //배송지 설정
        //Order 객체를 반환 => add()를 넣지 x
        delivery.setOrder(this);
    }
  • delivery.getOrder(this)을 했을때 반환이 객체가 나오게 된다면 컬렉션의 add()를 쓸 수 없으므로 , delivery.setOrder(this)와 같이 set()으로 써줘야 할 수도 있다.

(2)

  //Member와 Order 엔티티
    public void setMember(Member member) { 
        this.member = member;
        //Member엔티티에선 List<Order> orders 리스트 반환 => add()를 사용o
        member.getOrders().add(this);
    }
  • 반면에 이 setMember() 메서드는 같은 set()메서드지만 member.getORders()반환시 해당 orders 리스트가 반환되므로 -> 컬렉션 add()를 사용할 수 있었다.

👏 이에 관하여 질문을 드리고 답을 받았습니다!


※주문 엔티티

1) 주문 생성 메서드

createOrder()메서드

  • order 객체 생성하여 회원, 배송지, 주문상품목록에 상품을 연관관계로 값을 싹 세팅 해준다.
  • 주문 상태는 '주문'으로 처음 상태로 강제해놓음
  • 현재시간으로 맞춰 놓아줌 (LocalDateTime.now())
    ⇨ 앞으로 생성 메서드를 수정할때, 이 곳에서만 되기 때문에 생성메서드를 만들어 주는게 좋음.

📣OrderItem... orderItems자바 가변인자.

2) 비즈니스 로직

_1. 주문 취소 메서드

-DeliveryStatus클래스가 COMP이면 배송완료이므로 예외처리
-배송완료가 아닌 상태라면, 현재 주문상태를 CANCEL로 바꿔줌
⇨주문상태 취소
-주문자가 시킨 다른 주문상품에도 각각 주문취소를 알림

3) 조회 로직

_1. 전체 주문가격 조회

※주문상품 엔티티

1) 주문 생성 메서드

-생성이 단순하지 않으므로 메서드로 따로 정의해준다.

  • 왜 Item 엔티티에도 price가 있는데 여기서도 매개변수로 OrderPrice를 데려오는가?
    => 할인이나 쿠폰 적용의 경우가 있을 수 있기 때문.

2) 비즈니스 로직

_1) cancel() 메서드

Item 엔티티의 addStock()메서드를 호출해 count 만큼 취소하였으므로 '재고수량'을 증가 시킨다.

3) 조회 로직

_1) getTotalPrice() 금액조회 메서드

orderItem엔티티에서 order엔티티의 호출을 받아 해당 주문상품의 갯수와 가격을 계산하여 값을 반환해준다.



2. 주문 리포지토리 개발

  • 스프링 빈 주입 @Repository
  • @RequireArgsConstruct 'final' 멤버필드가 있는 것만 생성자 생성(생략)
  • 검색기능은 동적쿼리이므로 마지막에



3. 주문 서비스 개발

📣주의! 예제를 단순화 하기 위해서 주문서비스에서 이렇게 하나의 상품만 받도록 설계했습니다^^

*서비스에서 구현할 기능)

  1. 주문 2. 주문취소 3. 주문검색

1) cascade와 save()

👏 cascade가 왜쓰였는가?

1.cascade가 쓰일 수 있는 조건은 1)동일한 라이프 사이클, 2) 참조하는 주인이 private owner 일 때 라고 2가지 조건을 충족할 때 쓴다. 그러나 가장 중요한 점은 지금도, 미래에도 다른 곳에서 참조할 가능성이 없어야 한단 것이다.
.2. 양방형, 연관관계 주인등과 상관이 없다. 3. 변경감지와 cascade의 쓰임의 차이가 존재하며 이 곳에서 참조하였습니다.

👏 cascade가 어디에 있는가?

해당 Order엔티티에 orderItem에 찾아가 보면 cascade = CacadeType.ALL이 있다.
=> 곧 cascade가 있는 곳을 다 persist()를 날려주게 된다. => orderItem, delivery가 persist되었다
*(결론) :
1.cascade는 저장, 삭제의 효과가 다른 엔티티에게 전파되는 것입니다.
2.이해가 안가면 나중에 이런 상황이 보이면 그때 쓰자^^

2) 변경감지

  /** 주문 취소 */
    //주문 아이디만 알면 되네
    @Transactional
    public void cancelOrder(Long orderId) {

        //주문 id로 해당 주문엔티티 조회
        Order order = (Order) orderRepository.findOne(orderId);

        //주문 취소 메서드
        //*변경감지 (jpa가 데이터가 변경된 것을 감지하여 DB에 쿼리를 날려줌)
        //->cancel()메서드 내 orderStatus()변경으로 자동으로 감지, addStock()변경 자동감지
        order.cancel();
    }

cancel()메서드 내 orderStatus()변경으로 자동으로 감지, addStock()변경 자동감지
=> 영속상태에서 데이터가 변경된 것을 감지하여 DB에 쿼리를 날린다.

📣이해가 안가면 한 번 더 읽어보자


3) 추가 @NoArgsConstructor(access = AccessLevel.PROTECTED)

유지보수가 힘들게 객체를 만들고 set으로 하나하나씩 하는 사람이 있다. 이를 보고 복잡하게 만들지 않기 위해 쓰인다.
@NoArgsConstructor(access = AccessLevel.PROTECTED) 를 써주며 해당 메서드를 Service에서 직접 구현할 필요 없이 Entity에서 생성메소드와 같이 가져다 쓰라는 뜻이다.




4. 주문 기능 테스트

👀테스트 요구사항

  • 품 주문이 성공해야 한다.
  • 상품을 주문할 때 재고 수량을 초과하면 안 된다.
  • 주문 취소가 성공해야 한다

👀 OrderServiceTest


@RunWith(SpringRunner.class)
@SpringBootTest
@Transactional
public class OrderServiceTest {

    @PersistenceContext EntityManager em;
    @Autowired OrderService orderService;
    @Autowired OrderRepository orderRepository;

    @Test
    public void 상품주문() throws Exception {
        //Given
        Member member = createMember();
        Item item = createBook("시골 JPA", 10000, 10); //이름, 가격, 재고

        int orderCount = 2;


        //When
        //주문하기
        Long orderId = orderService.order(member.getId(), item.getId(),
                orderCount);


        //Then
        Order getOrder = orderRepository.findOne(orderId);

        //assertEquals("메세지", 기댓값, 실제값)
        assertEquals("상품 주문시 상태는 ORDER",OrderStatus.ORDER,
                getOrder.getStatus());
        assertEquals("주문한 상품 종류 수가 정확해야 한다.",1,
                getOrder.getOrderItems().size());
        assertEquals("주문 가격은 가격 * 수량이다.", 10000 * 2,
                getOrder.getTotalPrice());
        assertEquals("주문 수량만큼 재고가 줄어야 한다.",8, item.getStockQuantity());
    }

    @Test
    public void 주문취소() {

        //Given [~이 주어졌을때]
        Member member = createMember();
        Item 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);//해당 주문id

        assertEquals("주문 취소시 상태는 CANCEL 이다.",OrderStatus.CANCEL,
                getOrder.getStatus());
        assertEquals("주문이 취소된 상품은 그만큼 재고가 증가해야 한다.", 10,
                item.getStockQuantity()); //주문이 취소되었으므로 재고 자체가 10개로 원래대로 복구

    }

    //NotEnoughStock... ->이 예외가 터져야 함
    @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);

        //Then(~결과가 나옴)
        //테스트가 성공한다면 -> 여기까지 오면 안돼
        fail("재고 수량 부족 예외가 발생해야 한다.");
    }


    //==given에 쓰이던 것들을 다른 테스트에서도 쓰여야 하니까 걍 따로 메서드로 생성==//
    
    private Member createMember() {
        Member member = new Member();
        member.setName("회원1");
        member.setAddress(new Address("서울", "강가", "123-123"));
        em.persist(member);
        return member;
    }


    private Book createBook(String name, int price, int stockQuantity) {
        Book book = new Book();
        book.setName(name);
        book.setStockQuantity(stockQuantity);
        book.setPrice(price);
        em.persist(book);
        return book;
    }

📣 알게된 것

  1. DB와 상관없이 단위 테스트를 하는 것이 중요합니다.
  2. 핵심은 스프링 컨테이너나 특정 DB같은 하부 인프라 구조에 의존하지 않고, 핵심 비즈니스 로직(서비스, 엔티티)를 테스트 할 수 있으면 됩니다.
  3. 이때 Repository는 Mock 라이브러리를 사용해서 처리하면 됩니다.
  • 결국 중요한 것은 이러한 핵심 비즈니스 로직을 단위 테스트로 작성하는 것 입니다.

단위 테스트에 관하여 강사님의 생각에 대해 더 자세히 작성되어 있습니다.




5. 주문 검색 기능 개발

📣동적쿼리란?

(1) 실행시에 쿼리문장이 만들어져 실행되는 쿼리문을 말한다. 쿼리문이 변하냐 변하지 않느냐에따라 변하지 않으면 정적쿼리, 변한다면 동적쿼리로 생각하면 된다.

  • 대부분 동적쿼리를 사용할때에는 텍스트문장으로 쿼리문을 가지고 있다가 실행할때마다 텍스트 쿼리문장을 바꿔서 실행하는 방식을 사용


1) 동적쿼리의 두 가지 방법

_1. JPQL로 처리

/**
     * 검색기능 -> 동적쿼리
     *  (1) JPQL 방법
     *JPA Criteria(JpaSpecificationExecutor 포함)을 사용하지 마시고,
     * 단순해도 다른 방법으로 푸시는 것을 권장합니다.
     * !!! 현재 가장 좋은 방법은 Querydsl이라는 기술을 사용하는 것입니다.
     * 자바 코드로 쿼리를 작성해서 컴파일 시점에 오류를 잡아주고,
     * 자바 코드를 활용해서 매우 깔끔하게 동적 쿼리를 작성할 수 있습니다.
     */
    public List<Order> findAllByString(OrderSearch orderSearch) {

        //jpql을 동적으로 만들기 위해
        //(1)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";
        }


        //회원 이름 검색
        //hasText() 값이 잇다면
        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) {//orderStatus가 있다면
            query = query.setParameter("status", orderSearch.getOrderStatus());
        }

        if (StringUtils.hasText(orderSearch.getMemberName())) {//Member가 있다면
            query = query.setParameter("name", orderSearch.getMemberName());
        }


        return query.getResultList();
    }

    

코드만 봐도 길고 지친다... 너무 내용이 복잡하다.


_2. JPA Criteria

/**
     * (2) JPA Criteria -> 이것도 어려움;
     * jpql을 자바코드로 작성하게 해주게 '표준 방법임'
     단점: 유지보수가 안좋음-> 직관적이지 x
     */
    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();


    }

(1)JPA Criteria 또한 너무 길고 가독성이 좋지 않으며, jpa 표준인 (2)JPA Criteria 조차 코드가 너무 복잡하고 직관적이지 않아 이해가 어렵다.

😥 결론!

'동적쿼리'에 대해 많은 개발자 분들이 고민을 하시고 계시고 현재 가장 멋진 해결방법은 Querydsl이 있다고 하셨다

profile
초짜 백엔드 개린이

0개의 댓글