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

xyzw·2024년 3월 23일
0

DDD

목록 보기
5/11
post-thumbnail

5.1 시작에 앞서

CQRS: 명령(Command) 모델과 조회(Query) 모델을 분리하는 패턴
명령 모델: 상태를 변경하는 기능을 구현할 때 사용
조회 모델: 데이터를 조회하는 기능을 구현할 때 사용

[ex]
회원 가입, 암호 변경, 주문 취소: 상태(데이터) 변경 기능 -> 명령 모델 사용
주문 목록, 주문 상세: 데이터 보여주는 기능 -> 조회 모델 사용

엔티티, 애그리거트, 리포지터리: 상태 변경할 때 사용
-> 도메인 모델은 명령 모델로 주로 사용된다.

정렬, 페이징, 검색 조건 지정과 같은 기능: 조회 기능에 사용


5.2 검색을 위한 스펙

목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다.
검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스펙이다.
스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스이다.

public interface Specification<T> {
	public boolean isSatisfiedBy(T agg);
}

isSatisfiedBy() 메서드의 agg 파라미터는 검사 대상이 되는 객체다. 스펙을 리포지터리에 사용하면 agg는 애그리거트 루트가 되고, 스펙을 DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다.
그리고 이 메서드는 검사 대상 객체가 조건을 충족하면 true, 그렇지 않으면 false를 리턴한다.

리포지터리나 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> orderSpec = new OrderSpec("madvirus");
List<Order> orders = orderRepository.findAll(orderSpec);

하지만 실제 스펙은 이렇게 구현하지 않는다. 모든 애그리거트 객체를 메모리에 보관하기도 어렵고 설사 메모리에 다 보관할 수 있다 하더라도 조회 성능에 심각한 문제가 발생하기 때문이다.
실제 스펙은 사용하는 기술에 맞춰 구현하게 된다.


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

스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification을 제공하며 아래와 같이 정의되어 있다.

public interface Specification<T> extends Serializable {
	
    @Nullable
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}

스펙 인터페이스에서 제네릭 타입 파라미터 T는 JPA 엔티티 타입을 의미한다. toPredicate() 메서드는 JPA 크리테리아 API에서 조건을 표현하는 Predicate을 생성한다.

public class OrdererIdSpec implements Specification<OrderSummary> {
	
    private String orderId;
    
    public OrdererIdSpec(String ordererId) {
    	this.ordererId = ordererId;
    }
    
    @Override
    public Predicate toPredicate(Root<OrderSummary> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
    	return cb.equal(root.get(OrderSummary_.ordererId), ordererId);
    }
}

OrdererIdSpec 클래스는 Specification<OrderSummary> 타입을 구현하므로 OrderSummary에 대한 검색 조건을 표현한다. toPredicate() 메서드를 구현한 코드는 ordererId 프로퍼티 값이 생성자로 전달받은 ordererId와 동일한지 비교하는 Predicate을 생성한다.


OrderSummary_ 클래스는 JPA 정적 메타 모델을 정의한 코드이다. 정적 메타 모델 클래스는 다음과 같이 구현한다.

@StaticMetaModel(OrderSummary.class)
public class OrderSummary_ {
	public static volatile SingularAttribute<OrderSummary, String> number;
    public static volatile SingularAttribute<OrderSummary, Long> version;
    public static volatile SingularAttribute<OrderSummary, String> ordererId;
    public static volatile SingularAttribute<OrderSummary, String> ordererName;
    ...
}

정적 메타 모델은 @StaticMetaModel 애너테이션을 이용해서 관련 모델을 지정한다. 위 코드는 OrderSummary 클래스의 메타 모델을 정의하고 있다. 메타 모델 클래스는 모델 클래스의 이름 뒤에 '_'을 붙인 이름을 갖는다.

정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다. 이 정적 필드는 프로퍼티에 대한 메타 모델로서 프로퍼티 타입에 따라 SingularAttribute, ListAttribute 등의 타입을 사용해서 메타 모델을 정의한다.

정적 메타 모델 클래스를 직접 작성할 수 있지만 하이버네이트와 같은 JPA 프로바이더는 정적 메타 모델을 생성하는 도구를 제공하고 있으므로 이들 도구를 사용하면 편리하다.

스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.

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);
    }
}

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

스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다. 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이다.

and, or

and()or() 메서드는 기본 구현을 가진 디폴트 메서드이다. and() 메서드는 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성하고 or() 메서드는 두 스펙 중 하나 이상을 충족하는 조건을 표현하는 스펙을 생성한다.

Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1");
Specification<OrderSummary> spec2 = OrderSummarySpecs.orderDateBetween(
	LocalDateTime.of(2002, 1, 1, 0, 0, 0),
    LocalDateTime.of(2002, 1, 2, 0, 0, 0));
Specification<OrderSummary> spec3 = spec1.and(spec2);

not

스펙 인터페이스는 not() 메서드도 제공한다. 이는 조건을 반대로 적용할 때 사용한다.

where

null 가능성이 있는 스펙 객체와 다른 스펙을 조합해야 할 때가 있다. 이 경우 다음 코드처럼 null 여부를 판단해서 NullPointerException이 발생하는 것을 방지해야 하는데 null 여부를 매번 검사하려면 다소 귀찮다.

Specification<OrderSummary> nullableSpec = createNullableSpec();
Specification<OrderSummary> otherSpec = createOtherSpec();

Specification<OrderSummary> spec = nullableSpec == null ? otherSpec : nullableSpec.and(otherSpec);

where() 메서드를 사용하면 이런 귀찮음을 줄일 수 있다. 이 메서드는 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고 null이 아니면 인자로 받은 스펙 객체를 그대로 리턴한다. 위 코드를 아래와 같이 간단하게 변경할 수 있다.

Specification<OrderSummary> spec = Specification.where(createNullableSpec()).and(createOtherSpec());

5.6 정렬 지정하기

스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.

  • 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
  • Sort를 인자로 전달

1. 메서드 이름에 OrderBy 사용

findByOrdererIdOrderByNumberDesc 메서드는 다음 조회 쿼리를 생성한다.

  • ordererId 프로퍼티 값을 기준으로 검색 조건 지정
  • number 프로퍼티 값을 역순으로 정렬

장점

  • 간단함

단점

  • 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어짐
  • 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수 없음

2. Sort 타입 사용

public interface OrderSummaryDao extends Repository<OrderSummary, String> {
	
    List<OrderSummary> findByOrdererId(String ordererId, Sort sort);
    List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);
}

스프링 데이터 JPA는 파라미터로 전달받은 Sort를 사용해서 알맞게 정렬 쿼리를 생성한다.

Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);

이 코드는 "number" 프로퍼티 기준 오름차순 정렬을 표현하는 sort 객체를 생성한다. 만약 두 개 이상의 정렬 순서를 지정하고 싶다면 Sort.and() 메서드를 사용해서 두 Sort 객체를 연결하면 된다.


5.7 페이징 처리하기

목록을 보여줄 때 전체 데이터 중 일부만 보여주는 페이징 처리는 기본이다. 스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다. Sort 타입과 마찬가지로 find 메서드에 Pageable 타입 파라미터를 사용하면 페이징을 자동으로 처리해준다.

public interface MemberDataDao extends Repository<MemberData, String> {
	List<MemberData> findByNameLike(String name, Pageable pageable);
}

위 코드에서 findByNameLike() 메서드는 마지막 파라미터로 Pageable 타입을 갖는다. Pageable 타입은 인터페이스로, 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다.

PageRequest pageReq = PageRequest.of(1, 10);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq); 

PageRequest.of() 메서드의 첫번째 인자는 페이지 번호를, 두번째 인자는 한 페이지의 개수를 의미한다. 페이지 번호는 0번부터 시작하므로 위 코드는 한 페이지에 10개씩 표시한다고 했을 때 두번째 페이지를 조회한다. 즉 11번째부터 20번째까지 데이터를 조회한다.


  • PageRequestSort를 사용하면 정렬 순서를 지정할 수 있다.
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);
}
  • 스펙을 사용하는 findAll() 메서드도 Pageable을 사용할 수 있다.
public interface MemberDataDao extends Repository<MemberData, String> {
	Page<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
}
  • N개의 데이터만 가져오려면 Pageable을 사용하지 않고 findFirstN 형식의 메서드를 사용할 수도 있다.
    First 대신 Top을 사용해도 된다. First나 Top 뒤에 숫자가 없으면 한 개 결과만 리턴한다.
List<MemberData> findFirst3ByNameLikeOrderByName(String name)

MemberData findFirstByBlockedOrderById(boolean blocked)

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.getNumber(); // 현재 페이지 번호 
int numberOfElements = page.getNumberOfElements(); // 조회 결과 개수
int size = page.getSize(); // 페이지 크기

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

스펙을 생성하다 보면 조건에 따라 스펙을 조합해야 할 때가 있다.

Specification<MemberData> spec = Specification.where(null);
if (searchRequest.isOnlyNotBlocked()) {
	spec = spec.and (MemberDataSpecs .nonBlocked());
}
if (StrinqUtils.hasText(searchRequest.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(searchRequest.getName()))
		.toSpec();
List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));

if 블록을 사용할 때와 비교하면 코드 양은 비슷한데 메서드를 사용해서 조건을 표현하고 메서드 호출 체인으로 연속된 변수 할당을 줄여 코드 가독성을 높이고 구조가 단순해졌다.

스펙 빌더 클래스에는 and(), ifHasText(), ifTrue() 메서드가 있는데 이외에 필요한 메서드를 추가해서 사용하면 된다.


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 키워드가 있다.
new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름을 지정하고 괄호 안에 생성자에 인자로 전달할 값을 지정한다.
이 코드는 OrderView 생성자에 인자로 각각 Order의 number, Member의 name 등 필요한 값을 전달한다. OrderView 생성자는 생성자로 전달받은 데이터를 저장한다.


조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
많은 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로 값을 기본 타입으로 변환하면 편리하다.


동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다.


5.10 하이버네이트 @Subselect 사용

하이버네이트는 JPA 확장 기능으로 @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;
	private long version;
	@Column (name = "orderer_id")
	private String ordererId;
	@Column (name = "orderer_name")
	private String ordererName;
	...

	protected OrderSummary() {
	}

@Immutable, @Subselect, @Synchronize는 하이버네이트 전용 애너테이션으로, 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.

@Subselect

  • 조회(select) 쿼리를 값으로 갖는다.
  • 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.
  • 뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다.
  • 이름처럼 @Subselect의 값으로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다.
  • 장점: EntityManager.find(), JPQL, Criteria를 사용해서 조회할 수 있다. 스펙을 사용할 수 있다.

@Immutable

하이버네이트는 @Subselect를 이용한 @Entity의 매핑 필드/프로퍼티가 변경되어도 DB에 반영하지 않고 무시한다.

@Synchronize

해당 엔티티와 관련된 테이블 목록을 명시한다. 하이버네이트는 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 플러시를 먼저 한다.

0개의 댓글