본문은 [도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지]를 읽고 간단하게 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
5.1 시작에 앞서
- CQRS는 명령(Command) 모델과 조회(Query) 모델을 분리하는 패턴이다
- 명령 모델은 상태를 변경하는 기능을 구현할 때 사용한다
- 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다
- 엔티티, 애그리거트, 리포지터리 등 앞에서 살펴봤던 모델은 상태를 변경할 때 주로 사용된다
- 즉 도메인 모델은 명령 모델로 주로 사용된다
- 반면 이 장에서 설명할 정렬, 검색 조건 지정과 같은 기능은 조회 기능에 사용된다
- 즉 이 장에서 살펴볼 구현 방법은 조회 모델을 구현할 때 사용된다
- 이러한 이유로 이 장에서 사용하는 예제 코드는 리포지터리(도메인 모델에 속한)와 DAO(데이터 접근을 의미하는)라는 이름을 혼용한다
5.2 검색을 위한 스펙
- 스펙(Specification)은 검색 조건을 다양하게 조합해야 할 때 사용할 수 있다
- 스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다
public interface Specification<T> {
public boolean isStatisfiedBy(T agg);
}
- agg 파라미터는 검사 대상이 되는 객체다
- 스펙을 리포지터리에서 사용하면 agg는 애그리거트 루트가 되고, 스펙을 DAO에서 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다
- isSatisfiedBy() 메서드는 검사 대상 객체가 조건을 충족하면 true를 리턴하고, 그렇지 않으면 false를 리턴한다
public class OrdererSpec implements Specification<Order> {
private String ordererId;
public OrderSpec(String ordererId) {
this.ordererId = ordererId;
}
public boolean isSatisfiedBy(Order agg) {
return agg.getOrdererId().getMemberId().getId().equals(ordererId);
}
}
- 리포지터리가 스펙을 이용해서 검색 대상을 걸러주므로 특정 조건을 충족하는 애그리거트를 찾고 싶으면 아래와 같이 원하는 스펙을 생성해서 리포지터리에 전달해 주기만 하면 된다
// 검색 조건을 표현하는 스펙을 생성해서
Specification<Order> ordererSpec = new OrdererSpec("madvirus");
// 리포지터리에 전달
List<Order> orders = orderRepository.findAll(ordererSpec);
- 하지만 실제 스펙은 이렇게 구현하지 않는다
- 모든 애그리거트 객체를 메모리에 보관하는 것은 어렵다
- 메모리에 다 보관할 수 있다 하더라도 조회 성능에 심각한 문제가 발생한다
- 실제 스펙은 사용하는 기술에 맞춰 구현하게 되며, 이 장에서는 스프링 데이터 JPA를 사용하여 스펙을 구현한다
5.3 스프링 데이터 JPA를 이용한 스펙 구현
- 스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification(이후 '스펙 인터페이스'라고 지칭)을 제공하며 위와 같이 정의되어 있다
- 스펙 인터페이스에서 제네릭 타입 파라미터 T는 JPA 엔티티 타입을 의미한다
- toPredicate() 메서드는 JPA 크리테리아(Criteria) API에서 조건을 표현하는 Predicate를 생성한다
package org.springframework.data.jpa.domain;
import java.io.Serializable;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import org.springframework.lang.Nullable;
public interface Specification<T> extends Serializable {
// not, where, and, or 메서드 생략
@Nullable
Predicate toPredicate(
Root<T> root,
CriteriaQuery<?> query,
CriteriaBuilder criteriaBuilder);
}
- 아래 코드는 다음에 해당하는 스펙을 구한 것이다
- 엔티티 타입이 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 cb) {
// OrderSummary_ 클래스는 JPA 정적 메타 모델을 정의한 코드이다(p.180 참고)
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);
}
}
Specification<OrderSummary> betweenSpec =
OrderSummarySpecs.orderDateBetween(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");
// findAll() 메서드를 이용해서 검색
List<OrderSummary> results = orderSummaryDao.findAll(spec);
5.5 스펙 조합
- 스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 다양하게 사용할 수 있는 네 가지 메서드를 제공한다
public interface Specification<T> extends Serializable {
static <T> Specification<T> not(@Nullable Specification<T> spec) {
return spec == null ? (root, query, builder) -> {
return null;
} : (root, query, builder) -> {
return builder.not(spec.toPredicate(root, query, builder));
};
}
static <T> Specification<T> where(@Nullable Specification<T> spec) {
return spec == null ? (root, query, builder) -> {
return null;
} : spec;
}
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() 메서드는 스펙을 조합할 수 있다
- 두 메서드는 기본 구현을 가진 디폴트 메서드이다
- and() 메서드는 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성한다
- or() 메서드는 두 스펙 중 하나 이상을 충족하는 조건을 표현하는 스펙을 생성한다
- not()은 정적 메서드로 조건을 반대로 적용할 때 사용한다
- where() 메서드는 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고 null이 아니면 인자로 받은 스펙 객체 자체를 그대로 리턴한다
5.6 정렬 지정하기
- 스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다
- 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
- 메서드 이름에 OrderBy를 사용하는 방법은 간단하지만 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다
- 또한 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수도 없다
- Sort를 인자로 전달
- Sort를 인자로 전달하여 위 방식의 한계를 극복할 수 있다
- 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("user1", sort);
// and 메서드를 이용해서 두 Sort 객체를 연결할 수 있다
Sort.by("number").ascending().and(Sort.by("orderDate").descending());
5.7 페이징 처리하기
- 스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다
- Sort 타입과 마찬가지로 find 메서드에 Pageable 타입 파라미터를 사용하면 페이징을 자동으로 처리해준다
public interface MemberDataDao extends Repository<MemberData, String> {
List<MemberData> findByNameLike(String name, Pageable pageable);
}
- Pageable 타입은 인터페이스로 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다
- PageRequest.of() 메서드의 첫 번째 인자는 페이지 번호를, 두 번째 인자는 한 페이지의 개수를 의미한다
- 세 번째 인자로 Sort를 넘기면 정렬 순서를 지정할 수 있다
Pageable pageable = PageRequest.of(1, 10, Sort);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
- Pageable을 사용하는 메서드의 리턴 타입이 Page일 경우 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 개수를 구한다
- 최적화를 위해 COUNT 쿼리를 별도로 작성해서 실행시킬 수도 있다
- Page는 전체 개수, 페이지 개수 등 페이징 처리에 필요한 데이터도 함께 제공한다
page.getContent(); // 조회 결과 목록
page.getTotalElements(); // 조건에 해당하는 전체 개수
page.getTotalPages(); // 전체 페이지 번호
page.getNumber(); // 현재 페이지 번호
page.getNumberOfElements(); // 조회 결과 개수
page.getSize(); // 페이지 크기
- 프로퍼티를 비교하는 findBy프로퍼티 형식의 메서드는 Pageable 타입을 사용하더라도 리턴 타입이 List면 COUNT 쿼리를 실행하지 않는다
- 따라서 페이징 처리와 관련된 정보가 필요없다면 Page가 아닌 List로 리턴 타입을 설정하여 불필요한 COUNT 쿼리를 실행시키지 않도록 한다
Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
List<MemberData> findByNameLike(String name, Pageable pageable);
- 반면 스펙을 사용하는 findAll 메서드에 Pageable 타입을 사용하면 리턴 타입이 Page가 아니더라도 COUNT 쿼리를 사용한다
- 스펙을 사용하고 페이지 처리를 하면서 COUNT 쿼리를 실행하고 싶지 않다면 스프링 데이터 JPA의 커스텀 레포지토리 기능을 이용해서 직접 구현해야 한다
List<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
- 처음부터 N개의 데이터가 필요하다면 Pageable 대신 findFirstN 형식의 메서드를 사용할 수도 있다
List<MemberData> findFirst3ByNameLikeOrderByName(String name);
- First 대신 Top을 사용해도 된다
- 뒤에 숫자가 없으면 한 개 결과만 리턴한다
MemberData findFirstByBlockedOrderById(boolean blocked);
5.8 스펙 조합을 위한 스펙 빌더 클래스
- 조건에 따라 스펙을 조합해야 할 때 스펙 빌더를 사용하면 실수를 줄이면서 보다 간결한 구조를 유지할 수 있다
// 스펙 빌더 사용 X
// if와 각 스펙을 조합하는 코드가 섞여 있어 실수하기 좋고 복잡한 구조를 가짐
Specification<MemberData> spec = Specification.where(null);
if (searchRequest.isOnlyNotBlocked()) {
spec = spec.and(MemberDataSpecs.nonBlocked());
}
if (StringUtils.hasText(searchRequest.getName())) {
spec = spec.and(MemberDataSpecs.nameLike(searchRequest.getName()));
}
List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));
// 스펙 빌더 사용
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));
- 아래는 스펙 빌더 코드이다
- and(), ifHasText(), ifTrue() 메서드가 있는데 이외에 필요한 메서드를 추가해서 사용하면 된다
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<>();
public Builder<T> and(Specification<T> spec) {
specs.add(spec);
return this;
}
public Builder<T> ifHasText(String str,
Function<String, Specification<T>> specSupplier) {
if (StringUtils.hasText(str)) {
specs.add(specSupplier.apply(str));
}
return this;
}
public Builder<T> ifTrue(Boolean cond,
Supplier<Specification<T>> specSupplier) {
if (cond != null && cond.booleanValue()) {
specs.add(specSupplier.get());
}
return this;
}
public Specification<T> toSpec() {
Specification<T> spec = Specification.where(null);
for (Specification<T> s : specs) {
spec = spec.and(s);
}
return spec;
}
}
}
5.9 동적 인스턴스 생성
- JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다
- 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.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);
}
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;
}
// ...getter 생략
}
- 조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다
- 많은 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로 위 코드처럼 값을 기본 타입으로 변환하면 편리하다
- 동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다
- 개인적으론 도메인, VO, DTO 간의 변환 과정없이 한 번에 데이터를 조회할 수 없다는 것도 큰 장점 같다
5.10 하이버네이트 @Subselect 사용
- JPA의 기본 구현체인 하이버네이트는 JPA 확장 기능으로 @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 {
// 필드 생략..
protected OrderSummary() {
}
}
- @Immutable, @Subselect, @Synchronize는 하이버네이트 전용 애너테이션인데 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다
- @Suselect는 조회 쿼리를 값으로 갖는다
- 하이버네이트는 이 쿼리의 결과를 매핑할 테이블처럼 사용한다
- DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼 @Subselect를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다
- 뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다
- 실수로 @Subselect를 이용한 @Entity의 매핑 필드를 수정하면 하이버네이트는 update 쿼리를 실행시키지만, 매핑한 테이블이 실제 DB에는 없으므로 에러가 발생한다
- 이 문제를 발생하기 위해 @Immutable을 사용한다
- @Immutable을 사용하면 하이버네이트는 해당 엔티티의 매핑 필드/프로퍼티가 변경되도 DB에 반영하지 않고 무시한다
- 특별한 이유가 없으면 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영한다
- 따라서 아래 코드에서 OrderSummary에는 최신 값이 아닌 이전 값이 담기게 된다
// purchase_order 테이블에서 조회
Order order = orderRepository.findById(orderNumber);
order.changeShippingInfo(newInfo); // 상태 변경
// 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블에서 조회
List<OrderSummary> summaries = orderSummaryRepository.findByOrderId(userId);
- 이런 문제를 해소하기 위한 용도로 사용한 것이 @Synchronize이다
- @Synchronize는 해당 엔티티와 관련된 테이블 목록을 명시한다
- 하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 플러시(flush)를 먼저 한다
- OrderSummary를 로딩하기 전에 purchase_order 테이블에 변경이 발생하면 관련 내역을 먼저 플러시한다
- 따라서 OrderSummary를 로딩하는 시점에서는 변경 내역이 반영된다
- @Subselect를 사용해도 일반 @Entity와 같기 때문에 EntityManager#find(), JPQL, Criteria를 사용해서 조회할 수 있다는 것이 @Subselect의 장점이다
// @Subselect를 적용한 @Entity는 일반 @Entity와 동일한 방법으로 조회할 수 있다
// 스펙도 사용 가능하다
Specification<OrderSummary> spec = OrderSummarySpecs.orderDateBetween(from, to);
Pageable pageable = PageRequest.of(1, 1);
List<OrderSummary> results = orderSummaryDao.findAll(spec, pageable);
- @Subselect는 이름처럼 @Subselect의 값으로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다
- @Subselect를 사용할 때는 쿼리가 이러한 형태를 갖는다는 점을 유념해야 한다
- 서브 쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리를 사용하거나 마이바티스와 같은 별도 매퍼를 사용해서 조회 기능을 구현해야 한다
'책 > 도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지' 카테고리의 다른 글
[도메인 주도 개발 시작하기] 7장: 도메인 서비스 (0) | 2022.10.21 |
---|---|
[도메인 주도 개발 시작하기] 6장: 응용 서비스와 표현 영역 (0) | 2022.10.14 |
[도메인 주도 개발 시작하기] 4장: 리포지터리와 모델 구현 (0) | 2022.10.03 |
[도메인 주도 개발 시작하기] 3장: 애그리거트 (1) | 2022.09.20 |
[도메인 주도 개발 시작하기] 2장: 아키텍처 개요 (0) | 2022.09.16 |