Today
-
Yesterday
-
Total
-
  • 05. 스프링 데이터 JPA를 이용한 조회 기능
    책 공부/DDD 도메인 주도 개발 시작하기 2024. 1. 24. 08:39

    1. CQRS 패턴에 대한 간단한 소개

    CQRS에 대해서는 11장에서 학습

    p. 174


    • 명령(Command) 모델과 조회(Query) 모델을 분리하는 패턴

    • 명령 모델
      • command
      • 상태를 변경하는 기능 구현
      • ex : 회원 가입, 암호 변경, 주문 취소
      • 엔티티, 애그리거트, 리포지터리 등 모델은 명령 모델로 주로 사용된다.

    • 조회 모델
      • query
      • 데이터를 조회하는 기능 구현
      • ex : 주문 목록, 주문 상세
      • 정렬, 페이징, 검색 조건 지정과 같은 기능은 조회 기능에 사용된다.

    이 장에서 사용하는 예제 코드는 리포지터리(도메인 모델에 속한)와 DAO(데이터 접근을 의미)라는 이름을 혼용해서 사용한다.




    2. 검색을 위한 스펙

    p. 175


    검색 조건이 고정되어 있고 단순하면 다음과 같이 특정 조건으로 조회하는 기능을 만들면 된다.

    public interface OrderDataDao {
    	Optional<OrderData> findById(OrderNo id);
    	List<OrderData> findByOrderer(String ordererId, Date fromDate, Date toDate);
    }
    

    목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다.


    필요한 조합마다 find 메서드를 정의할 수도 있지만 이것은 좋은 방법이 아니다.
    조합이 증가할수록 정의해야 할 find 메서드도 함께 증가하기 때문이다.



    이렇게 검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스펙 Specification이다.


    스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다.




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

    p.177


    스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification(이하 ‘스펙 인터페이스’)을 제공하며 다음과 같이 정의되어 있다.


    package org.springframework.data.jpa.domain;
    
    import jakarta.persistence.criteria.CriteriaBuilder;
    import jakarta.persistence.criteria.CriteriaDelete;
    import jakarta.persistence.criteria.CriteriaQuery;
    import jakarta.persistence.criteria.Predicate;
    import jakarta.persistence.criteria.Root;
    
    import java.io.Serializable;
    import java.util.Arrays;
    import java.util.stream.StreamSupport;
    
    import org.springframework.lang.Nullable;
    
    public interface Specification<T> extends Serializable {
    
        long serialVersionUID = 1L;
    
        static <T> Specification<T> not(@Nullable Specification<T> spec) {
    
            return spec == null //
                    ? (root, query, builder) -> null //
                    : (root, query, builder) -> builder.not(spec.toPredicate(root, query, builder));
        }
    
        static <T> Specification<T> where(@Nullable Specification<T> spec) {
            return spec == null ? (root, query, builder) -> null : spec;
        }
    
        default Specification<T> and(@Nullable Specification<T> other) {
            return SpecificationComposition.composed(this, other, CriteriaBuilder::and);
        }
    
        default Specification<T> or(@Nullable Specification<T> other) {
            return SpecificationComposition.composed(this, other, CriteriaBuilder::or);
        }
        
        @Nullable
        Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
    
        static <T> Specification<T> allOf(Iterable<Specification<T>> specifications) {
    
            return StreamSupport.stream(specifications.spliterator(), false) //
                    .reduce(Specification.where(null), Specification::and);
        }
    
        @SafeVarargs
        static <T> Specification<T> allOf(Specification<T>... specifications) {
            return allOf(Arrays.asList(specifications));
        }
    
        static <T> Specification<T> anyOf(Iterable<Specification<T>> specifications) {
    
            return StreamSupport.stream(specifications.spliterator(), false) //
                    .reduce(Specification.where(null), Specification::or);
        }
    
        @SafeVarargs
        static <T> Specification<T> anyOf(Specification<T>... specifications) {
            return anyOf(Arrays.asList(specifications));
        }
    }
    
    

    스펙 인터페이스에서 지네릭 타입 파라미터 TJPA 엔티티 타입을 의미한다.


    toPredicate() 메서드는 JPA 크리테리아(Criteria) API에서 조건을 표현하는 Predicate를 생성한다.


    예를 들어 다음에 해당하는 스펙은 다음과 같이 구현할 수 있다.

    • 엔티티 타입이 OrderSummary다.
    • orderId 프로퍼티 값이 지정한 값과 동일하다.

    public class OrdererIdSpec implements Specification<OrderSummary> {
        
        private String ordererId;
        
        public OrdererIdSpec(String ordererId) {
            this.ordererId = ordererId;
        }
        
        @Override
        public Predicate toPredicate(Root<OrderSummary> root,
                         CriteriaQuery<?> query,
                         CriteriaBuilder cb) {
            return cb.equal(root.get(OrderSummary_.orderId), ordererId);
        }
    }
    

    JPA 정적 메타 모델

    바로 위의 예시에서 OrderSummary_.ordererId 코드를 볼 수 있는데,
    OrderSummary_ 클래스는 JPA 정적 메타 모델을 정의한 코드이다.

    정적 메타 모델 클래스는 다음과 같이 구현한다.

    import javax.persistence.metamodel.SingularAttribute;
    import javax.persistence.metamodel.StaticMetamodel;
    import java.time.LocalDateTime;
    
    @StaticMetamodel(OrderSummary.class)
    public class OrderSummary_ {
        public static volatile SingularAttribute<OrderSummary, String> number;
        public static volatile SingularAttribute<OrderSummary, Long> version;
        public static volatile SingularAttribute<OrderSummary, String> ordererId;
        public static volatile SingularAttribute<OrderSummary, String> ordererName;
        ...
    }
    

    정적 메타 모델은 @StaticMetamodel 애너테이션을 이용해서 관련 모델을 지정한다.


    메타 모델 클래스는 모델 클래스의 이름 뒤에 _를 붙인 이름을 갖는다.


    정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다.


    정적 필드는 프로퍼티에 대한 메타 모델로서 프로퍼티 타입에 따라 SingularAttribute, ListAttribute 등의 타입을 사용해서 메타 모델을 정의한다.


    정적 메타 모델을 구현하는 대신 문자열로 프로퍼티를 지정할 수도 있지만,
    문자열은 오타 가능성이 있고 실행하기 전까지는 오타가 있다는 것을 놓치기 쉽다.

    이런 이유로 Criteria를 사용할 때에는 정적 메타 모델 클래스를 사용하는 것이 코드 안정성이나 생산성 측면에서 유리하다.


    하이버네이트와 같은 JPA 프로바이더는 정적 메타 모델을 생성하는 도구를 제공한다.




    스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.


    public class OrderSummartSpecs {
    
        public static Specification<OrderSummary> 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) -> db.between(root.get(OrderSummary_.orderDate), from, to);
        }
    }
    

    스펙 인터페이스는 함수형 인터페이스이므로 람다식을 이용해서 객체를 생성할 수 있다.




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

    p.182

    findAll()

    스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다.


    public interface OrderSummaryDao extends Repository<OrderSummary, String> {
        List<OrderSummary> findAll(Specification<OrderSummary> spec);
    }
    



    5. 스펙 조합

    p.183

    스프링 데이터 jpa가 제공하는 스펙 인터페이스는
    스펙을 조합할 수 있는 두 메서드를 제공한다.


    and()

    두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성한다.


    or()

    두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성한다.


    and() 예시

    Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1");
    Specification<OrderSummary> spec2 = OrderSummarySpecs.orderDateBetween(
        LocalDateTime.of(2022,1,1,0,0,0),
        LocalDateTime.of(2022,1,1,0,0,0));
    Specification<OrderSummary> spec3 = spec1.and(spec2);
    
    // 아래와 같이 사용할 수도 있다.
    
    OrderSummarySpecs.ordererId("user1")
        .and(OrdererSummarySpecs.orderDateBetween(from, to));
    

    not()

    조건을 반대로 적용할 때 사용한다.

    Specification<OrderSummary> spec = Specification.not(OrderSummarySpecs.ordererId("user1"));
    

    where()

    where()메서드는 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고 null이 아니면 인자로 받은 스펙 객체를 그대로 리턴한다.


    null 가능성이 있는 스펙 객체와 다른 스펙을 조합해야 할 때 where() 메서드를 사용하면 null 검사를 추가 하지 않고도 안정적인 코드를 작성할 수 있다.


    // where() 메서드 사용 x
    Specification<OrderSummary> nullableSpec = createNullableSpec(); // null 가능성 있음
    Specification<OrderSummary> otherSpec = createOtherSpec();
    
    Specification<OrderSummary> spec = nullableSpec == null 
                                        ? otherSpec 
                                        : nullableSpec.and(otherSpec);
    
    // where() 메서드 사용 o
    Specification<OrderSummary> spec = Specification
                                        .where(createNullableSpec())
                                        .and(creaeOtherSpec());
    



    6. 정렬 지정하기

    p. 185

    스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.


    • 메서드 이름에 OrderBy 사용해서 정렬 기준 지정
    • Sort를 인자로 전달

    특정 프로퍼티로 조회하는 find 메서드는 이름 뒤에 OrderBy를 사용해서 정렬 순서를 지정할 수 있다.


    사용 방법은 간단하지만 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다.


    또한 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수도 없다.


    이럴 때는 Sort 타입을 사용하면 된다.


    import org.springframework.data.domain.Sort;
    
    public interface OrderSummaryDao extends Repository<OrderSummary, String> {
        List<OrderSummary> findByOrdererId(String ordererId, Sort sort);
        List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);
    }
    

    스프링 데이터 JPA는 파라미터로 전달 받은 Sort를 사용해서 알맞게 정렬 쿼리를 생성한다.


    사용 예시

    // 1. number 프로퍼티 기준으로 오름차순 정렬을 표현
    Sort sort = Sort.by("number").ascending();
    List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
    
    // 2. 두개 이상의 정렬 순서를 지정하는 방법 1 
    Sort sort1 = Sort.by("number").ascending();
    Sort sort2 = Sort.by("orderDate").descsnding();
    Sort sort = sort1.and(sort2);
    
    // 3. 두개 이상의 정렬 순서를 지정하는 방법 2
    Sort sort = Sort.by("number").ascending().and(Sort.by("orderDate").descending());
    



    7. 페이징 처리하기

    p.187

    목록을 보여줄 때 전체 데이터 중 일부만 보여주는 페이징 처리는 기본이다.


    Pageable, PageRequest

    스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다.

    Sort 타입과 마찬가지로 find 메서드에 Pageable 타입 파라미터를 사용하면 페이징을 자동으로 처리해준다.

    import org.springframework.data.domain.Pageable;
    
    public interface MemberDataDao extends Repository<MemberData, String> {
        List<MemberData> findByNameLike(String name, Pageable pageable);
    }
    

    위 코드에서 findByNameLike() 메서드는 마지막 파라미터로 Pageable 타입을 갖는다.

    Pageable 타입은 인터페이스로, 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다.

    // findByNameLike() 호출 예시
    import org.springframework.data.domain.PageRequest;
    
    PageRequest pageReq = PageRequest.of(1, 10);
    List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
    

    PageRequest.of() 메서드의 첫 번째 인자는 페이지 번호를, 두 번째 인자는 한 페이지의 개수를 의미한다.


    페이지 번호는 0번부터 시작하므로 위 코드는 한 페이지에 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

    Page타입을 사용하면 데이터 목록 뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다.


    Pageable을 사용하는 메서드의 리턴 타입이 Page일 경우 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 개수를 구한다.


    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.getNumber(); // 현재 페이지 번호
    int numberOfElements = page.getNumberOfElements(); // 조회 결과 개수
    int size = page.getSize(); // 페이지 크기
    

    스펙을 사용하는 findAll() 메서드도 Pageable을 사용할 수 있다.


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


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


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

    List<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
    

    위의 메서드가 실행되면 페이지 관련 정보가 필요 없더라도 count 쿼리가 실행된다.




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

    p. 190

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


    Specification<MemberData> spec = Specification.where(null);
    if (searchRequest.isOnlyNotBlocked()) {
        spec = spec.and(MemberDataSpecs.nonBlocked());
    }
    if (StringUtils.hasText(searchRequest.getName())) {
        spec = spec.and(MemberDataSpecs.nameLike(searchRequest.getName()));
    }
    List<MemberData> results = 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));
    



    9. 동적 인스턴스 생성

    p. 193

    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.memerId.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);
    
    }
    

    위 코드에서 select절을 보면 new 키워드가 있다.

    new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름을 지정하고 괄호 안에 생성자에 인자로 전달할 값을 지정한다.


    조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.


    동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다.




    10. 하이버네이트 @Subselect 사용

    p. 195

    하이버네이트는 JPA 확장 기능으로 @Subselect를 제공한다.


    @Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 유용한 기능이다.


    사용 예시

    @Entity
    @Immutable
    @Subselect("""
               select o.order_number as number,
                      o.version, o.orderer_id, o.orderer_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.priduct_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;
        ...생략
    
        protected OrderSUmmary() {}
    }
    

    @Immutable, @Subselect, @Synchronize는 하이버네이트 전용 애너테이션으로
    이 태그들을 사용하여 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.


    @Subselect

    조회 쿼리를 값으로 갖는다.

    하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.


    DBMS가 뷰를 사용하는 것처럼 @Subselect를 사용한다.


    뷰를 수정할 수 없듯 @Subselect로 조회한 @Entity 역시 수정할 수 없다.


    @Subselect를 사용해도 일반 @Entity와 같기 때문에 EntityManager#find(), JPQL, Criteria를 사용해서 조회할 수 있다는 것이 @Subselect의 장점이다.


    이것은 스펙을 사용할 수 있다는 것도 포함한다.


    @Subselect는 이름처럼 @Subselect(value = “”)의 값(value)로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다.


    즉, 실행하는 쿼리는 다음과 같은 형식을 갖는다.

    select osm.number as number1_0_, ...생략
    from (
        select o.order_number as number,
               o.version,
               ...생략
               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
    ) osm
    where osm.orderer_id = ? 
    order by osm.number desc
    

    서브 쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리를 사용하거나 마이바티스와 같은 별도 매퍼를 사용해서 조회 기능을 구현해야 한다.


    @Immutable

    실수로 @Subselect를 이용한 @Entity의 매핑 필드를 수정하면
    실제 테이블이 없기 때문에 에러가 발생한다.


    이런 문제를 방지하기 위해 @Immutable을 사용한다.


    @Immutable을 사용하면 하이버네이트는 해당 엔티티의 매핑 필드 프로퍼티가 변경되어도 DB에 반영하지 않고 무시한다.


    @Synchronize

    // purchase_order 테이블에서 조회
    Order order = orderRepository.findById(orderNumber);
    order.changeShippingInfo(newInfo); // 상태 변경
    
    // 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블에서 조회
    List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
    

    위 코드는 Order의 상태를 변경한 뒤에 OrderSummery를 조회하고 있다.


    특별한 이유가 없으면 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영하므로, Order의 변경 내역을 아직 purchase_order 테이블에 반영하지 않은 상태에서 purchase_order 테이블을 사용하는 OrderSummary를 조회하게 되고 OrderSummary에는 최신 값이 아닌 이전 값이 담기게 된다.


    이런 문제를 해소하기 위한 용도로 사용한 것이 @Synchronize이다.


    @Synchronize는 해당 엔티티와 관련된 테이블 목록을 명시한다.

    하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 flush를 먼저 한다.


    OrderSummary의 @Synchronize는 ‘purchase_order’ 테이블을 지정하고 있으므로 OrderSummary를 로딩하기 전에 purchase_order 테이블에 변경이 발생하면 관련 내역을 먼저 플러시 한다.

    따라서 OrderSummary를 로딩하는 시점에는 변경 내역이 반영된다.


    책 정보

    책 클릭하면 구매처 안내 💁

Designed by Tistory / Custom by 얼거스