효습
5장 스프링 데이터 JPA를 이용한 조회 기능 본문
5.1 시작에 앞서
- CQRS는 명령(Command) 모델과 조회(Query)모델을 분리하는 패턴이다.
- 명령 모델은 상태를 변경하는 기능을 구현할 때 사용한다.
- 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다.
- 도메인 모델은 주로 명령 모델로 사용된다.
- 반면 5장의 정렬,페이징,검색 조건 지정과 같은 기능은 조회 기능에서 사용된다.
5.2 검색을 위한 스펙
검색 조건이 고정되어 있고 단순하면 특정 조건으로 조회하는 기능을 만들면 된다.
다양한 검색 조건을 조합해야할 때 , 필요한 조합마다 find 메서드를 정의할 수도 있지만 조합이 증가할수록 정의해야할 find 메서드도 함께 증가한다.
→ 검색 조건을 다양한게 조합해야 할 때 사용할 수 있는 것이 스펙(Spectations)다.
- 스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다.
- 스펙 인터페이스는 다음과 같이 정의한다.
public interface Spectation<T>{
public boolean isSatisfiedBy(T agg);
}
- agg 파라미터는 검사 대상이 되는 객체다.
- 스펙을 리포지터리에 사용하면 agg는 애그리거트 루트가 되고,
- 스펙을 DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다.
- isSatisfied() 메서드는 검사 대상 객체가 조건을 충족하면 true를 리턴하고, 그렇지 않으면 false를 리턴한다.
- 리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다.
- 리포지터리가 스펙을 이용해서 검색 대상을 걸러주므로 특정 조건을 충족하는 애그리거트를 찾고 싶으면 원하는 스펙을 생성해서 리포지터리에 전달해주기만 하면 된다.
5.3 스프링 데이터 JPA를 이용한 스펙 구현
스펙 인터페이스를 구현한 클래스 예시
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);
}
}
- OrderIdSpec 클래스는 Specification 타입을 구현하므로 OrderSummary에 대한 검색 조건을 표현한다.
- toPredicate()메서드를 구현한 코드는 OrderId 프로퍼티 값이 생성자로 전달받은 OrderIdSpec의 orderID와 동일한지 비교하는 Predicate을 생성한다.
스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.
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);
}
위의 코드에서 findAll()
메서드는 OrderSummary에 대한 검색 조건을 표현하는 스펙 인터페이스를 파라미터로 갖는데 이를 스펙 구현체와 사용하면 특정 조건을 충족하는 엔티티를 검색할 수 있다.
//스펙 객체를 생성하고
Specification<OrderSummary> spec = new OrderIdSpec("user1");
//findAll() 메서드를 이용해서 검색
List<OrderSummary> results = orderSummaryDao.findAll(sepc);
5.5 스펙 조합
스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드 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);
and()
와or()
메서드는 기본 구현을 가진 디폴트 메서드다.and()
메서드는 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성한다.or()
메서드는 두 스펙 중 하나 이상을 충족하는 조건을 표현하는 스펙을 생성한다.- 위 코드에서는 spec1 과 spec2의 조건을 모두 충족하는 spec3를 생성한다.
스펙 인터페이스는 not()
메서드도 제공한다.
- 정적 메서드로 조건을 반대로 적용할 때 사용한다.
null 가능성이 있는 스펙 객체와 다른 객체를 조합할 때는 null 판단 여부를 파악해서 NullPointException이 발생하는 것을 방지해야하는데 where()
메서드를 사용하면 간편하게 null를 처리할 수 있다.
where()
메서드는 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고, null이 아니면 인자로 받은 스펙 객체를 그대로 리턴한다.
Specification<OrderSummary> spec =
Specification.where(createNullableSpec()).and(createOtherSpec());
5.6 정렬 지정하기
스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.
- 메서드 이름을 OrderBy를 사용해서 정렬 기준 지정
- Sort를 인자로 전달
예를 들어 findByOrderIdOrderByNumberDesc메서드는
- orderId 프로퍼티 값을 기준으로 검색 조건을 지정하고
- number 프로퍼티 값을 역순으로 정렬한다.
메서드 이름에 OrderBy를 사용하는 방법은 간단하지만 정렬 기준 프로퍼티가 두 개 이상이면 메서드 이름이 길어지는 단점이 있다. 또한 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬 순서를 변경할 수 없다.
→ Sort 타입을 사용하면 된다.
Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrderId("user1",sort);
5.7 페이징 처리하기
- 스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다.
- Pageable 타입은 인터페이스로 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다.
- PageReq와 Sort를 사용하면 정렬 순서를 지정할 수 있다.
Sort sort = Sort.by("name").descending();
pageRequest pageReq = PageRequest.of(1.10);
List<MemberData> user = memberDateDao.findByNameLike("사용자",pageReq);
Pageable을 사용하는 메서드의 리턴 타입이 Page인 경우 스프링 데이터 JPA는 목록 조회 쿼리를 함께 COUNT 쿼리로 실행해서 조건에 해당하는 데이터 개수를 구한다.
- 스펙을 사용하는
findAll()
메서드도 Pageable을 사용할 수 있다. - 프로퍼티를 비교하는 findBy프로퍼티 형식의 메서드는 Pageable 타입을 사용하더라도 리턴 타입이 List면 COUNT 쿼리를 실행하지 않는다.
처음부터 N개의 데이터가 필요하다면 Pageable를 사용하지 않고 findFirstN 형식의 메서드를 사용할 수 있다.
First 대신 Top을 사용해도 된다.
List<MemberData> findFirst3ByNameLikeOrderByName(String name)
MemberData findTopByBlockedOrderById(boolean blocked)
5.8 스펙 조합을 위한 스펙 빌더 클래스
조건에 따라 스펙을 조합해야할 때가 생긴다.
이때 스펙 빌더 클래스를 사용한다.
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));
- 메서드를 사용하여 조건을 표현하고 메서드 호출 체인으로 연속된 변수 할당을 줄여 코드 가독성을 높이고 구조가 단순해졌다.
5.9 동적 인스턴스 생성
JPA는 쿼리 결과를 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.
JPQL에서 동적 인스턴스를 사용한 코드는 다음과 같다.
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);
}
- select 절을 보면 new 키워드가 있다.
- new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름을 지정하고 괄호 안에 생성자에 인자로 전달한 값을 지정한다.
조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
- 많은 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로 값을 기본 타입으로 변환하면 편하다.
동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다.
5.10 하이버네이트 @Subselect 사용
하이버네이트는 JPA 확장 기능으로 쿼리 결과를 @Entity
로 매핑할 수 있는 @Subselect
를 제공한다.
@Immutable
,@Subselect
,@Synchronize
는 하이버네이트 전용 애너테이션인데 이 태그를 사용하면 테이블이 아닌 쿼리 결과를@Entity
에 매핑할 수 있다.@Subselect
는 조회 쿼리를 값으로 갖는다.- 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.
- DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼 ,
@Subselect
를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다.
- DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼 ,
뷰를 수정할 수 없듯이 @Subselect
로 조회한 @Entity
역시 수정할 수 없다.
- 실수로
@Subselect
를 이용한@Entity
의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리를 실행할 것이다. → 그런데 매핑할 테이블이 없으므로 에러가 발생한다.( 매핑 필드를 수정하였으니 똑같은 테이블을 찾을 수 없음)- 이런 에러를 방지하기 위해
@Immutable
을 사용한다.
- 이런 에러를 방지하기 위해
@Immutable
을 사용하면 하이버네이트는 해당 엔티티의 매핑 필드/프로퍼티가 변경되도 DB에 반영하지 않고 무시한다.- 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 저장하므로 변경 사항이 연관된 테이블을 조회할 경우 최신값이 아닌 이전의 값을 조회하게 된다.
- 이를 방지하기 위해
@Synchronize
를 사용한다.@Synchronize
는 해당 엔티티와 관련된 테이블 목록을 명시한다. - 하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 플러시(Flush)를 먼저한다.
- 이를 방지하기 위해
@Subselect
를 사용해도 일반 @Entity
와 같기 때문에 EntityManager의 find()메서드나 JPQL,Criteria를 사용해서 조회할 수 있다는 것이 @Subselect
의 장점이다.
@Subselect
는 이름처럼 @Subselect
의 값으로 지정한 쿼리를 from 절의 서브 쿼리로 사용한다.
서브 쿼리를 사용하지 않고 쿼리 결과를 @Entity
에 매핑하고 싶다면 네이티브 SQL쿼리를 사용하거나 마이바티스와 같은 별도의 매퍼(Mapper)를 사용해서 조회 기능을 구현해야한다.
'책 > 도메인 주도 개발 시작하기' 카테고리의 다른 글
7장 도메인 서비스 (0) | 2024.04.01 |
---|---|
6장 응용 서비스와 표현 영역 (0) | 2024.03.25 |
4장 리포지터리 모델 구현 (2) | 2024.03.18 |
3장 애그리거트 (0) | 2024.03.18 |
2장 아키텍처 개요 (0) | 2024.03.11 |