-
5장 - 스프링 데이터 JPA를 이용한 조회 기능📕 book/도메인 주도 개발 시작하기 2023. 1. 5. 00:26
❓CQRS
💡 CQRS는 명령(Command) 모델과 조회(Query) 모델을 분리하는 패턴
명령 모델 : 상태를 변경하는 기능 구현할 때 사용
조회 모델 : 데이터를 조회하는 기능을 구현할 때 사용'
엔티티, 애그리거트, 지포지터리 등 모델은 주문 취소, 배송지 변경과 같은 상태를 변경할 때 사용한다.
즉, 도메인 모델은 명령 모델로 주로 사용된다.
반면에 정렬, 페이징, 검색 조건 지정과 같은 기능은 주문 목록, 상품 상세와 같은 조회 기능에 사용된다.
📌 검색을 위한 스펙
검색 조건이 고정되어 있고 단순하다면 특정 조건을 조회하는 기능을 만들면 된다.
public interface OrderDataDao { Optional<OrderData> findById(OrderNo id); List<OrderData> findByOrderer(String ordererId, Data fromDate, Date toDate); ... }
하지만, 목록 조회와 같은 기능은 다양한 검색 조건 을 조합해야 할 경우가 존재한다.
스펙
검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스펙(specification)이다
스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스이다.
public interface Specification<T> { public boolean isSatisfiedBy(T agg); }
- agg 파라미터는 검사 대상이 되는 객체이다.
- 스펙을 리포지터리에 사용할 경우 → agg는 애그리거트 루트가 된다.
- 스펙을 DAO에 사용할 경우 → agg는 검색 결과로 리턴할 데이터 객체가 된다.
- 조건을 충족하면 true, 그렇지 않으면 false를 리턴
ex) Order 애그리거트 객체가 특정 고객의 주문인지 확인하는 스펙
public class OrdererSpec implements Specification<Order> { private String ordererId; public OrdererSpec(String ordererId) { this.ordererId = ordererId; } public boolean isSatisfiedBy(Order agg) { return agg.getOrdererId().getMemberId().getId().equals(ordererId); } }
ex) 리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용
public class MemberOrderRepository 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("beomsic"); // 리포지터리에 전달 List<Order> orders = orderRepository.findAll(ordererSpec);
📌 스프링 데이터 JPA를 이용한 스펙 구현
스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification 을 제공한다❗
스프링 데이터 JPA가 제공하는 Specification 인터페이스
public interface Specification<T> extends Serializable { long serialVersionUID = 1L; ... @Nullable Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder); }
- 제네릭 타입 파라미터 T - JPA 엔티티 타입을 의미한다.
- toPredicate()
- JPA Criteria API에서 조건을 표현하는 Predicate를 생성한다.
스펙 인터페이스를 구현한 클래스 예시
- 엔티티 - OrderSummary
- ordererID 프로터티 값이 지정한 값과 동일하다.
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 criteriaBuilder) { return criteriaBuilder.equal(root.get(OrderSummary_.ordererId), ordererId); } }
- 해당 클래스는 Specification<OrderSummary> 타입을 구현 ⇒ OrderSummary에 대한 검색 조건을 표현
- toPredicate() 메서드를 구현
- ordererId 프로퍼티 값이 생성자로 전달받은 ordererId와 동일한지 비교하는 predicate을 생성
💡 JPA 정적 메타 모델
OrdererIdSpec 클래스의 코드중에 OrderSummary_ 클래스가 있는데
- 이는 JPA 정적 메타 모델을 정의한 코드이다.
- 메타 모델 클래스는 모델 클래스의 이름 뒤에 ‘_’ 을 붙인 이름을 갖는다.
- @StaticMetamodel 애너테이션을 이용해 관련 모델을 지정
→ 엔티티의 속성명까지 코드로 작성할 수 있다.
정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다.
스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아두어도 된다.
스펙 생성 기능을 별도 클래스에 모음
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.orderDateBetween(from, to);
스펙 인터페이스는 함수형 인터페이스이다.
- 따라서 람다식을 이용해 객체를 생성할 수 있다.
📌 리포지터리 / DAO 에서 스펙 사용
스펙을 만족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다.
- findAll 메서드는 스펙 인터페이스를 파라미터로 갖는다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> { List<OrderSummary> findAll(Specification<OrderSummary> spec); } // 사용 // 스펙 객체 생성 Specification<OrderSummary> spec = new OrdererIdSpec("beomsic"); // 검색 List<OrderSummary> results = orderSummaryDao.findAll(spec);
⇒ OrderSummary에 대한 검색조건을 표현하는 스펙 인터페이스를 파라미터로 갖는다.
📌 스펙 조합
스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드를 제공한다.
- and
- or
두 메서드는 기본 구현을 가진 디폴트 메서드이다.
and()
두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성
Specification<OrderSummary> spec = new OrdererIdSpec("beomsic") .and(OrderSummarySpecs.orderDateBetween(from, to));
or()
두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성
not()
- 정적메서드
- 조건을 반대로 적용할 때 사용
Specification<OrderSummary> spec = Specification.not(OrderSummarySpecs.ordererId("beomsic"));
where()
- null 가능성이 있는 스펙을 다른 스펙과 조합할 때 사용하면 좋다.
- null 을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴
- null 이 아니면 인자로 받은 스펙 객체를 그대로 리턴
Specification<OrderSummary> spec = Specification.where(createNullableSpec()).and(createOtherSpec));
📌 정렬 지정하기
스프링 데이터 JPA는 두 가지 방법을 사용해 정렬을 지정할 수 있다.
- 메서드 이름에 OrderBy를 사용해 정렬 기준 지정
- Sort를 인자로 전달
OrderBy
특정 프로피터로 조회하는 find 메서드 → 이름뒤 OrderBy 를 사용해 정렬 순서를 지정할 수 있다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> { List<OrderSummary> findByOrdererIdOrderByNumberDesc(String ordererId); }
장점
- 사용하기 간단
단점
- 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어진다.
- 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수 없다.
Sort 타입
스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 Sort 타입을 제공한다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> { 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("beomsic", sort);
find 메서드를 사용하는 코드는 알맞는 Sort 객체를 생성해 전달하면 된다.
📌 페이징 처리하기
스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 사용한다.
public interface MemberDataDao extends Repository<MemberData, String> { List<MemberData> findByNameLike(String name, Pageable pageable); }
Pageable 타입
Pageable 타입 객체는 PageRequest 클래스를 이용해 생성한다.
PageRequest pageReq = PageRequest.of(1, 10); List<MemberData> user = memberDataDao.findByNameLike("beomsic%", pageReq);
- 한 페이지에 10개씩 표현, 두 번째 페이지 조회
- 페이지 번호는 0번 부터 시작
- 따라서 11 ~ 20 번째 데이터 조회
PageRequest.of()
- 첫 번째 인자 - 페이지 번호
- 두 번째 인자 - 한 페이지의 개수
PageRequest와 Sort를 사용하면 정렬 순서를 지정할 수도 있다.
Sort sort = Sort.by("name").ascending(); PageRequest pageReq = PageRequest.of(1, 10, sort);
조건에 해당하는 전체 개수 구하기
Pageable을 사용하는 메서드의 리턴 타입이 Page일 경우 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행한다.
📌 스펙 조합을 위한 스펙 빌더 클래스
조건에 따른 스펙을 조합해야 하는 경우 if와 각 스펙을 조합하는 코드가 섞여 실수하기도 쉽고 복잡하다
→ 스펙 빌더를 통해 스펙을 조합한다.
Specification<MemberData> spec = SpecBuilder.builder(MemberData.class) .ifTrue(searchRequest.isOnlyNotBlocked(), () -> MemberDataSpecs.nonBlocked()) .ifHasText(searchRequest.getName(), name -> MemberDataSpecs.nameLike(searchReqeuest.getName())) .toSpec();
- 코드 가독성을 높이고 구조가 단순해진다.
📌 동적 인스턴스 생성
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); }
- select 절에 new 키워드가 있다.
- new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름을 지정하고 괄호 안에 생성자에 인자로 전달할 값을 지정한다.
장점
- JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민없이 원하는 모습으로 데이터를 조회할 수 있다.
📌 하이버네이트 @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.product_id""" ) @Synchronize({"purchase_order", "order_line", "product"}) public class OrderSummary { @Id private String number; ... protected OrderSummary() {} ... }
@Immutable, @Subselect, @Synchronize 는 하이버네이트 전용 애너테이션이다.
- 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.
@Subselect
조회 쿼리를 값으로 갖는다.
- 쿼리 실행 결과를 매핑할 테이블 처럼 사용한다.
@Subselect 로 조회한 @Entity는 수정할 수 없다.
- 실수로 수정한다면 하이버네이트는 변경내역을 반영하는 update 쿼리를 실행할 것이다.
- 이때, 매핑한 테이블이 없으므로 에러가 발생한다.
@Immutable
@Subselect 에서 발생하는 에러를 방지하기 위해 사용한다.
이를 사용하면 해당 엔티티의 매핑 필드 / 프로퍼티가 변경되도 DB에 반영하지 않고 무시한다.
@Synchronize
// purchase_order 테이블에서 조회 Order order = orderRepository.findById(orderNumber); order.changeShippingInfo(newInfo); // 상태 변경 // 변경 내역이 아직 DB에 반영되지 않음 // 이때, purchase_order 테이블에서 조회 List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
이 코드는 Order의 상태를 변경하기 전에 OrderSummary를 조회한다.
하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영하므로 Order의 변경 내역을 아직 테이블에 반영하지 않은 상태에서의 테이블에서 OrderSummary를 조회한다.
- 즉, 최신 값이 아니다.
이런 문제를 해결하기 위해 @Synchronize 를 사용한다.
@Synchronize는 해당 엔티티와 관련된 테이블 목록을 명시한다.
하이버네이트는 엔티티를 로딩하기전 지정한 테이블과 관련된 변경이 발생하면 flush를 진행한다.
- 따라서, OrderSummary를 로딩하는 시점에 변경 내역이 반영된다.
참고자료
'📕 book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
7장 - 도메인 서비스 (0) 2023.01.12 6장 - 응용 서비스와 표현 영역 (0) 2023.01.05 4장 - 리포지터리와 모델 구현 (0) 2023.01.02 3장 - 애그리거트 (1) 2022.11.02 2장 - 아키텍처 개요 (0) 2022.10.27