IT recording...

[DDD START!] 5장 - 리포지터리의 조회 기능(JPA중심) 본문

DevBookReview

[DDD START!] 5장 - 리포지터리의 조회 기능(JPA중심)

I-one 2022. 1. 7. 17:35

https://adorable-aspen-d23.notion.site/DDD-START-5-JPA-8dba1d6e43d94aa2b9149dea10aa83b7

 

[DDD START!] 5장 - 리포지터리의 조회 기능(JPA중심)

1. 검색을 위한 스펙

adorable-aspen-d23.notion.site

1. 검색을 위한 스펙

식별자 외에 다양한 조건으로 애그리거트를 찾으려면 어떻게 해야 할까?

⇒ specification(스펙) 사용!

  • 스펙은 애그리거트가 특정 조건을 충족하는지 여부를 검사한다.
public interface Specifation<T>{
	public boolean isSatisfiedBy(T agg);
}

→ agg(애그리거트 객체)가 조건을 충족하는지 boolean값을 리턴한다.

public class OrdereSpec implements Specification<Order> {
	private String ordererId;
	public OrdererSpec(String ordererId) {
		this.ordererId = ordererId; //비교 대상
	}
	//Order 애그리거트 객체가 특정 고객의 주문인지 확인하는 스펙 구현
	public boolean isSatisfiedBy(Order agg) {
		return agg.getOrdererId().getMemberId().getId().equals(ordererId);
	}
}
  • 리포지터리는 스펙을 전달받아 애그리거트를 걸러내는 용도로 사용한다.
public class MemoryOrderRepository implements OrderRepository {
	public List<Order> findAll(**Specification spec**) {
		List<Order> allOrders = findAll();
		return allOrders.stream().filter(order -> **spec.isSatisfiedBy(order)**).collect(toList());
	}
}
  • 특정 조건을 충족하는 애그리거트를 찾기 위해서는 스펙을 생성해서 리포지터리에 전달해주면 된다.
Specification<Order> ordererSpec = new OrdererSpec("madvirus"); //특정 스펙 생성
List<Order> orders = orderRepository.findAll(ordererSpec); //리포지터리에 전달

스펙 조합

  • 두 스펙을 AND 혹은 OR 연산자를 이용해서 조합할 수 있다.
public class AndSpec<T> implements Specification<T> {
	private List<Specification<T>> specs;
	
	public AndSpecification(Specification<T> ... specs) {
		this.specs = Arrays.asList(specs);
	}

	public boolean isSatisfiedBy(T agg) {
		for (Specification<T> spec : specs){
			if (!spec.isSatisfiedBy(agg)) return false;
		}
		return true;
	}
}
Specification<Order> ordererSpec = new OrdererSpec("madvirus");
Specification<Order> orderDateSpec = new OrderDataSpec(fromDate,toDate);

AndSpec<T> spec = new AndSpec(ordererSpec,orderDateSpec); //여러 스펙 전달
List<Order> orders = orderRepository.findAll(spec); //and스펙을 리포지토리에 전달
  • But, 앞선 리포지터리 코드는 모든 애그리거트 조회 후 스펙을 이용해서 걸러내는 방식이었다.
    • 애그리거트가 10만 개인 경우 10만개 데이터를 DB에서 메모리로 로딩한 뒤 다시 10만 개 객체를 루프를 돌면서 스펙을 검사하게 되는데, 이는 성능 저하 문제를 일으킨다.
    ⇒ JPA를 위한 스펙 구현

2. JPA를 위한 스펙 구현

  • 실제 구현에서는 스펙을 쿼리의 where절을 사용하는 방식으로 바꿔야 한다.

JPA 스펙 구현

  • CriteriaBuilder, Predicate를 사용한다.
public interface Specification<T> {
	Predicate toPredicate(Root<T> root, CriteriaBuilder cb);
}
public class OrdererSpec implements Specification<Order> {
	private String ordererId;

	public OrdererSpec(String ordererId){
		this.ordererId = ordererId;
	}

	@Override
	public Predicate toPredicate(Root<Order> root, CriteriaBuilder cb){
		return cb.equal(root.get(Order_.orderer).get(Orderer_.memberId).get(MemberId_.id),
					ordererId); //Order 애그리거트의 orderer로 검색
	}
}
Specification<Order> ordererSpec = new OrdererSpec("madvirus"); //스펙 생성
List<Order> orders = orderRepository.findAll(ordererSpec); //리포지터리 적용
  • Specification 구현 클래스를 개별적으로 만들지 않고, 별도 클래스에 스펙 생성 기능을 모아도 된다.
public class OrderSpecs {
	public static Specification<Order> orderer(String ordererId){
		return (root,cb) -> cb.equal(
			root.get(Order_.orderer).get(Orderer_.memberId).get(MemberId_.id),
				ordererId);
	}
	
	public static Specification<Order> between(Date from, Date to) {
		return (root, cb) -> cb.between(root.get(Order_.orderDate),from,to);
	}
}
Specification<Order> betweenSpec = OrderSpecs.between(fromTime,toTime);

AND/OR 스펙 조합을 위한 JPA스펙 구현

  • AND를 위한 JPA스펙
public class AndSpecification<T> implements Specification<T> {
	private List<Specification<T>> specs;

	public AndSpecificatoin(Specification<T> ... specs){
		this.specs = Arrays.asList(specs);
	}

	@Override
	public Predicate toPredicate(Root<T> root, CriteriaBuilder cb){
		Predicate[] predicates = specs.stream()
										.map(spec -> spec.toPredicate(root,cb))
										.toArray(size -> new Predicate[size]); //predicate 배열 생성
		return cb.and(predicates); //CriteriaBuilder를 사용하여 새로운 predicate 생성
	}
}
  • OR를 위한 JPA스펙
public class OrSpecification<T> implements Specification<T> {
	private List<Specification<T>> specs;

	public OrSpecification(Specification<T> ... specs) {
			this.specs = Arrays.asList(specs);
	}

	@Override
	public Predicate toPredicate(Root<T> root, CriteriaBuilder cb) {
		Predicate[] predicates = specs.stream()
										.map(spec -> spec.toPredicate(root,cb))
										.toArray(Predicate[]::new); //predicate 배열 생성
		return cb.or(predicates); //CriteriaBuilder를 사용하여 새로운 predicate 생성
	}
}
  • AND/OR 스펙을 생성해주는 팩토리 클래스
public class Specs{
	public static <T> Specification<T> and(Specification<T> ... specs) {
		return new AndSpecification<>(specs);
	}
	
	public static <T> Specification<T> or(Specification<T> ... specs) {
		return new OrSpecification<>(specs);
	}
}
//팩토리를 이용하여 스펙 조합하기
Specificatoin<Order> specs = Specs.and(
		OrderSpecs.orderer("madvirus"), OrderSpecs.between(fromTime,toTime)
);

스펙을 사용하는 JPA 리포지터리 구현

public interfce OrderRepository{
	public List<Order> findAll(Specification<Order> spec));
	...
}
@Repository
public class JpaOrderRepository implements OrderRepository{
	@PersistenceContext
	private EntityManager entityManager;
	...//생략
	
	@Override
	public List<Order> findAll(Specification<Order> spec){
		**CriteriaBuilder** cb = entityManager.getCriteriaBuilder();
		**CriteriaQuery**<Order> criteriaQuery = cb.createQuery(Order.class);
		**Root**<Order> root = criteriaQuery.from(Order.class); //검색 조건 대상
		**Predicate** predicate = spec.toPredicate(root,cb); // spec으로 새로운 predicate 생성
		
		**criteriaQuery.where(predicate); //조건 다 넣어놓기
		criteriaQuery.orderBy(
				cb.desc(root.get(Order_.number).get(OrderNo.number)));	

		TypedQuery<Order> query = entityManager.createQuery(criteriaQuery);**
		return query.getResultList();
	}
}

** 도메인 모델은 구현 기술에 의존하지 않아야 한다.

  • 하지만, JPA 용 specification인터페이스는 JPA 의 Root와 CreateBuilder에 의존하고 있다.
  • → 리포지터리 구현 기술에 의존하지 않는 Specification을 만들려면 많은 부분을 추상화 해야하는데, 이는 많은 노력을 요구한다.
  • 따라서 한 어플리케이션에서 다양한 리포지터리 구현 기술을 사용하고 각 리포지터리에 대해 동일한 스펙 인터페이스를 사용해야 하는 경우만 스펙을 추상화하려는 노력을 해야 한다.

3. 정렬 구현

  • JPA에서는 CriteriaQuery#orderBy()를 이용해서 정렬 순서를 지정한다.
    • CriteriaBuilder#asc()와 desc()메서드로 정렬할 대상을 지정한다.
criteriaQuery.**orderBy**(
				**cb.desc**(root.get(Order_.number).get(OrderNo.number)));//정렬할 대상 지정
  • JPQL을 사용하는 경우에는 order by 절을 사용한다.
TypedQuery<Order> query = entityManager.createQuery(
		"select o from Order o" +
				"where o.orderer.memberId.id = :ordererId" +
				**"order by o.number.number desc"**,
		Order.class);
)

정렬 순서가 고정되지 않은 경우

  • 정렬 순서를 응용 서비스에서 결정하는 경우에는 정렬 순서를 리포지터리에 전달할 수 있어야 한다.
    • JPA Criteria는 Order타입을 이용해서 정렬 순서를 지정한다.
    • 하지만 응용 서비스에서는 CriteriaBuilder에 접근할 수 없다.
    • 따라서 JPA Order가 아닌 다른 타입을 이용해서 리포지터리에 정렬 순서를 전달한다.
    • JPA리포지터리는 이를 다시 JPA Order로 변환한다.
  • 문자열을 사용하여 정렬 순서 지정하기
List<Order> orders = orderRepository.findAll(somespec, "number.number")
@Repository
public class JpaOrderRepository implements OrderRepository{
	..//생략
	
	@Override
	public List<Order> findAll(Specification<Order> spec, String ... orders){
	..//생략
	
	criteriaQuery.where(predicate);
	**if(orders.length > 0) {
		criteriaQuery.orderBy(JpaQueryUtils.toJpaOrders(root,cb,orders));
	}**
	TypedQuery<Order> query = entityManager.createQuery(criteriaQuery)
	return query.getResultList();
	}
}
public class **JpaQueryUtils**{
	public static <T> **List<Order> toJpaOrders**(Root<T> root, CriteriaBuilder cb, String ... orders){
		if (orders == null || orders.length == 0) return Collections.emptyList();
		return Arrays.stream(orders)
							.map(orderStr -> toJpaOrder(root,cb,orderStr)) //각각 toJpaOrder시행
							.collect(toList());
	}

	private static <T> **Order toJpaOrder**(Root<T> root, CriteriaBuilder cb, String ... orders){
		String[] orderClause = orderStr.split(" ");
		boolean ascending = true;
		if(orderClause.length == 2 && orderClause[1].equalsIgnoreCase("desc")) {
			ascending = false;
		}
		String[] paths = orderClause[0].split("\\\\.");
		Path<Object> path = root.get(paths[0]);
		for (int i=1;i<paths.length;i++) {
			path = path.get(paths[i]);
		}
		return ascending ? cb.asc(path) : cb.desc(path);
	}

	//JPQL의 order by 절 생성
	public static **String** **toJPQLOrderBy**(String alias, String ... orders){
		if(orders==null || orders.length == 0) return "";
		String orderParts = Arrays.stream(orders)
									.map(order -> alis + "." + order)
									.collect(joining(", "));
		return "order by " + orderParts;
	}
}

⇒ 다음과 같이 사용 가능하다.

1. name desc → cb.desc(root.get(”name”))
2. customer.name asc → cb.asc(root.get(”customer”).get(”name”))
TypedQuery<Order> query = entityManager.createQuery(
	"select o from Order o" + 
			"where o.orderer.memberId.id = :ordererId" +
			**JpaQueryUtils.toJPQLOrderby("o", "number.number desc")**,
	Order.class);

4. 페이징과 개수 구하기 구현

페이징

  • JPA 쿼리에서는 setFirstResult() 와 setMaxResults() 메서드를 제공한다
    • setFirstResult() : 읽어올 첫 번째 행 번호 지정 (0부터 시작)
    • setMaxResults() : 읽어올 행 개수 지정
@Override
public List<Order> findByOrdererId(String ordererId, **int startRow, int fetchSize**){
	TypedQuery<Order> query = entityManager.createQuery(
					"select o from Order o "+
						"where o.orderer.memberId.id = :ordererId" +
						"order by o.number.number desc"),
					Order.class);
	query.setParameter("ordererId", ordererId);
	**query.setFirstResult(startRow);
	query.setMaxResults(fetchSize);**
	return query.getResultList();
	)
}

ex) 한 페이지에 15개 행이 보여지고, 4번째 페이지를 조회하려고 한다

→ 46번째 (0부터 시작하므로)

List<Order> orders = findByOrdererId("madvirus", 45, 15);

전체 개수 구하기

  • JPQL을 이용한 구현
@Repository
public class JpaOrderRepository implements OrderRepository {
	...
	@Override
	public Long countsAll() {
		TypedQuery<Long> query = entityManager.createQuery(
			**"select count(o) from Order o"**, Long.class);
		return query.getSingleResult();
	}
}
  • specification 이용
    • 특정 조건 충족하는 애그리거트 개수 구하기
@Repository
public class JpaOrderRepository implements OrderRepository {
	...
	@Override
	public Long counts(Specification<Order> spec){
		CriteriaBuilder cb = entityManager.getCriteriaBuilder();
		CriteriaQuery<Long> criteriaQuery = cb.createQuery(Long.class); //리턴 클래스 지정
		Root<Order> root = criteriaQuery.from(Order.class); //검사할 클래스 지정

		**criteriaQuery.select(cb.count(root))
										.where(spec.toPredicate(root,cb));**
		TypedQuery<Long> query = entityManager.createQuery(criteriaQuery);
		return query.getSingleResult();
	}
}
  • 지금까지 리포지터리의 스펙, 정렬 순서, 페이징을 위한 코드 구현에 대해 알아보았는데, 이러한 구현을 대부분 자동으로 해주는 모듈이 바로 JPA이다.
    • JPA를 이용하여 인터페이스 작성만으로 구현할 수 있게 된다.

5. 조회 전용 기능 구현

  • 리포지터리는 애그리거트의 저장소를 표현하는 것으로, 다음 용도로 사용하는 것은 적합하지 않다.
    • 여러 애그리거트를 조합해서 한 화면에 보여주는 데이터 제공
      • JPA의 지연 로딩과 즉시 로딩 설정, 연관 매핑 등으로 복잡해진다.
      • 애그리거트 간에 직접 연관을 맺으면 ID 참조의 장점을 활용할 수 없게 된다.
    • 각종 통계 데이터 제공
      • 다양한 테이블을 조인하거나 DBMS 전용 기능을 사용해야 하는데, JPQL이나 Criteria로 처리하기 힘들다.

⇒ 조회 전용 쿼리를 사용하자!

  • 동적 인스턴스 생성
  • 하이버네이트의 @Subselect 확장 기능
  • 네이티브 쿼리 등

동적 인스턴스 생성

  • JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.
@Repository
public class JpaOrderViewDao implements OrderViewDao{
	@PersistenceContext
	private EntityManager em;

	@Override
	public List<OrderView> selectByOrderer(String ordererId) {
		String selectQuery = 
				"select **new com.myshop.order.application.dto.OrderView(o,m,p)**"+
				"from Order o join o.orderLines ol, Member m, Product p"+
				"where o.orderer.memberId.id = :ordererId" + 
				"and o.orderer.memberId = m.id" +
				"and index(ol) = 0" +
				"and ol.productId = p.id"+
				"order by o.number.number desc";

		TypedQuery<OrderView> query = 
					em.createQuery(selectQuery, OrderView.class);
		query.setParameter("ordererId", ordererId);
		return query.getResultList();
	}
}
public class OrderView {
	private String number;
	private Long totalAmounts;
	...
	private String productName;

	public class OrderView(Order order,Member member,Product product){
		this.number = order.getNumber().getNumber(); //매핑
		this.totalAmounts = order.getTotalAmounts().getValue();
		...
		this.productName = product.getName();
	}
	... //get 메서드
}
  • 조회 전용 모델을 생성하는 이유(새로 추가한 밸류타입을 → 기본 타입으로 변환해준다.)
  • : 표현 영역을 통해 사용자에게 데이터를 보여주기 위해 사용한다.
  • 모든 객체를 전달하지 않고 필요한 개별 프로퍼티를 생성자에 전달해줘도 된다.
----JPQL
select new com.myshop.order.application.dto.OrderView(
		o.number.number,o.totalAmounts,o.orderDate,m.id.id,m.name,p.name)
...생략
----

----자바생성자
public class OrderView {
	private String number;
	private Long totalAmounts;
	...
	private String productName;

	public OrderView(String number, int totalAmounts, Date orderDate, String memberId,
						String memberName, String productName) {
		this.number = number;
		this.totalAmounts = totalAmounts;
		...
		this.productName = productName;
	}
	...
}
  • 동적 인스턴스의 장점
    • JPQL을 그대로 사용하므로,
    • 객체 기준으로 쿼리를 작성하면서도
    • 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다.

하이버네이트 @Subselect 사용

  • @Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 유용한 기능이다.
@Entity
**@Immutable**
**@Subselect**("select o.order_number as number, " +
				"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 String ordererId;
	private String ordererName;
	private int totalAmounts;
	...
	private String productName;

	protected OrderSummary(){}
	//..get 메서드
}
  • @Subselect→ 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.
  • : 조회 쿼리를 값으로 갖는다.
  • @Immutable
    • subselect를 이용하여 조회한 entity를 수정할 수 없기 때문에 불변으로 설정한다.
    • 해당 엔티티의 매핑 필드/프로퍼티가 변경되어도 DB에 반영하지 않고 무시한다.
  • @Synchronize
    • 해당 엔티티와 관련된 테이블 목록을 명시하여 엔티티 로딩 전, 지정한 테이블과 관련된 변경이 발생하면 플러시를 먼저 수행한다.
  • Subselect 장점
    • Entity와 같기 때문에 EntityManager#find(), JPQL, Criteria를 사용해서 조회할 수 있다.
    • 스펙 또한 사용 가능하다
//@Subselct 를 적용한 @Entity는 일반 @Entity와 동일한 방법으로 조회할 수 있다.
OrderSummary summary = entityManager.find(OrderSummary.class, orderNumber);

TypedQuery<OrderSummary> query = em.createQuery("select os from OrderSummary "+
		"os where os.ordererId = :ordererId" +
		"order by os.orderDate desc", OrderSummary.class);
query.setParameter("ordererId", ordererId);
List<OrderSummary> result = query.getResultList();
Comments