//
Search
Duplicate
📖

[도메인 주도 개발 시작하기] - 스프링 데이터 JPA를 이용한 조회 기능

상태
완료
담당자
시작일
2023/09/04 10:43
최종편집일
2023/12/10 11:50
CQRS 명령 Command 모델과 조회 모델을 분리하는 패턴. 명령 모델은 상태를 변경하는 기능을 구현할 때 사용하고 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다.

목차

검색을 위한 스팩

검색 조건이 고정되어 있고 단순하면 특정 조건으로 조회하는 기능으로 만든다.
public interface OrderDateDao { Optional<OrderDate> findById(OrderNo id); List<OrderDate> findByOrderer(String ordererId, Date fromDate, Date toDate); ... }
Java
복사
검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스팩이다. 스팩은 애거리거트가 특정 조건을 충족하는지를 검색할 때 사용하는 인터페이스이다.
public interface Speficication<T> { public boolean isSatisfiedBy(T agg); }
Java
복사
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); } }
Java
복사
리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다. 메모리에 모든 애그리거트를 보관하고 있다면 다음과 같이 사용할 수있다.
public class MemoryOrderRepository implements OrdererRepository { public List<Order> findAll(Specification<Order> spec) { List<Order> allOrders = findAll(); return allOrders.stream() .filter(order -> spec.isSatisfiedBy(order)) .toList(); } ... }
Java
복사
리포지터리가 스팩을 이용해서 검색 대상을 걸러주므로 특정 조건을 충족하는 애그리거트를 찾고 싶으면 원하는 스팩을 생성해서 리포지터리에 전달하면 된다.
Speficication<Order> ordererSpec = new OrdererSpe("madvirus"); List<Order> orders = orderRepository.findAll(ordererSpec);
Java
복사

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

스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스 Specification을 제공
package org.springframework.data.jpa.domain; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; /** * Specification in the sense of Domain Driven Design. * * @author Oliver Gierke * @author Thomas Darimont */ public interface Specification<T> { /** * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given * {@link Root} and {@link CriteriaQuery}. * * @param root * @param query * @return a {@link Predicate}, must not be {@literal null}. */ Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb); }
Java
복사
해당 스팩은 아래와 같이 작성할 수 있다.
public class OrdererSpec Implements Sepcification<OrderSumary> { private String ordererId; public OrdererIdSpec(String ordererId) { this.ordererId = ordererId; } @Override Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb){ return cb.equal(root.get(OrderSuary_.orderId), ordererId); } }
Java
복사
스팩 생성 기능을 별도 클래스에 모은 예
public class OrderSumarySpecs { public static Sepcification<OrderSumary> orderId(String ordererId) { return (Root<OrderSumary> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> cb.equal(root.<String>get("orderId"), ordererId); } public static Sepcification<OrderSumary> orderDateBetween(LocalDateTime from LocalDateTime to) { return (Root<OrderSumary> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> cb.between(root.get(OrderSumary_.orderDAte), from, to); } }
JavaScript
복사
스팩 인터페이스는 함수명 인터페이스이므로 위와 같이 람다식을 이용해 객체를 생성할 수 있다.
리포지터리/DAO에서 스펙 사용하기
스펙 인터페이스를 파라미터로 갖는 findAll() 메서드
public interface OrderSumaryDao extends Repository<OrderSumary, String> { List<OrderSumary> findAll(Sepcification<OrderSummary> sepc); }
JavaScript
복사
구현클래스
// 스팩 객체를 생성 Sepcification<OrderSumary> spec = new OrderIdSpec("user1"); // findAll() 메서드를 이용해서 검색 List<OrderSumary> results = orderSummaryDao.findAll(spec);
Java
복사

스펙 조합

스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드를 제공하고 있다.
public interface Sepcification<T> extends Serializable { ... 생략 default Sepcification<T> and (@Nullable Sepcification<T> other) {...} default Sepcification<T> or (@Nullable Sepcification<T> other) {...} @Nullable Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder); }
Java
복사
구현클래스
Sepcification<OrderSumary> sepc1 = OrderSumarySpecs.orderId("user1"); Sepcification<OrderSumary> sepc2 = OrderSumarySpecs.orderDateBetween( LocalDateTime.of(2022.1,1,0,0,0) LocalDateTime.of(2022.1,2,0,0,0)); Sepcification<OrderSumary> spec3 = spec1.and(sepc2);
Java
복사
구현클래스 2
Sepcification<OrderSumary> sepc1 = OrderSumarySpecs.orderId("user1") .and(OrderSumarySpecs.orderDateBetween(from, to)); )
Java
복사
구현클래스 3
not() 메서드 제공
Sepcification<OrderSumary> sepc = Sepcification.not(OrderSumarySpecs.orderId("user1"));
Java
복사
구현클래스 4
null 가능성이 있는 스펙 객체와 다른 스펙을 조햡해야 할 때가 있다. 이 경우 다음 코드처럼 Null 여부를 판단해 NullPointException이 발생하는 것을 방지해야 하는데 null 여부를 매번 검사하려면 다소 귀찮다.
Sepcification<OrderSumary> nullableSpec = createNullableSpec(); // null 일 수 있음 Specification<OrderSumary> otherSpec = createOtherSpec(); Specification<OrderSumary> spec = nullableSpec = null ? otherSpec : nullableSpec.and(otherSpec);
Java
복사
where() 메서드를 사용하면 이런 귀찮음을 줄일 수 있다.
Specification<OrderSumary> spec = Specification.where(createNullableSpec()).and(createOtherSpec());
Java
복사

정렬 지정하기

JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.
메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
Sort를 인자로 전달
조회하는 find 메서드는 이름 뒤에 OrderBy를 사용해서 정렬 순서를 지정 할 수 있다.
public interface ORderSummaryDao extends Repository<OrderSumary, String> { List<OrderSummary> findByOrderByNumberDesc(String ordererId); }
Java
복사
두 개 이상의 프로퍼티에 대한 정렬 순서를 지정
findByOrdererIdOrderByOrderDateDescNumberAsc
Java
복사
장점
사용하는 방법이 간단하다.
단점
정렬 프로퍼티가 2개 이상이면 메소드 이름이 길어진다
정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서 변경할 수도 없다.
스프링 데이터 JPA 정렬 순서를 지정
public interface OrderSummaryDao extends Repository<OrderSummary, String> { List<OrderSummary> findByOrderId(String orderId, Sort sort); List<OrderSummary> findByAll(Specification<OrderSummary> spec, Sort sort); }
Java
복사
find 메서드를 사용하는 코드는 알맞은 sort 객체를 생성해서 전달하면 된다.
Sort sort = Sort.by("number").ascending(); List<OrderSummary> results = orderSummaryDao.findByOrderId("user1", sort);
Java
복사

페이징 처리하기

스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다. Sort 타입과 마찬가지로 find 메서드에 Pageable 타입 파라미터를 사용하면 페이징을 자동으로 처리해 준다.
public interface MemberDataDao extends Reposiotry<MemberData, String> { List<MemberData> findByNameLike(String name, Pageable pageable); }
Java
복사
PageRequest pageReq = PageRequest.of(1, 10); List<MemberData> user = memeberDataDao.findByNameLike("사용자", pageReq);
Java
복사
Page 타입을 사용하면 데이터 목록뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다.
public interface MemberDataDao extends Reposiotry<MemberData, String> { Page<MemberData> findByBlocked(boolean blocked, Pageable pageable); }
Java
복사
스펙을 사용하는 findAll() 메서드도 Pageable을 사용할 수 있다.
public interface MemberDataDao extends Reposiotry<MemberData, String> { Page<MemberData> findAll(Specification<MemberData> spec, Pageable pageable); }
Java
복사

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

스팩조합 방법
Specification<MemberData> spec = Specification.where(null); if (searchRequest.isOnlyNotBlocked()) { spec = spec.and(MemberDataSpecs.nonBlocked()); } if (StringUtils.hasText(serachReqeust.getName())) { spec = spec.and(MemberDataSpecs.serarchRequest.getName()) } List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0.5));
Java
복사
이 코드는 if와 각 스펙을 조합하는 코드가 섞여 있어 실수하기 좋고 복잡한 구조를 갖는다.
Specification<MemberData> spec = SpecBuilder.Builder(MemberData.class) .ifTrue(searchRequest.isOnlyNotBlocked(), () -> MemberDataSpecs.nonBlocked()) .ifHasText(searchRequest.getName(), name -> MemberDataSpecs.nameLike(serachRequest.getNmae())) .toSpec(); List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0.5));
Java
복사
스펙 빌더 코드는
public class SpecBuilder { public static <T> Builder<T> builder(Class<T> type) { return new Builder<T>(); } public static class Builder<T> { private List<Specification<T>> specs = new ArrayList<>(); private void addSpec(Specification<T> spec) { if (spec != null) { specs.add(spec); } } public Builder<T> and(Specification<T> spec) { addSpec(spec); return this; } public Builder<T> ifHasText(String str, Function<String, Specification<T>> specSupplier) { if (StringUtils.hasText(str)) { addSpec(specSupplier.apply(str)); } return this; } public Builder<T> ifTrue(Boolean cond, Supplier<Specification<T>> specSupplier) { if (cond != null && cond.booleanValue()) { addSpec(specSupplier.get()); } return this; } public <V> Builder<T> ifNotNull(V value, Function<V, Specification<T>> specSupplier) { if (value != null) { addSpec(specSupplier.apply(value)); } return this; } public Specification<T> toSpec() { Specification<T> spec = Specification.where(null); for (Specification<T> s : specs) { spec = spec.and(s); } return spec; } } }
Java
복사

동적 인스턴스 생성

public interface OrderSumaryDao extends Repository<OrderSumary, 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); }
Java
복사
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; } public String getNumber() { return number; } public OrderState getState() { return state; } public String getMemberName() { return memberName; } public String getMemberId() { return memberId; } public String getProductName() { return productName; } }
Java
복사
조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다. 많은 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로 위 코드처럼 값을 기본 타입으로 변환하면 편리하다.

하이버네이트 @Subselect 사용

@Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 기능이다.
import org.hibernate.annotations.Immutable; import org.hibernate.annotations.Subselect; import org.hibernate.annotations.Synchronize; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.Id; import java.time.LocalDateTime; @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; @Column(name = "total_amounts") private int totalAmounts; @Column(name = "receiver_name") private String receiverName; private String state; @Column(name = "order_date") private LocalDateTime orderDate; @Column(name = "product_id") private String productId; @Column(name = "product_name") private String productName; protected OrderSummary() { } ... }
Java
복사
@Immutable, @Subselect, @Synchronize는 하이버네이트 전용 에너테이션인데 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entitiy로 매핑할 수 있다.
DMBS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼 @Subselect를 사용하면 쿼리 실행 결과를 매핑할 테이블 처럼 사용한다.
@Subselect로 조회한 @Entity역시 수정할 수 없다. → 수정을 하게되면 매핑할 테이블이 없기 때문에 error를 도출한다. 이러한 문제를 방지하기 위해 @Immutable을 사용한다.
@Immutable을 사용하면 하이버네이트는 해당 엔티티의 매핑 필드/프로퍼티가 변경되도 DB에 반영하지 않고 무시한다.
//purchase_order 테이블에서 조회 Order order = orderRepostiory.findById(orderNumber); order.changeShipping(newInfo);//상태변경 // 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블에서 조회 List<OrderSummary> summarises = orderSummaryRepository.findByOrdererId(userId);
Java
복사
Order의 상태를 변경 → orderSummary를 조회, 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 저장히기 때문에 Order의 변경 내역을 아직 purchase_order 테이블에 반영하지 않는 상태에서 purchase_order 테이블을 사용하는 orderSummary를 조회, 즉 orderSummary는 최신 값이 아닌 이전 값이 담기게 된다.
이런 문제를 해결 하기위해 @Synchronize를 사용한다. @Synchronize는 해당 엔티티와 관련된 테이블 목록을 명시한다. 하이버네이트는 엔티티를 로딩하기 전에 저장한 테이블과 관련된 변경이 발생한면 플러시를 먼저 한다. 따라서 orderSummary를 로딩하는 시점에서는 변경 내역이 반영된다.
// @Subselect를 적용한 @Entity는 일반 @Entity와 동일한 방법으로 조회할 수 있다. Specification<OrderSummary> spec = orderDateBetween(from, to); Pageable pageable = PageRequests.of(1,10); List<OrderSummary> results = orderSummaryDao.findAll(spec, pageable);
Java
복사
@Subselect는 이름처럼 @Subselect의 값으로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다.
select osm.number as number1_0, ... 새략 from( 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 ) osm where osm.orderer_id = ? order by osm.number desc;
SQL
복사