도메인 주도 개발 시작하기 - 5. 스프링 데이터 JPA를 이용한 조회 기능
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을 생성한다.
- OrdererIdSpec 클래스는
-
스펙 구현 클래스를 개별적으로 만들지 않고, 별도 클래스에 스펙 생성 기능을 모아도 된다.
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 정렬 방법
- 메서드 이름에
OrderBy를 사용해서 정렬 기준 지정findByOrdererId**OrderBy**OrderDate**Desc**Number**Asc**()- 메서드 이름에
OrderBy를 사용하는 방법은 간단하지만, 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다. - 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수도 없다.
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);- 스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는
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번째까지 데이터를 조회한다.
- 페이지 번호는 0번 부터 시작하므로,
-
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는 변경 사항을 반영하기 전에 해당 엔티티를 조회하면 플러시를 먼저 한 뒤에 최신 값을 읽도록 한다.