도메인 주도 개발 시작하기 책 정리
시작에 앞서
✅ CQRS
명령 모델과 조회 모델을 분리하는 패턴이다.
-> 도메인 모델은 명령 모델로 주로 사용된다.
이 장에서 설명할 정렬, 페이징, 검색 조건 지정과 같은 기능은 주로 조회 기능에 사용된다.
-> 즉, 이 장에서 살펴볼 구현 방법은 조회 모델을 구현할 때 주로 사용한다.
검색을 위한 스펙
✅ 검색 조건이 복잡할 때
목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다.
-> 이럴 때 사용할 수 있는 것이 스펙(Specification)이다.
스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다.
public interface Specification<T> {
public boolean isSatisfiedBy(T agg);
}
isSatisfiedBy() 메서드의 agg 파라미터는 검사 대상이 되는 객체다.
ex) Order 애그리거트 객체가 특정 고객의 주문인지 확인하는 스펙
public class OrderSpec implements Specification<Order> {
private String ordererId;
public OrderSpec(String ordererId) {
this.ordererId = ordererId;
}
@Override
public boolean isSatisfiedBy(Order agg) {
return agg.getOrderer().getMemberId().getId().equals(ordererId);
}
}
리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다.
public class MemberOrderRepository implements OrderRepository {
public List<Order> findAll(Specification<Order> spec) {
List<Order> allOrders = findAll();
return allOrders.stream()
.filter(order -> spec.isSatisfiedBy(order))
.toList();
}
...
}
+) 위 코드는 리포지터리가 메모리에 모든 애그리거트 객체를 보관하고 있을 때 적합한 코드다.
(어떤 Spec이 오든 지연로딩없이 필터링해야 효율적이기 때문)
리포지터리를 호출하는 쪽에서는 원하는 스펙을 생성해서 리포지터리에 전달해준다.
Specification<Order> ordererSpec = new OrdererSpec("han");
List<Order> orders = orderRepository.findAll(ordererSpec);
하지만 실제 스펙은 이렇게 구현하지 않는데,
모든 애그리거트 객체를 메모리에 보관하기가 어렵기 때문에,
실제 스펙은 사용하는 기술에 맞춰 구현하게 된다.
이 장에서는 스프링 데이터 JPA를 이용한 스펙 구현에 대해 알아볼 것이다.
스프링 데이터 JPA를 이용한 스펙 구현
✅ 스프링 데이터 JPA가 제공하는 스펙
스프링 데이터 JPA는 이미 검색 조건을 표현하기 위한 인터페이스인 Specification을 제공한다.
public interface Specification<T> extends Serializable {
long serialVersionUID = 1L;
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);
}
1. 스펙 인터페이스의 지네릭 타입 파라미터 T는 JPA 엔티티 타입을 의미한다.
2. toPredicate() 메서드는 JPA 크리테리아(Criteria) API에서 조건을 표현하는 Predicate를 생성한다.
ex) 엔티티 타입이 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) {
return cb.equal(root.get(OrderSummary_.ordererId), ordererId);
}
}
✅ JPA 정적 메타 모델
코드를 보면 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;
public static volatile SingularAttribute<OrderSummary, Integer> totalAmounts;
public static volatile SingularAttribute<OrderSummary, String> receiverName;
public static volatile SingularAttribute<OrderSummary, String> state;
public static volatile SingularAttribute<OrderSummary, LocalDateTime> orderDate;
public static volatile SingularAttribute<OrderSummary, String> productId;
public static volatile SingularAttribute<OrderSummary, String> productName;
}
1. @StaticMetamodel 애노테이션을 통해 관련 모델을 지정한다.
2. 메타 모델 클래스는 모델 클래스의 이름 뒤에 '_'을 붙인 이름을 갖는다.
3. 정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다.
정적 메타 모델을 사용하는 대신 문자열로 프로퍼티를 지정할 수는 있지만 오타 가능성이 있기 때문에,
Criteria를 사용할 때는 정적 메타 모델 클래스를 사용하는 것이 코드 안정성이나 생산성 측면에서 유리하다.
cb.equal(root.<String>get("ordererId"), ordererId); // 오타 가능성이 있다
+) 다음 링크와 같은 방식으로 하이버네이트가 자동으로 정적 메타 모델을 생성하도록 도구를 사용하면 방법도 있다.
https://ocwokocw.tistory.com/169
✅ 모든 스펙 생성 기능을 한 클래스에
스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.
ex) OrderSummarySpecs
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);
}
}
리포지터리/DAO에서 스펙 사용하기
✅ 스펙을 충족하는 엔티티 검색
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findAll(Specification<OrderSummary> spec);
}
이렇게 하면 별다른 구현 없이 스펙을 만족하는 엔티티 목록을 검색할 수 있다.
스펙 조합
✅ 스펙을 조합하는 메서드
스프링 데이터 JPA의 스펙 인터페이스는 스펙을 조합할 수 있는 메서드 and, or를 제공한다.
and() 메서드는 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성하고,
or() 메서드는 두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성한다.
ex)
Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1");
Specification<OrderSummary> spec2 = OrderSummarySpecs.orderDateBetween(
LocalDateTime.of(2022, 1, 1, 0, 0, 0),
LocalDateTime.of(2022, 1, 2, 0, 0, 0)
);
Specification<OrderSummary> spec3 = spec1.and(spec2);
✅ 스펙을 반대로 적용하는 메서드
스펙 인터페이스는 not() 메서드도 제공한다.
not()은 조건을 반대로 적용할 때 사용한다.
ex)
Specification<OrderSummary> spec4 = Specification.not(spec1);
✅ null safe
null 가능성이 있는 스펙 객체와 다른 스펙을 조합해야 할 때가 있다.
이런 경우 NPE 방지를 위해 null 여부를 검사해야 하는데, 이 때 where() 메서드를 사용하면 편하다.
where() 메서드는 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고,
null이 아니면 인자로 받은 스펙 객체 그대로 리턴한다.
ex)
Specification<OrderSummary> spec5 = Specification.where(spec1).and(spec2);
정렬 지정하기
✅ OrderBy
스프링 데이터 JPA는 메서드 이름에 OrderBy를 사용해서 정렬을 지정할 수 있다.
ex)
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findByOrdererIdOrderByNumberDesc(String ordererId);
List<OrderSummary> findByOrdererIdOrderByNumberDescOrderDateAsc(String ordererId); // 2개 프로퍼티 정렬
}
이런 방식은 간단하지만 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다.
또한 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수도 없다.
✅ Sort
스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 Sort 타입을 제공한다.
ex)
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findByOrdererId(String ordererId, Sort sort);
List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);
}
리포지토리를 사용하는 코드는 다음과 같다.
Sort sort1 = Sort.by("number").ascending();
Sort sort2 = Sort.by("orderDate").descending();
Sort sort = sort1.and(sort2);
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
and() 메서드로 두 Sort 객체를 연결할 수 있다.
페이징 처리하기
✅ Pageable
스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다.
find 메서드에 해당 타입을 파라미터로 사용하면 페이징을 자동으로 처리해준다.
ex)
public interface MemberDataDao extends Repository<MemberData, String> {
List<MemberData> findByNameLike(String name, Pageable pageable);
}
리포지토리를 사용하는 코드는 다음과 같다.
PageRequest pageReq = PageRequest.of(1, 10);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
Pageable 타입 객체는 PageRequest 클래스를 이용해 생성한다.
+) 페이지 번호는 0번부터 시작하므로, 위 코드는 두 번째 페이지를 조회하게 된다.
PageRequest는 Sort를 사용해 정렬 순서를 지정할 수 있다.
PageRequest pageReq = PageRequest.of(1, 10, Sort.by("name").ascending());
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
Page 타입을 사용하면 데이터 목록 뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다.
-> Pageable을 사용하는 메서드의 리턴 타입이 Page일 경우,
-> 스프링 데이터 JPA는 목록 조회 쿼리와 함께 전체 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 개수를 구한다.
public interface MemberDataDao extends Repository<MemberData, String> {
Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
}
✅ COUNT 쿼리 실행 조건
public interface MemberDataDao extends Repository<MemberData, String> {
List<MemberData> findByNameLike(String name, Pageable pageable);
List<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
}
다음 두 메서드는 리턴 타입이 모두 List이기 때문에 COUNT 쿼리를 별도로 생성하지 않는다고 생각할 수 있다.
-> 그러나 결과는 findByNameLike()는 COUNT 쿼리를 생성하지 않고, findAll은 COUNT 쿼리를 생성한다.
그 이유는 'findBy프로퍼티' 형식의 메서드는 프로퍼티에 대한 조건만으로 전체 데이터 개수를 알 수 있다.
그러나 스펙을 사용한 메서드는 다양한 조건으로 필터링할 수 있기 때문에 전체 데이터 개수를 미리 파악하는 것이 불가능한다.
-> 즉, 페이징 처리를 위해서는 전체 데이터 개수를 먼저 알아야 하므로 COUNT 쿼리가 실행된다.
✅ First
처음부터 N개의 데이터가 필요하다면 Pageable을 사용하지 않고 findFirstN 형식의 메서드를 사용할 수 있다.
List<MemberData> findFirst3ByNameLikeOrderByName(String name);
+) First 대신 Top을 사용해도 된다.
+) First 뒤에 숫자가 없으면 한 개 결과만 리턴한다.
스펙 조합을 위한 스펙 빌더 클래스
✅ SpecBuilder
스펙을 생성하다 보면 다음 코드처럼 조건에 따라 스펙을 조합해야 할 때가 있다.
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> results = memberDataDao.findAll(spec, PageRequest.of(0, 5));
이 코드는 if와 각 스펙을 조합하는 코드가 섞여 있어 복잡하다.
-> 이 점을 보완하기 위해 스펙 빌더를 만들어 사용할 수 있다.
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;
}
}
}
이를 사용하는 코드는 다음과 같다.
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));
동적 인스턴스 생성
✅ 조회 전용 모델
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 키워드를 통해 동적 인스턴스를 생성할 수 있다.
이렇게 조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
-> 밸류 타입을 기본 타입 등의 알맞은 형식으로 출력할 수 있고,
-> 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다.
하이버네이트 @Subselect 사용
✅ 쿼리 결과를 엔티티로 매핑
하이버네이트는 JPA 확장 기능으로 @Subselect를 제공한다.
-> @Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 기능이다.
ex) OrderSummary
@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;
...
}
✅ @Subselect
해당 select 쿼리의 결과를 엔티티에 매핑한다. (실제 테이블은 없다)
DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼,
@Subselect를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다.
✅ @Immutable
뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다.
실수로 엔티티를 수정하면 하이버네이트는 update 쿼리를 실행하게 되는데, 이 때 매핑 테이블이 없으므로 에러가 발생한다.
이런 문제를 방지하기 위해 @Immutable을 사용하면 하이버네이트는 해당 엔티티가 변경되도 DB에 반영하지 않고 무시한다.
✅ @Synchronize
다음 코드를 보자.
// purchase_order 테이블에서 조회
Order order = orderRepository.findById(orderNumber);
order.changeShippingInfo(newInfo); // 상태 변경
// 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블에서 조회
List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
위 코드는 Order의 상태를 변경한 뒤에 OrderSummary를 조회하고 있다.
보통 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영하므로,
Order의 변경 내역을 아직 purchase_order 테이블에 반영하지 않는 상태에서 해당 테이블을 사용하는 OrderSummary를 조회한다.
-> 즉, OrderSummary에는 최신 값이 아닌 이전 값이 담기게 된다.
이런 문제를 해소하기 위해 @Synchronize에 해당 엔티티와 관련된 테이블 목록을 명시한다.
-> 하이버네이트는 OrderSummary를 로딩하기 전에 지정한 테이블과 관련된 변경이 있으면 flush를 먼저 하고 로딩한다.
✅ 실제 조회 쿼리
만약 OrderSummary를 조회할 때, @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
'book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
[DDD Start] 도메인 서비스 (0) | 2023.08.18 |
---|---|
[DDD Start] 응용 서비스와 표현 영역 (0) | 2023.08.12 |
[DDD Start] 리포지터리와 모델 구현 (0) | 2023.07.28 |
[DDD Start] 애그리거트 (0) | 2023.07.18 |
[DDD Start] 아키텍처 개요 (0) | 2023.07.12 |