도메인 주도 개발 스터디

Chapter 5 스프링 데이터 JPA를 이용한 조회 기능

막이86 2023. 11. 8. 17:38
728x90

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

5.1 시작에 앞서

  • CQRS는 명령 모델과 조화 모델을 분리하는 패턴
  • 명령 모델은 상태를 변경하는 기능을 구현할 때 사용
    • 회원가입, 암호변경, 주문취소 등
  • 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용
    • 주문목록, 주문 상세 등
  • 엔티티, 애그리거트, 리포지터리 등은 주로 상태를 변경할 때 사용이 된다
    • 도메인 모델은 명령 모델로 주로 사용됨
  • 정렬, 페이징, 검색 조건 같은 기능은 조회 기능에 사용됨

5.2 검색을 위한 스펙

  • 검색 조건이 고정되어 있고 단순하면 특정 조건으로 조회하는 기능을 만들면 된다.
  • public interface OrderDataDao { Optional<OrderData> findById(OrderNo id); List<OrderData> findByOrderer(String ordererId, Date fromDate, Date toData); ... }
  • 목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다.
    • 필요한 조합마다 find 메서드를 정의할 수도 있지만 좋은 방법은 아님
    • 조합이 증가할수록 정의해야할 find 메서드도 함께 증가
  • 검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스펙(Specification)이다.
    • 스펙은 애그리거트가 특정 조건을 충족하는지 검사할 때 사용하는 인터페이스
    public interface Speficiation<T> {
    	public boolean isSatisfiedBy(T agg);
    }
    
  • isSatisfiedBy() 메서드의 agg 파라미터는 검사 대상이 되는 객체
    • 스펙을 리포지터리에 사용하면 agg는 애그리거트 루트가 됨
    • 스펙을 DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 됨
  • isSatisfiedBy() 메서드는 검사 대상 객체가 조건을 충족하면 true를 리턴하고 그렇지 않으면 false를 리턴
  • 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); } }
  • 리포지터리나 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() } }
  • 리포지터리가 스펙을 이용해서 검색 대상을 걸러주므로 특정 조건을 충족하는 애그리거트를 찾고 싶으면 원하는 스펙을 생성해서 리포지터리에 전달해주기만 하면 된다.
  • Specification<Order> ordererSpec = new OrdererSpec("madvirus"); List<Order> orders = orderRepository.findAll(ordererSpec);

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

  • 스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification을 제공
    • T는 JPA 엔티티를 의미
    public interface Specification<T> extends Serializable {
    	@Nullable
    	Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);	
    }
    
  • 스펙 인터페이스를 구현
    • 엔티티 타입이 OrderSummary
    • ordererId 프로퍼티 값이 지정한 값과 동일
    public class ORdererIdSpec implements Specification<OrderSummary> {
    	private String ordererId;
    	
    	public OrdererIdSpect(String ordererId) {
    		this.ordererId = 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); } }

JPA 정적 메타 모델

  • OrderSummary_.ordererId 코드에 OrderSummary_ 클래스는 JPA 정적 메타 모델을 정의한 코드
    • 정적 메타 모델은 @StaticMetamodel 애너테이션을 이용해서 관련 모델을 지정
    • 메타 모델 클래스는 모델 클래스의 이름 뒤에 ‘_’을 붙인 이름을 갖는다.
    • 대상 모델의 각 프로퍼티와 동이랗ㄴ 이름을 갖는 정적 필드를 정의
    • 프로퍼티에 대한 메타 모델로서 프로퍼티 타입에 따라 SingularAttribute, ListAttribute 등의 타입을 사용해서 매타 모델을 정의
    @StaticMetaodel(OrderSummary.class)
    public class OrderSummary_ {
    	public static volatile SinglarAttribute<OrderSummary, String> number;
    	public static volatile SinglarAttribute<OrderSummary, Long> version;
    	public static volatile SinglarAttribute<OrderSummary, String> ordererId;
    	public static volatile SinglarAttribute<OrderSummary, String> ordererName;
    	...
    }
    

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

  • 스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다.
  • public interface OrderSummaryDao extends Repository<OrderSummary, String> { List<OrderSummary> findAll(Specification<OrderSummary> spec); }
  • 스펙 구현체를 사용하면 특정 조건을 충족하는 엔티티를 검색할 수 있다.
  • Specification<OrderSummary> spec = new OrdererIdSpec("user1"); List<OrderSummary> results = orderSummaryDao.findAll(spec);

5.5 스펙 조합

  • 스프링 데이터 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);
    	}
    
    	@Nullable
    	Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
    }
    
  • and 메서드는 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성하고 or 메서드는 두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성
  • Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1"); Specification<OrderSummary> spec2 = OrderSummarySpecs.ordererDateBetween(LocalDateTime.of(2022, 1, 1, 0, 0, 0), LocalDateTime.of(2022, 1, 2, 0, 0, 0)); Specification<OrderSummary> spec3 = spec1.and(spec2); Specification<OrderSummary> spec = OrderSummarySpecs.ordererId("user1").and(OrderSummarySpecs.ordererDateBetween(from, to));
  • not 메서드 제공
  • Specification<OrderSummary> spec = Specification.not(OrderSummarySpecs.ordererId("user1"));
  • null 가능성이 있는 스펙 객체와 다른 스펙을 조합 할 경우
    • NullPointerException 이 발생하는 것을 방지
    Specification<OrderSummary> nullableSpec = createNullableSpec();
    Specification<OrderSummary> otherSpec = createOtherSpec();
    
    Specification<OrderSummary> spec = nullableSpec == null ? otherSpec : nullableSpec.and(otherSpec);
    
  • where 메서드는 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴, null이 아니면 인자로 받는 스펙 객체를 리턴
  • Specification<OrderSummary> spec = Specifiation.where(createNullableSpec()).and(createOtherSpec());

5.6 정렬 지정하기

  • 스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.
    • 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
    • Sort를 인자로 전달
  • 특정 프로퍼티로 조회하는 find 메서드는 이름 뒤에 OrderBy를 사용해서 정렬 순서를 지정할 수 있다.
  • public interface OrderSummaryDao extends Repository<OrderSummary, String> { List<OrderSummary> findByOrdererIdOrderByNumberDesc(String ordererId); }
  • findByOrdererIdOrderByNumberDesc
    • ordererId 프로퍼티 값을 기준으로 검색 조건 지정
    • number 프로퍼티 값 역순으로 정렬
  • 두 개 이상의 프로퍼티에 대한 정렬 순서를 지정할 수도 있다.
  • 메서드 이름에 OrderBy를 사용하는 방법은 간단하지만 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다.
  • 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수 없다.
  • 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 Sort.by("number").ascending(); List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
  • 두개 이상의 정렬 순서를 지정하고 싶다면 Sort#and() 메서드를 사용해서 두 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());

5.7 페이징 처리하기

  • 스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 시용
  • public interface MemberDataDao extends Repository<MemberData, String> { List<MemberData> findByNameLike(String name, Pageable pageable); }
  • findByNameLike 메서드 호출
  • PageRequest pageReq = PageRequest.of(1, 10); 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);
  • Page가 제공하는 메서드의 일부
  • Pageable pageReq = PageRequet.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 numberOfEletents = page.getNumberOfElements(); // 조회 결과 개수 int size = page.getSize(); // 페이지 크기
  • 처음부터 N개의 데이터가 필요하다면 Pageable을 사용하지 않고 findFirstN 형식의 메서드를 사용할 수 있다.
  • List<MemberData> findFirst3ByNameLikeOrderByName(String name);
  • First 대신 Top을 사용해도 된다.
    • First나 Top 뒤에 숫자가 없으면 한 개 결과만 리턴
    MemberData findFirstByBlockedOrderById(boolean blocked)
    

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

  • 스펙을 생성하다 보면 조건에 따라 스펙을 조합해야 할 때가 있다.
  • Specifiation<MemberData> spec Specifiation.where(null); if (serchRequest.isOnlyNotBlocked()) { spec = spec.and(MemberDataSpecs.nonBlocked()); } if (StringUtils.hasText(searchReqeust.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(searchReqeust.getName())
    	.toSpec();
    
    List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));
    
  • 스팩 빌더 코드
  • public class SpecBuilder { public static <T> Builder<T> builder(Class<T> type) { return new Builder<T>(); } public static class Builder<T> { private List<Specifiation<T>> specs = new ArrayList<>(); public Builder<T> and(Specifiation<T> spec) { spec.and(spec); return this; } public Builder<T> ifHasText(String str, Function<String, Specifiation<T>> specSupplier) { if (StringUtils.hasText(str)) { specs.and(specSupplier.apply(str)); } return this; } public Builder<T> ifTrue(Boolean cond, Supplier<Specifiation<T>> specSupplier) { if (cond != null && cond.booleanValue()) { specs.and(specSupplier.get()); } return this; } public Specification<T> toSpec() { Specifiation<T> spec = Specification.where(null); for (Specifiation<T> s: specs) { spec = spec.and(s); } return this; } } }

5.9 동적 인스턴스 생성

  • JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공
    • JPQL의 select 절을 보면 new 키워드가 있다.
    • new 키워드 뒤에 생성할 인스터스의 완전한 클래스 이름을 지정하고 괄호 안에 생성자에 인자로 전달할 값을 지정
    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.member.id = m.id
    				and index(ol) = 0
    				and ol.productId.id = p.id
    				order by o.number.number desc		
    	""")
    	List<OrderView> findOrderView(String ordererId);
    }
    
  • OrderView
  • 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을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다.

5.10 하이버네이트 @Subselect 사용

  • 하이버네이트는 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.link_index = 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; protected ORderSummary() { } }
  • @Subselect는 조회 쿼리를 값으로 갖는다.
    • DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도
    • 쿼리 실행 결과를 매핑할 테이블처럼 사용한다
    • @Subselect로 조회한 @Entity는 수정할 수 없다.
    • 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리 실행 (테이블이 없으므로 에러 발생)
  • @Subselect를 수정하면 오류가 발생함으로 @Immutable을 사용
    • 하이버네이트는 해당 엔티티의 매핑 필드/프로퍼티가 변경되도 DB에 반영하지 않고 무시
  • Order의 상태를 변경한 뒤에 OrderSummary 조회
    • 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영
    • purchase_order 테이블에 반영하지 않은 상태에서 purchase_order 테이블을 조회
    • 최신 값이 아닌 이전 값이 조회
    Order order = orderRepository.findById(orderNumber);
    order.changeShippingInfo(newInfo) // 상태 변경
    
    // 변경 내역 저장 전에 purchase_order 테이블 조회
    List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
    
  • @Synchronize는 엔티티와 관련된 테이블 목록을 명시
    • 하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 플러시를 먼저 실행
    • @Synchronize는 purchase_order 테이블을 지정하고 있으므로 OrderSummary를 로딩하기 전에 purchase_order 테이블에서 변경이 발생하면 관련 내역을 먼저 플러시 한다.
  • @Subselect를 사용해도 일반 @Entity와 같기 때문에 EntityManager#find(), JPQL.Criteria를 사용해서 조회 할수 있다
  • // @Subselect를 적용한 @Entity는 일반 @Entity와 동일한 방법으로 조회할 수 있다. Specification<OrderSummary> spec = orderDateBetween(from, to) Pageable pageable = PageRequest.of(1, 10); List<OrderSummary> results = orderSummaryDao.findAll(spec, pageable);
  • @Subselect는 값으로 저정한 쿼리를 from 절의 서브 쿼리로 사용한다.
  • select osm.number as number1_0_, ... from ( select o.order_number as number, o.version, ... from purchase_order o inner join order_line ol on o.order_number = ol.order_number cross join product p where ol.link_index = 0 and ol.product_id = p.product_id ) osm where osm.orderer_id = ? order by osm.number desc
  • @Subselect를 사용할 때는 쿼리가 아닌 형태를 갖는다는 점을 유의
    • 서브 쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리를 사용하거나 마이바티스와 같은 별도 매퍼를 사용해서 조회기능을 구현
728x90