도메인 주도 개발 시작하기 - 5. 스프링 데이터 JPA를 이용한 조회 기능

 Date: 2022-10-10

5.1 시작에 앞서
5.2 검색을 위한 스펙
5.3 스프링 데이터 JPA를 이용한 스펙 구현
5.4 리포지터리/DAO에서 스펙 사용하기
5.5 스펙 조합
5.6 정렬 지정하기
5.7 페이징 처리하기
5.8 스펙 조합을 위한 스펙 빌더 클래스
5.9 동적 인스턴스 생성
5.10 하이버네이트 @Subselect 사용

5.1 시작에 앞서

  • CQRS는 명령 모델과 조회 모델을 분리하는 패턴이다.
    • 명령 모델은 상태를 변경하는 기능을 구현할 때 사용한다.
      • 앞서 살펴봤던 엔티티, 애그리거트, 리포지터리 모델 등에서 주문 취소, 배송지 변경과 같이 상태를 변경할 때 주로 사용된다.
    • 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다.
      • 정렬, 페이징, 검색 조건 지정과 같은 기능은 주문 목록, 상품 상세와 같은 조회 기능에 사용된다.

5.2 검색을 위한 스펙

  • 검색 조건이 고정되어 있고 단순하면 특정 조건으로 조회하는 기능을 만들면 된다.
  • 하지만 다양한 검색 조건을 조합해야 할 때가 있는데, 필요한 조합마다 find 메서드를 정의하는 것은 좋은 방법이아니다.
  • 검색 조건을 다양하게 조합해야 할 때 스펙(Specification)을 사용할 수 있다.

      public interface Specification<T> {
          public boolean isSatisfiedBy(T agg);
      }
    
    • isSatisfiedBy() 메서드의 agg 파라미터는 검사 대상이 되는 객체다.
    • 스펙을 리포지터리에 사용하면 agg는 애그리거트 루트가 되고, 스펙을 DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다.

리포지터리에서 스펙 사용

public class MemoryOrderRepository implements OrderRepository {
    public List<Order> findAll(Specification<Order> spec) {
        List<Order> allOrders = findAll();
        return allOrders.stream()
                        .filter(order -> spec.isSatisfiedBy(order))
                        .toList();
    }
    ...
}
  • 특정 조건을 충족하는 애그리거트를 찾고 싶으면 원하는 스펙을 생성해서 리포지터리에 전달해주기만 하면 된다.

      // 검색 조건을 표현하는 스펙을 생성
      Specification<Order> ordererSpec = new OrdererSpec("madvirus");
      // 리포지터리에 전달
      List<Order> orders = orderRepository.findAll(ordererSpec);
    

    → 하지만 모든 애그리거트 객체를 메모리에 보관할 수 없고, 메모리에 다 보관하더라도 조회 성능에 심각한 문제가 발생하기 때문에 실제 스펙은 이렇게 구현하지 않는다.

5.3 스프링 데이터 JPA를 이용한 스펙 구현

  • 스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification을 제공한다.

      public interface Specification<T> extends Serializable {
          // not, where, and, or 메서드 생략
        
          @Nullable
          Predicate toPredicate(Root<T> root,
                                CriteriaQuery<?> query,
                                CriteriaBuilder cb);
      }
    
    • 타입 파라미터 T는 JPA 엔티티 타입을 의미한다.

JPA 정적 메타 모델

  • 정적 메타 모델은 @StaticMetamodel 애너테이션을 이용해서 관련 모델을 지정한다.
  • 메타 모델 클래스는 모델 클래스의 이름 뒤에 ‘_’을 붙인 이름을 갖는다.
  • 문자열로 프로퍼티를 지정할 수도 있지만, 문자열은 오타 가능성이 있고 실행 전까지는 오타가 있다는 것을 놓치기가 쉽다는 단점이 있다.
  • 정적 메타 모델을 사용한 스펙 인터페이스 구현 예시

      public class OrdererIdSpec implements Sepcification<OrderSummary> {
          private String ordererId;
        
          public Orderer(String ordererId) {
              this.ordererId = ordererId;
          }
        
          @Override
          public Predicate toPredicate(Root<OrderSummary> root, 
                                       CriteriaQuery<?> query,
                                       CriteriaBuilder cb) {
              return cb.equal(root.get(OrderSummary_.orderId), ordererId);
          }
      }
    
    • OrdererIdSpec 클래스는 Sepcificatoin<OrderSummary> 타입을 구현하므로, OrderSummary에 대한 검색 조건이다.
    • ordererId 파라미터 값이 생성자로 전달받은 ordererId와 동일한지 비교하는 Predicate을 생성한다.
  • 스펙 구현 클래스를 개별적으로 만들지 않고, 별도 클래스에 스펙 생성 기능을 모아도 된다.

      public class OrderSummarySpecs {
          public static Specification<OrderSumary> ordererId(String ordererId) {
              return (Root<OrderSummary> root, CriteriaQuery<?> query,
                      CriteriaBuilder cb) -> 
                          cb.equal(root.<String>get("ordererId"), ordererId);
          }
        
          public static Specification<OrderSummary> orderDateBetween(LocalDateTime from, LocalDateTime to) {
              return (Root<OrderSummary> root, CriteriaQuery query,
                      CriteriaBuilder cb) -> 
                          cb.between(root.get(OrderSummary_.orderDate), from, to);
          }
      }
    
    • 스펙 인터페이스는 함수형 인터페이스이므로 람다식을 이용해서 객체를 생성할 수 있다.

5.4 리포지터리/DAO에서 스펙 사용하기

  • 스펙을 충족하는 엔티티를 검색하고 싶다면 스펙 인터페이스를 파라미터로 갖는 findAll() 메서드를 사용하면 된다.

      public interface OrderSummaryDao extends Repository<OrderSummary, String> {
          List<OrderSummary> findAll(Specification<OrderSummary> spec);
      }
    
      // 스펙 객체를 생성
      Specification<OrderSummary> spec = new OrdererIdSpec("user1");
      // findAll() 메서드를 이용해서 검색
      List<OrderSummary> results = orderSummaryDaoo.findAll(spec);
    

5.5 스펙 조합

  • 스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 메서드를 제공한다.
    • and()or() 메서드는 스펙 조건마다 변수를 선언하지 않고 스펙을 조합하여 사용할 수 있게 해주는 default 메서드이다.

        Specification<OrderSummary> spec = OrderSummarys.ordererId("user1")
                        .and(OrderSummarySpecs.orderDateBetween(from, to));
      
    • not()은 조건을 반대로 적용할 때 사용하는 정적 메서드이다.

        Specification<OrderSummary> spec = Specification.not(OrderSummarySpecs.ordererId("user1"));
      
    • where()null을 전달하면 아무 조건도 생성하지 않고, null이 아니면 스펙 객체를 그대로 리턴하는 정적 메서드이다.

        // 기존
        Specification<OrderSummary> nullableSpec = createNullableSpec();  // null일 수 있음
        Specification<OrderSummary> otherSpec = createOtherSpec();
        Specification<OrderSummary> spec = (nullableSpec == null) ? 
                                            otherSpec : nullableSpec.and(otherSpec);
              
        // where 방식
        Specification<OrderSummary> spec = Specification.where(createNullableSpec())
                                                .and(createOtherSpec());
      

5.6 정렬 지정하기

스프링 데이터 JPA 정렬 방법

  1. 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
    • findByOrdererId**OrderBy**OrderDate**Desc**Number**Asc**()
    • 메서드 이름에 OrderBy를 사용하는 방법은 간단하지만, 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다.
    • 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수도 없다.
  2. Sort를 인자로 전달
    • 스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 Sort 타입을 제공한다.
     public interface OrderSummaryDao extends Repository<OrderSummary, String> {
         List<OrderSummary> findByOrdererId(String odererId, Sort sort);
         List<OrderSummary> findByAll(Specification<OrderSummary> spec, Sort sort);
     }
        
     // Sort 사용
     Sort sort1 = Sort.by("number").ascending();
     Sort sort2 = Sort.by("orderDate").descending();
     Sort sort = sort1.and(sort2);
     // Sort sort = Sort.by("number").ascending().and(Sort.by("orderDate".descending()));
        
     List<OrderSummary> results = orderSummaryDao.findAll("user1", sort);
    

5.7 페이징 처리하기

  • 목록을 보여줄 때 전체 데이터 중 일부만 보여주는 페이징 처리는 기본이다.
  • 스프링 데이터 JPA는 페이진 처리를 위해 Pageable 타입을 이용한다.

      public interface MemberDataDao extends Repository<MemberData, String> {
          List<MemberData> findByNameLike(String name, Pageable pageable);
      }
        
      // Pageable 사용
      PageRequest pageReq = PageRequest.of(1, 10);
      List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
    
    • 페이지 번호는 0번 부터 시작하므로, PageRequest.of(1, 10) 는 11번째부터 20번째까지 데이터를 조회한다.
  • PageRequest와 Sort를 사용하면 정렬 순서를 지정할 수 있다.

      Sort sort = Sort.by("name").descending();
      PageRequest pageReq = PageRequest.of(1, 2, sort);
      List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
    
  • Page 타입을 사용하면 데이터 목록뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다.

      public interface MemberDataDao extends Repository<MemberData, String> {
          Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
      }
    
  • Page가 제공하는 메서드 예시

      Pageable pageReq = PageRequest.of(2, 3);
      Page<MemberData> page = memberDataDao.findByBlocked(false, pageReq);
        
      List<MemberData> content = page.**getContent**(); // 조회 결과 목록
      long totalElements = page.**getTotalElements**(); // 조건에 해당하는 전체 개수
      int totalPages = page.**getTotalPages**(); // 전체 페이지 번호
      int number = page.page.**getNumber**(); // 현재 페이지 번호
      int numberOfElements = page.**getNumberOfElements**(); // 조합 결과 개수
      int size = page.**getSize**(); // 페이지 크기
    
  • 스펙을 사용하는 findAll() 메서드도 Pageable을 사용할 수 있다.

      public interface MemberDataDao extends Repository<MemberData, String> {
          Page<MemberData> findAll(Specification<MemberDao> spec, Pageable pageable);
      }
    

Count 쿼리 자동 실행

  • 프로퍼티를 비교하는 findBy 프로퍼티 형식의 메서드는 Pageable 타입을 사용하더라도 리턴 타입이 List이면 COUNT 쿼리를 실행하지 않는다.

      // COUNT 쿼리가 실행되지 않는다
      List<MemberData> findByNameLike(String name, Pageable pageable);
      // COUNT 쿼리가 실행된다
      Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
    

    → 페이징 처리와 관련된 정보가 필요 없다면, Page 리턴 타입이 아닌 List를 사용해서 불필요한 COUNT 쿼리를 실행하지 않도록 한다.

  • 반면, 스펙을 사용하는 findAll 메서드에 Pageable 타입을 사용하면 리턴 타입이 Page가 아니어도 COUNT 쿼리를 실행한다.

      // COUNT 쿼리가 실행된다
      List<MemberData> findAll(Specification<MemberDao> spec, Pageable pageable);
    
    • 스펙을 사용하면서 페이징 처리 시 COUNT 쿼리는 실행하고 싶지 않다면, 커스텀 리포지터리 기능을 이용해서 직접 구현해야 한다. → 참고
  • 처음 N개의 데이터가 필요하다면 Pageable을 사용하지 않고 findFirstN 형식의 메서드를 사용할 수도 있다.

      List<MemberData> findFirst3ByNameLikeOrdererByName(String name);
    
    • like 검색 결과를 name으로 오름차순 정렬해서 처음 3개만 조회한다.
  • First 대신 Top을 사용해도 되며, First나 Top 뒤에 숫자가 없으면 한 개의 결과만 리턴한다.

      MemberData findFirstByBlockedOrderById(boolean blocked);
    

5.8 스펙 조합을 위한 스펙 빌더 클래스

  • 스펙을 생성하다보면 조건에 따라 스펙을 조합해야 할 때가 있다.

      Specification<MemberData> spec = Specification.where(null);
      if (searchRequest.isOnlyNotBlocked()) {
          spec = spec.and(MemberDataSpecs.nonBlocked());
      }
      if (StringUtils.hasText(searchRequest.getName())) {
          spec = spec.and(MemberDataSpecs.nameLike(request.getName()));
      }
      List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));
    
    • 위 코드는 if와 각 스펙을 조회하는 코드가 섞여 있어 실수하기 좋고 복잡한 구조를 갖는다.
  • 스펙 빌더를 사용하면 메서드 호출 체인으로 연속된 변수 할당을 줄여 코드 가독성을 높이고 구조가 단순해진다.

      Specification<MemberData> spec = SpecBuilder.builder(MemberData.class)
          .ifTrue(searchRequest.isOnlyNotBlocked(), 
                 () -> MemberDataSpecs.nonBlocked())
          .ifHasText(searchRequest.getName(), 
                 name -> MemberDataSpecs.nameLike(searchRequest.getName()))
          .toSpec();
      List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));
    

5.9 동적 인스턴스 생성

  • JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.

      public interface OrderSummaryDao extends Repository<OrderSummary, String> {
          @Query("""
              select new com.myshop.order.query.dto.OrderView(
                  o.number, o.state, m.name, m.id, p.name
              )
              from Order o join o.orderLines ol, Member m, Product p
              where o.orderer.memberId.id = :ordererId
              and o.orderer.memberId.id = m.id
              and index(ol) = 0
              and ol.productId.id = p.id
              order by o.number.number desc
          """)
          List<OrderView> findOrderView(String ordererId);
      }
    
    • JPQL의 select 절에 new 키워드로 생생할 인스턴스의 완전할 클래스 이름을 지정하고, 괄호 안에 생성자에 인자로 전달할 값을 지정한다.
  • 조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
  • 동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 데이터를 조회할 수 있다는 점이다.

5.10 하이버네이트 @Subselect 사용

  • 하이버네이트는 JPA 확장 기능으로 @Subselect를 제공한다.
  • @Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 기능이다.

      @Entity
      @Immutable
      @Subselect(
          """
          select o.order_number as number,
          o.version, o.orderer_id, o.order_name,
          o.total_amounts, o.receiver_name, o.state, o.order_date,
          p.product_id, p.name as product_name
          from purchase_order o inner join order_line ol
              on o.order_number = ol.order_number
              cross join product p
          where
          ol.line_idx = 0;
          and ol.product_id = p.product_id    
          """
      )
      @Synchronize({"purchase_order", "order_line", "product"})
      public class OrderSummary {
          @Id
          private String number;
          private long version;
          @Column(name = "orderer_id")
          private String ordererId;
          @Column(name = "orderer_name")
          private String OrdererName;
          ...
      }
    
  • @Immutable, @Subselect, @Synchronize는 하이버네이트 전용 애너테이션인데, 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.
  • @Subselect는 조회 쿼리를 값으로 갖는다. 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.
    • @Subselect로 지정한 쿼리는 from 절의 서브 쿼리로 사용된다.
    • @Subselect를 사용해도 일반 @Entity와 같기 때문에 EntityManager#find(), JPQL, Criteria를 사용해서 조회할 수 있다는 것이 장점이다.
    • @Subselect에도 스펙을 사용할 수 있다.
    • 뷰를 수정할 수 없듯이, @Sebselect로 조회한 @Entity 역시 수정할 수 없다.
    • @Subselect를 이용한 @Entity의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하기 위해 update 쿼리를 실행할 것이다. 그런데 매핑한 테이블이 없으므로 에러가 발생한다.
  • @Immutable을 사용하면 하이버네이트는 엔티티의 매핑 필드/프로퍼티가 변경되더라도 DB에 반영하지 않고 무시한다.
  • 하이버네이트는 특별한 이유가 없으면 트랜잭션을 커밋하는 시점에 변경 사항을 DB에 반영한다.

      // purchase_order 테이블에서 조호
      Order order = orderRepository.findById(orderNumber);
      order.changeShippingInfo(newInfo); // 상태 변경
        
      // 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블 조회
      List<OrderSummary> summarise = orderSummaryRepository.findByOrdererId(userId);
    
  • @Synchorize는 변경 사항을 반영하기 전에 해당 엔티티를 조회하면 플러시를 먼저 한 뒤에 최신 값을 읽도록 한다.

Reference

스프링 데이터 JPA : Pageable 대신 일정 범위 조회 기능 추가하기