해당 포스팅은 최범균 작가님의 도메인 주도 개발 시작하기 (P.183~198)를 읽고 정리한 글입니다.
스펙 조합
스펙 인터페이스는 기본 구현을 제공하는 default 메서드인 and()와 or()를 통해 스펙을 조합할 수 있다.
// 스펙 조건마다 개별 변수 선언
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);
// 바로 and() 메서드 사용
Specification<OrderSummary> spec = OrderSummarySpecs.ordererId("user1")
.and(OrderSummarySpecs.orderDateBetween(from, to));
스펙 인터페이스는 not() 메서드도 제공한다. not()은 정적 메서드로 조건을 반대로 적용할 때 사용한다.
Specification<OrderSummary> spec =
Specification.not(OrderSummarySpecs.ordererId("user1"));
스펙 객체가 null일 경우 NPE가 발생할 수 있으므로, where() 메서드를 사용해 이를 방지할 수 있다.
Specification<OrderSummary> spec =
Specification.where(createNullableSpec()).and(createOtherSpec());
정렬 지정하기
스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.
- 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
- Sort를 인자로 전달
스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 Sort 타입을 제공한다.
Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
만약 두 개 이상의 정렬 순서를 지정하고 싶다면, Sort#and() 메서드를 사용해서 두 Sort 객체를 연결하면 된다.
// 각 객체 변수 선언
Sort sort1 = Sort.by("number").ascending();
Sort sort2 = Sort.by("orderDate").descending();
Sort sort = sort1.and(sort2);
// 짧게 연결
Sort sort = Sort.by("number").ascending().and(Sort.by("orderDate").descending());
페이징 처리하기
Spring Data JPA는 페이징 처리를 위해 Pageable 타입을 이용한다. Pageable 타입은 인터페이스로 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다.
PageRequest pageReq = PageRequest(1, 10);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);
PageRequest.of() 메서드의 첫 번째 인자는 페이지 번호를, 두 번째 인자는 한 페이지의 개수를 의미한다. PageRequest와 Sort를 함께 사용하면 정렬 순서를 지정할 수 있다. Page 타입을 사용하면 데이터 목록뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다.
Pageable을 파라미터로 사용하는 메서드의 반환 타입이 Page일 때는 목록 조회 쿼리와 함께 전체 건수를 구하기 위한 COUNT 쿼리를 실행하지만, 반환 타입이 List면 COUNT 쿼리를 실행하지 않는다. 그러나 스펙을 사용하는 findAll 메서드는 Pageable을 넘기면 반환 타입이 Page가 아니어도 COUNT 쿼리를 수행한다.
스펙 조합을 위한 스펙 빌더 클래스
조건에 따라 스펙을 조합해야 할 때가 있다. 이때 if-else 조건문으로 구성하게 되면 실수하기 좋고 복잡한 코드 구조를 갖게 된다. 이 점을 보완하기 위해 스펙 빌더를 만들어 사용한다.
Specification<MemberData> spec = SpecBuilder.builder(MemberData.class)
.ifTrue(searchReqeust.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<>();
}
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;
}
}
}
동적 인스턴스 생성
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을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다.
하이버네이트 @Subselect 사용
하이버네이트는 JPA 확장 가능으로 @Subselect를 제공한다. @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;
}
@Immutable, @Subselect, @Synchronize는 하이버네이트 전용 에너테이션이다. 이 어노테이션을 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.
@Subselect
@Subselect는 조회 쿼리를 값으로 갖는다. 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다. DBMS가 뷰를 사용하는 것처럼 @Subselect를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다.
@Immutable
뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다. 실수로 해당 엔티티의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리를 실행할 것이다. 그러나 매핑 한 테이블이 없으므로 에러가 발생한다. 이런 문제를 해결하기 위해 @Immutable을 사용한다. @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 테이블에 반영하지 않은 상태에서 purchase_order 테이블을 사용하는 OrderSummary를 조회하게 된다. 즉, OrderSummary에는 최신 값이 아닌 이전 값이 담기게 된다.
이러한 문제를 해소하기 위해 @Synchronize를 사용한다. 하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 flush를 먼저 한다. OrderSummary의 @Synchronize는 'purchase_order' 테이블을 지정하고 있으므로 OrderSummary를 로딩하기 전에 purchase_order 테이블에 변경이 발생하면 관련 내역을 먼저 플러시 한다. 따라서 OrderSummary를 로딩하는 시점에서는 변경 내역이 반영된다.
@Subselect는 이름처럼 @Subselect의 값으로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다. 즉, 실행하는 쿼리는 다음과 같은 형식을 갖는다.
select osm.number as number1_0_, ...
from (
select o.order_number as number,
o.version,
... 생략
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
@Subselect를 사용할 때는 쿼리가 이러한 형태를 갖는다는 점을 유념해야 한다. 서브 쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리를 사용하거나 마이바티스와 같은 별도의 매퍼를 사용해서 조회기능을 구현해야 한다.
오늘 Spring Data JPA의 다양한 기능을 알게 되어 매우 흥미로웠다. 특히 @Subselect는 조회 시 유용하게 활용할 수 있을 것 같아 인상 깊었고, 프로젝트 진행 중 필요할 때 사용할 수 있는 유용한 도구가 하나 더 생긴 것 같아 기쁘다.
'📚 개발자의 서재 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
[DDD] 응용 서비스와 표현 영역 -2 (0) | 2025.05.11 |
---|---|
[DDD] 응용 서비스와 표현 영역 -1 (0) | 2025.05.10 |
[DDD] 스프링 데이터 JPA를 이용한 조회 기능 -2 (0) | 2025.05.08 |
[DDD] 리포지터리와 모델 구현 -8 / 스프링 데이터 JPA를 이용한 조회 기능 - 1 (0) | 2025.05.07 |
[DDD] 리포지터리와 모델 구현 -7 (0) | 2025.05.06 |