05.Spring Data JPA를 이용한 조회 기능

도메인 주도 개발 시작하기 5장을 요약한 내용입니다.

사작에 앞서

CQRS(Command and Query Responsibility Segregation)

  • 명령 모델과 조회 모델을 분리하는 패턴

  • 명령 모델은 상태를 변경하는 기능을 구현할 때 사용(도메인 모델)

  • 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용(조회 모델)

조회 모델을 구현할 때 JPA, myBatis, jdbcTemplate 등 다양한 기술이 사용된다.

  • 모든 DB 연동 코드를 JPA만 사용해서 구현해야 한다고 생각하지 말자.

검색을 위한 스펙

스펙(Specification)

  • 검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것

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

스펙 인터페이스 정의

public interface Specification<T> {
    public boolean isSatisfiedBy(T agg);
}

스펙을 리포지터리에 사용하면 agg(검사 대상 객체)는 애그리거트 루트가 되고, 스펙을 DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다.

isSatisfiedBy() 메서드는 검사 대상 객체가 조건을 충족하면 true, 그렇지 않으면 false 리턴

public class OrdererSpec implements Specification<Order> {
	private String ordererId;
    ...

	public boolean isSatisfiedBy(Order agg) {
		return agg.getOrdererId().getMemberId().getId().equals(ordererId);
	}
}

리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용

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();
    }
}

리포지터리가 스펙을 이용해서 검색 대상을 걸러주므로 특정 조건을 충족하는 애그리거트를 찾고 싶으면 원하는 스펙을 생성해서 리포지터리에 전달해 주기만 하면 된다.

  • 실제 스펙은 이렇게 구현하지 않고, Spring Data JPA를 이용

Specification<Order> ordererSpec = new OrdererSpec("madvirus");
List<Order> orders = orderRepository.findAll(ordererSpec);

Spring Data JPA를 이용한 스펙 구현

Spring Data JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification 제공

  • 제네릭 타입 파라미터 TJPA 엔티티 타입

  • toPredicate() 메서드는 JPA Criteria API에서 조건을 표현하는 Predicate 생성

package org.springframework.data.jpa.domain;

public interface Specification<T> extends Serializable {
    ...

    @Nullable
    Predicate toPredicate(Root<T> root, 
                          CriteriaQuery<?> query, 
                          CriteriaBuilder criteriaBuilder);
}

엔티티 타입이 OrderSummary이고, orderId 프로퍼티 값이 지정한 값과 동일한 스펙

  • OrderSummary에 대한 검색 조건 표현

  • toPredicate() 메서드는 ordererId 프로퍼티 값이 생성자로 받은 ordererId와 동일한지 비교하는 Predicate 생성

  • OrderSummary_는 JPA 정적 메타 모델.

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

public class OrdererIdSpec implements Specification<OrderSummary> {
	private String ordererId;
    ...

    @Override
	public Predicate toPredicate(Root<OrderSummary> root, 
                                CriteriaQuery<?> query, 
                                CriteriaBuilder cb) {
		return cb.equal(root.get(OrderSummary_.ordererId), ordererId);
	}
}

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

  • 스펙 인터페이스는 함수형 인터페이스이므로 람다식 이용도 가능

public class OrderSummarySpecs {
    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) ->
                cb.between(root.get(OrderSummary_.orderDate), from, to);
    }
}

...

Specification<OrderSummary> betweenSpec = OrderSummarySpecs.orderDetailBetween(from, to);

Repository/DAO에서 스펙 사용하기

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

  • 스펙 인터페이스를 파라미터로 갖음

public interface OrderSummaryDao extends Repository<OrderSummary, String> {
    List<OrderSummary> findAll(Specification<OrderSummary> spec);
    List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);
    List<OrderSummary> findAll(Specification<OrderSummary> spec, Pageable pageable);
    ...
}

...

Specification<OrderSummary> spec = new OrdererIdSpec("user1"); // 스펙 객체 생성
List<OrderSummaryDao> results = orderSummaryDao.findAll(spec); // findAll() 메서드를 이용한 검색

스펙 조합

Spring Data JPA 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드(and, or)를 제공

  • 기본 구현을 가진 디폴트 메서드

public interface Specification<T> extends Serializable {
    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);
    }
    ...
}

...

Specification<OrderSummary> spec = OrderSummarySpecs.ordererId("user1")
        .and(OrderSummarySpecs.orderDateBetween(from, to));

not() 메서드는 정적 메서드로 조건을 반대로 적용할 때 사용

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

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

Specification<OrderSummary> spec = 
            Specification.where(createNullableSpec()).and(createOtherSpec());

정렬 지정하기

Spring Data JPA는 두 가지 방법을 사용해서 정렬 지정 가능

  • 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정

  • Sort를 인자로 전달

List<OrderSummary> findByOrdererIdOrderByNumberDesc(String ordererId);

두 개 이상의 프로퍼티에 대한 정렬 순서를 지정할 경우

List<OrderSummary> findByOrdererIdOrderByOrderDateDescNumberAsc(String ordererId);

메서드 이름에 OrderBy를 사용하는 방법은 간단하지만 정렬 기준의 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 존재하고, 상황에 따라 정렬 순서를 변경할 수도 없음.

이럴 경우, Sort 타입을 적용할 수 있다.

List<OrderSummary> findByOrdererId(String ordererId, Sort sort);
List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);

...

Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);

Sort sort = Sort.by("number").ascending().and(Sort.by("orderDate").descending());

페이징 처리하기

Spring Data JPA는 페이징 처리를 위해 Pageable 타입을 이용

  • find 메서드에 Pageable 타입 파라미터를 전달하면 페이징을 자동으로 처리

  • Pageable 타입 객체는 PageRequest 클래스를 이용하여 생성

  • 페이지 번호는 0번부터 시작

  • 리턴 타입으로 Page 타입을 사용하면 데이터 목록과 함께 페이징 처리에 필요한 데이터도 함께 제공

List<OrderSummary> findByOrdererId(String ordererId, Pageable pageable);
List<OrderSummary> findAll(Specification<OrderSummary> spec, Pageable pageable);
Page<OrderSummary> findAll(Pageable pageable);

...

PageRequest pageReq = PageRequest.of(1, 10); // 페이지 번호, 한 페이지의 개수 (11번째~20번째 데이터)
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);

PageRequest, Sort 조합도 가능

Sort sort = Sort.by("name").descending();
PageRequest pageReq = PageRequest.of(1, 2, sort);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);

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

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

반면, 스펙을 사용하는 findAll 메서드에 Pageable 타입을 사용하면 리턴 타입이 Page가 아니더라도 COUNT 쿼리를 실행한다. 이 경우 COUNT 쿼리를 실행하고 싶지 않다면 커스텀 리포지터리 기능을 이용해 보자.

참고. https://javacan.tistory.com/entry/spring-data-jpa-range-query

N개의 데이터만 조회할 경우

  • First, Top 뒤에 숫자가 없을 경우 한 개의 결과만 리턴

findFirst3ByNameLikeOrderByName(String name);
findFirstByBlockedOrderById(boolean blocked);

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

스펙 빌더를 사용하면 조건에 따라 스펙을 조합할 때 편리하다.

  • 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));

스펙 빌더 클래스

동적 인스턴스 생성

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

...

public class OrderView {

    private final String number;
    private final OrderState state;
    private final String memberName;
    private final String memberId;
    private final String productName;

    public OrderView(OrderNo number, OrderState state, String memberName, MemberId memberId, String productName) {
        this.number = number.getNumber();
        this.state = state;
        this.memberName = memberName;
        this.memberId = memberId.getId();
        this.productName = productName;
    }
    ...
}

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

하이버네이트 @Subselect 사용

하이버네이트는 JPA 확장 기능으로 @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.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 쿼리 결과를 매핑할 테이블처럼 사용(DBMS의 뷰와 유사)

  • 뷰와 동일하게 @Subselect로 조회한 @Entity는 수정 불가

@Immutable

  • @Subselect를 이용한 @Entity의 매핑 필드 수정 시 하이버네이트가 변경 내역을 반영하여 update 쿼리를 실행하는 문제를 방지하기 위해 사용

  • 매핑 테이블이 없으므로 에러가 발생하는데 이 문제를 방지

  • @Immutable를 사용하면 해당 엔티티의 매핑 필드/프로퍼티가 변경되어도 DB에 반영하지 않고 무시

@Synchronize

  • 엔티티 상태 변경 내역이 아직 테이블이 반영되지 않은 상태(커밋 전 시점)에서 조회 시 최신 값이 아닌 이전 값이 담기는 문제를 해소하기 위해 사용

  • 해당 엔티티와 관련된 테이블 목록 명시

  • 하이버네이트는 엔티티 로딩 전에 지정한 테이블과 관련된 변경이 발생하면 flush 수행

.

@Subselect를 사용해도 일반 @Entity와 같으므로 EntityManager#find(), JPQL, Criteria, Spec을 사용한 조회가 가능

Specification<OrderSummary> spec = OrderSummarySpecs.orderDateBetween(from, to);
Pageable pageable = PageRequest.of(1, 1);
List<OrderSummary> results = orderSummaryDao.findAll(spec, pageable);

단, @Subselect는 지정 쿼리를 from 절의 서브 쿼리로 사용하는데, 서브 쿼리를 사용하고 싶지 않을 경우 Natice SQL 쿼리를 사용하거나 myBatis 같은 별도 Mapper를 사용해서 조회 기능을 구현해야 한다.

Last updated