SpringJPA 도메인

GGOMG·2022년 9월 27일
0

1. 요구사항

회원 기능

  • 회원 등록
  • 회원 조회

상품 기능

  • 상품 등록
  • 상품 수정
  • 상품 조회

주문 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

2. 애플리케이션 아키텍쳐

계층형 구조 사용

  • controller, web : 웹 계층
  • service : 비즈니스 로직, 트랜잭션 처리
  • repository : JPA 사용 계층, 엔티티 매니저 사용
  • domain : 엔티티가 모여있는 계층

패키지 구조

  • jpabook.jpashop
    domain
    exception
    repository
    service
    web

개발 순서

도메인, 서비스, 리포지토리 -> 테스트 케이스 검증 -> 웹


3. 회원 도메인

구현 기능

  • 회원 등록
  • 회원 목록 조회

회원 리포지토리

  • @PersistenceContext
    JPA의 엔티티 매니저를 스프링이 생성한 엔티티 매니저에 주입함
    ->
    스프링 부트의 스프링JPA를 사용하면 @Autowired 로 대체 가능
    ->
    @RequiredArgsConstructor

  • JPQL
    SQL은 테이블을 대상으로, JPQL은 엔티티 객체를 대상으로 쿼리

회원 서비스

  • em.persist(member)
    영속성 컨텍스트에 멤버객체를 올린다
    이 때, 영속성 컨텍스트는 id값을 key로 가져옴
    따라서 persist로 꺼내면 항상 값이 보장됨

  • @Transactional(readOnly = true)
    JPA가 조회 성능을 최적화한다

  • @RequiredArgsConstructor
    롬복 라이브러리
    final 필드의 생성자를 만들어줌

@Repository
public class MemberRepository {

    @PersistenceContext
    private EntityManager em;

    public MemberRepository(EntityManager em) {
        this.em = em;
    }
...

}

=

@Repository
public class MemberRepository {

    @Autowired // 스프링 부트가 @PersistenceContext 대신 @Autowired도 주입을 지원 해줌
    private EntityManager em;

    public MemberRepository(EntityManager em) {
        this.em = em;
    }
...

}

=

@Repository
@RequiredArgsConstructor // 롬복
public class MemberRepository {

    private final EntityManager em;
...

}

회원 기능 테스트

테스트 요구사항

  • 회원 가입 성공
  • 회원 가입 시 같은 이름이 있으면 예외 발생

회원 가입 테스트

  • persist를 해도 기본적으로 insert 쿼리가 나가지 않는다
    왜냐하면 DB트랜잭션이 커밋 될 때 flush 와 함께 insert 쿼리가 나가는 것인데,
    스프링 @Transactional은 기본적으로 commit 이 아닌 Rollback 이다
    Rollback 하지 않고, insert 쿼리를 보고싶으면 @Rollback(false)를 추가해주자
    or
    엔티티 매니저를 등록하고 em.flush(); 해주자

테스트를 완전히 격리된 방법에서 하는 방법

위의 테스트들은 h2 DB를 실행시키지 않으면 정상적으로 실행되지 않는다
springboot에서 격리된 환경에서의 테스트를 지원한다

1. springboot 없이

src/test/resources/application.yml

spring:
  datasource:
    url: jdbc:h2:mem:test
    username: sa
    password:
    driver-class-name: org.h2.Driver

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
#        show_sql: true # sout에 출력

logging.level:
  org.hibernate.SQL: debug # logger에 출력
  org.hibernate.type: trace # 쿼리 파라미터 로그 남기기

main 디렉토리처럼 resources 디렉토리를 만들어 application.yml을 생성한다
  • 테스트 실행 시 main디렉토리의 resources는 무시되고, test디렉토리의 resources가 우선권을 가진다
  • url: jdbc:h2:mem:test
    url을 변경하여 메모리 모드로 실행한다
    https://www.h2database.com/html/cheatSheet.html 참조
  • 테스트 시 h2 DB를 실행하지 않아도 정상적으로 실행된다!

2. 그런데 스프링 부트에서는

src/test/resources/application.yml

spring:
#  datasource:
#    url: jdbc:h2:mem:test
#    username: sa
#    password:
#    driver-class-name: org.h2.Driver
#
#  jpa:
#    hibernate:
#      ddl-auto: create
#    properties:
#      hibernate:
#        format_sql: true
#        show_sql: true # sout에 출력

logging.level:
  org.hibernate.SQL: debug # logger에 출력
  org.hibernate.type: trace # 쿼리 파라미터 로그 남기기

스프링 부트가 yml DB 설정 없이도 동작한다
극단적으로, 전부 주석처리해도 동작한다


4. 상품 도메인

구현 기능

  • 상품 등록
  • 상품 목록 조회
  • 상품 수정

상품 엔티티

객체지향적으로,
데이터를 가지고 있는 쪽에 메서드를 생성하는 것이 좋다
따라서 재고 증가, 감소 로직은 Item 엔티티에 만드는 것이 좋다.

    // 비즈니스 로직
    /**
     * stock 증가
     */
    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }

    /**
     * stock 감소소
    */
    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0){
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;

상품 리포지토리

    public void save(Item item){
        if (item.getId() == null) { // 아이템이 등록되어있지 않으면 새로 등록
            em.persist(item);
        } else{ // 아이템이 있다면 업데이트
            em.merge(item);
        }
    }

5. 주문 도메인

가장 중요한 파트
Transaction 스크립트 패턴 Domain 모델 패턴 중
Domain 모델 패턴에 대해 알아보자

구현 기능

  • 상품 주문
  • 주문 내역 조회
  • 주문 취소

주문 엔티티, 주문상품 엔티티

    // 생성 매서드
    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 myMethod(String... strings){
    // method body
}

varargs, 가변인자
String 객체가 0개부터 n개까지 매개변수로 올수 있다
항상 맨 뒤에 적어야 한다

  • Java8 stream()을 통한 리스트 순회
    public int getTotalPrice() {
        int totalPrice = 0;
        for (OrderItem orderItem : orderItems) {
            totalPrice += orderItem.getTotalPrice();
        }
        return totalPrice;
    }

=

    public int getTotalPrice(){
        return orderItems.stream()
                .mapToInt(OrderItem::getTotalPrice)
                .sum();
    }

주문 서비스

Cascade

@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); // CascadeType.All
        return order.getId();
    }

}

Order 클래스에 CascadeType.ALl을 설정해놓았기 때문에
order 만 persist 해도
orderItem persist
delivery persist 가 작동한다

Cascade의 범위는 어디까지?

Order의 경우 OrderItem과 Delivery를 모두 관리한다
OrderItem과 Delivery는 모두 Order에서만 참조한다
참조의 주인이 명확한 경우에 사용하자

형식을 제어하기 위한 방법 protected 생성자

protected OrderItem(){
}
=
@NoArgsConstructor(access = AccessLevel.PROTECTED) //롬복

외부에서 new OrderItem()을 할 수 없게 한다

// 주문상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);

생성 메서드로 생성하게 강제함

JPA의 강점

주문이 취소되면 상품 재고를 다시 늘려주어야 한다
일반적인 SQL 스타일이라면 상품재고를 조회하고, 변경된 수량만큼 다시 업데이트 하는 쿼리를 작성해야 했겠지만
JPA에서는 dirty check(변경 감지)를 통해 변경점을 찾아서 DB에 업데이트 쿼리를 날려준다

도메인 모델 패턴 / 트랜잭션 스크립트 패턴

도메인 모델 패턴

주문 서비스의 주문과 주문 취소 메서드를 보면 비즈니스 로직 대부분이 엔티티에 있다
서비스 계층은 엔티티에 필요한 요청을 위임하는 역할을 한다.
엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 것을 도메인모델 패턴이라고 한다.

트랜잭션 스크립트 패턴

반대로 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 것을 트랜잭션 스크립트 패턴 이라고 한다.

주문 기능 테스트

테스트 요구사항

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

좋은 테스트는 단위 테스트
도메인 모델 패턴은 단위 테스트에 적합하다
예제의 테스트는 통합 테스트에 가깝다

주문 검색 기능

동적 쿼리

회원명으로 주문 내역을 검색하고 싶다.
이때 사용하는 것이 동적 쿼리이다.
JPA에서 동적 쿼리를 어떻게 해결할까?

public List<Order> findAll(OrderSearch orderSearch) {
    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())
//          .setFirstResult(100)// 페이징 시작포인트
            .setMaxResults(1000)// 최대 1000건
            .getResultList();
    }

만약 status, name 파라미터가 없으면 전부 조회해서 가져오고 싶다면

public List<Order> findAll(OrderSearch orderSearch) {
    return em.createQuery("select o from Order o join o.member m"
            .setMaxResults(1000)// 최대 1000건
            .getResultList();
    }

이런 코드가 나와야 한다
동적 쿼리가 필요한 시점이다.

방법 1. Java String (권장하지 않음)

public List<Order> findAll(OrderSearch orderSearch) {

    //== 동적 쿼리 방법 1 ==//
    
    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();
}

이렇게 복잡한 동적쿼리 생성을 도와주는 도구 : mybatis

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<Object, Object> 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);
    return query.getResultList();
}

장점 : 동적 jqpl을 자바 코드로 작성할 수 있게 함
단점 : 유지보수성이 제로. 실무 사용 힘듬

3. 쿼리 DSL (Querydsl)


출처
김영한 실전! 스프링 부트와 JPA 활용1 (인프런)

0개의 댓글