시작에 앞서 언급할 것이 하나 있다. 바로 CORS 이다.
CORS는 명령 모델과 조회 모델을 분리하는 패턴이다.
명령 모델은 상태를 변경하는 기능을 구현할 때 사용하고, 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다.
예를 들어,
회원 가입, 암호 변경처럼 상태를 변경하는 기능을 구현할 때는 명령 모델을 사용한다.
주문 목록, 주문 상세처럼 데이터를 보여주는 기능을 구현할 때는 조회 모델을 사용한다.
엔티티, 애그리거트, 리포지토리 등 앞에서 살펴본 모델은 상태를 변경할 때 주로 사용된다.
즉, 도메인 모델은 명령 모델로 주로 사용된다.
반면에 정렬, 페이징, 검색 조건 지정과 같은 기능은 조회 기능에 사용되기 떄문에, 조회 모델을 구현할 때 주로 사용된다.
이러한 이유로, 도메인 모델에 속하는 Repository 와 데이터 접근을 의미하는 DAO 이름을 혼용해서 사용하겠다.
검색 조건이 고정되어 있고 단순하면 다음과 같이 특정 조건으로 조회하는 기능을 만들면 된다.
public interface OrderDataDao {
Optional<OrderData> findById(OrderNo id);
List<OrderData> findByOrderer(String ordererId, Data fromdate, Date toDate);
...
}
목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다.
필요한 조합마다 find()를 정의하는 것은 좋은 방법은 아니다.
이렇게 검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 거이 스펙, Specification 이다.
스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스로, 다음과 같이 정의한다.
public interface Specification<T> {
public boolean isSatisfiedBy(T agg);
}
isSatisfiedBy()
검사 대상 객체가 조건을 충족하면true, 그렇지 않으면false를 반환한다.
isSatisfiedBy() 의 agg 인자는 검사 대상이 되는 객체이다.
스펙을 리포지토리에 사용하면 agg 는 애그리거트 루트가 되고, DAO에 사용하면 검색 결과로 리턴할 데이터 객체가 된다.
Order 애그리거트 객체가 특정 고객의 주문인지 확인하는 스펙은 다음과 같이 구현할 수 있다.
public class OrderSpec 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);
}
}
리포지토리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다.
만약, 리포지토리가 모든 애그릭트를 보관하고 있다면 다음과 같이 스펙을 사용할 수 있다.
public class MemoryOrderRepository implements OrderRepository {
public List<Order> findAll(Specificiation<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);
하지만 실제 스펙은 이렇게 구현하지 않는다.
모든 애그리거트 객체를 메모리에 보관하기도 어렵고 설사 메모리에 다 보관할 수 있더라도 조회 성능에 심각한 문제가 발생한다.
실제 스펙은 사용하는 기술에 맞춰서 구현하게 된다.
스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스 Specification 을 제공한다.
package org.springframework.data.jpa.domain;
public interface Specification<T> extends Serializable {
// not, where, and, or
@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBUilder cb);
}
- GenericType Parameter
T: JPA 엔티티 타입- toPredicate() : JPA Criteria API에서 조건을 표현하는 Predicate
스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 을 사용하면 된다.
findAll() 는 스펙 인터페이스를 인자로 갖는다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findAll(Specification<OrderSummary> spec);
}
// OrderSummary에 대한 검색 조건을 표현하는 스펙 인터페이스를 인자로 전달
Specitification<OrderSummary> spec = new OrderIdSpec("user1");
List<OrderSummary> results = orderSummaryDao.findAll(spec);
스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드를 제공하고 있다.
바로 and 와 or 이다.
public interface Specification<T> extends Serializable {
default Specification<T> and(@Nullable Specification<T> other) {...}
default Specification<T> or(@Nullable Specification<T> other) {...}
@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
두 메서드는 기본 구현을 가진 디폴트 메서드이다.
and() : 두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성or() : 두 스펙 중 하나 이상을 충족하는 조건을 표현하는 스펙을 생성Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1");
Specification<OrderSummary> spec2 = OrderSuammrySpecs.orderDateBetween(
LocalDateTime.of(2022, 1, 1, 0, 0, 0),
LocalDateTime.of(2022, 1, 2, 0, 0, 0));
Specification<OrderSummary> spec3 = spec1.and(spec2);
다음 코드처럼 개별 스펙 조건마다 변수를 선언하지 않고, 불필요한 변수 사용을 줄여 다음처럼 구현할 수도 있다.
Specification<OrderSummary> spec = OrderSummarySpecs.ordererId("user1")
.and(OrderSuammrySpecs.orderDateBetween(from, to));
조건을 반대로 적용할 때 사용하는 not() 정적 메서드도 제공한다.
Specification<OrderSummary> spec = Specification.not(OrderSummarySpecs.ordererId("user1"));
NPE가 발생할 가능성이 있는 스펙 객체와 다른 객체를 조합해야 할 경우, NPE 발생 여부 판단해서 방지해야 하는데, 이는 다소 귀찮은 작업이다.
스펙 인터페이스에서 제공하는 where() 을 이용하면 이런 귀찮음을 줄일 수 있다.
정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고, null이 아니면 인자로 받은 스펙 객체를 그대로 리턴한다.
Specification<OrderSummary> spec = Specification.where(CreateNullableSpec()).and(createOtherSpec());
스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정할 수 있다.
특정 프로퍼티로 조회하는 find() 는 이름 뒤에 OrderBy 를 사용해서 정렬 순서를 지정할 수 있다.
다음 메서드는 조회 쿼리를 다음과 같이 생성한다.
ordererId 프로퍼티 값을 기준으로 검색 조건 지정number 프로퍼티 값 역순으로 정렬public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findByOrdererIdOrderByNumberDesc(String ordererId);
}
두 개 이상의 프로퍼티에 대한 정렬 순서를 지정할 수도 있다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
List<OrderSummary> findByOrdererIdOrderByOrderDateDescNumberAsc(String ordererId);
}
메서드 이름에 OrderBy 를 사용하는 바법은 간단하지만 메서드 이름이 길어지는 단점이 있다.
또한, 메서드 이름으로 정렬 순서가 정해지기 땜ㄴ에 상황에 따라 정렬 순서를 변경할 수도 없다.
이럴 때는 Sort 타입을 사용하면 된다.
스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 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를 사용해서 알맞게 정렬 쿼리를 생성한다.
find() 는 알맞은 Sort 객체를 생성해서 전달하면 된다.
Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
두 개 이상의 정렬 순서를 지정하고 싶다면 and() 를 사용해서 연결하면 된다.
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());
목록을 보여줄 때 전체 데이터 중 일부만 보여주는 페이징 처리는 기본이다.
스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다.
Sort 타입과 마찬가지로, find() 에 Pageable 타입 파라미터를 이용해 페이징을 자동으로 처리해준다.
public interface MemberDataDao extends Repository<MemberData, String> {
List<MemberData> findByNameLike(String name, Pageable pageable);
}
Pageable 타입은 인터페이스로 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다.
// PageRequest.of(페이지 번호, 한 페이지의 개수)
PageRequest pageRequest PageRequest.of(1, 10);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageRequest);
PageRequest와 Sort를 사용하면 정렬 순서를 지정할 수 있다.
Sort sort = Sort.by("name").descending();
PageRequest pageRequest = PageRequest.of(1, 2, sort);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageRequest);
Page 타입을 이용해서 조건에 해당하는 전체 개수도 구할 수 있다.
메서드 리턴 타입이 Page일 경우, 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 개수를 구한다.
public interface MemberDataDao extends Repository<MemberData, String> {
Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
}
Page는 전체 개수, 페이지 개수 등 페이징 처리에 필요한 데이터도 함께 제공한다.
다음은 Page가 제공하는 메서드의 일부를 보여준다.
Pageable pageRequest = PageRequest.of(2, 3);
Page<MemberData> page = memberDataDao.findByBlocked(false, pageRequest);
List<MemberData> contet = page.getContent(); // 조회 결과 목록
long totalElements = page.getTotalElements(); // 조건에 해당하는 전체 개수
int totalPages = page.getTotalPages(); // 전체 페이지 번호
int number = page.getNumber(); // 현재 페이지 번호
int numberOfElements = page.getNumberOfElements(); // 조회 결과 개수
int size = page.getSize(); // 페이지 크기
스펙을 사용하는 findAll() 에도 Pageable을 사용할 수 있다.
public interface MemberDataDao extends Repository<MemberData, String> {
Page<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
}
프로퍼티를 비교하는 findBy() 메서드는 Pageable 타입을 사용하더라도 리턴 타입이 List면 COUNT 쿼리를 실행하지 않는다. 페이징 처리와 관련된 정보가 필요없다면 Page 리턴 타입이 아닌, List를 사용해서 불필요한 COUNT 쿼리를 실행하지 않도록 한다. 반면에 스펙을 사용하는 findAll에 Pageable 타입을 사용하면 리턴 타입이 Page가 아니어도 COUNT 쿼리를 실행한다. 스펙을 사용하고 페이징 처리를 하면서 COUNT 쿼리를 실행하고 싶지 않다면 스프링 데이터 JPA가 제공하는 커스텀 리포지토리 기능을 이용해서 직접 구현해야 한다.
처음부터 N개의 데이터가 필요하다면 Pageable 대신 findFirstN 형식의 메서드를 사용할 수 있다.
First 대신 Top을 사용해도 되고, 뒤에 숫자가 없으면 한 개의 결과만 리턴한다.
List<MemberData> findFirst3ByNameLikeOrderByName(String name);
MemberData findFirstByBlockedOrderById(boolean blocked);
스펙을 생성하다 보면 조건에 따라 스펙을 조합해야 할 때가 있다.
스펙을 조합하는 코드에 if문과 조합 코드가 섞이면 실수하기 좋고 복잡한 구조를 갖게 된다.
이를 보완하기 위해서 스펙 빌더를 사용할 수 있다.
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));
JPA는 커리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.
public interface OrderSummaryDao extends Repository<OrderSummary, String> {
@Query("""
select new 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 키워드 뒤에 생성할 인스턴스에 완전한 클래스 이름을 저장하고 괄호 안에 생성자에 인자로 전달할 값을 지정한다.
조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
많은 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로, 값을 기본 타입으로 변환하면 편리하다.
물론 원하는 형식으로 출력하도록 프레임워크를 확장해서 조회 전용 모델에서 밸류 타입의 의미가 사라지지 않도록 할 수도 있다.
동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연, 즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다.
하이버네이트는 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 oin product p
where
ol.line_idx = 0
and ol.product_id = p.product_id
"""
)
@Synchroize({"purchase_order", "order_line", "product"})
public class OrderSummary {
@Id
private String number;
...
protected OrderSummary() {}
}
@Immutable, @Subselect, @Synchronize는 하이버네이트 전용 어노테이션이다.
이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.
@Subselect는 조회 쿼리를 값으로 갖는다.
하이버네이트는 이 select 쿼리 결과를 매핑할 테이블처럼 사용한다.
DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블로 보여주기 위해 뷰를 사용하는 것처럼 @Subselect를 사용하면 커리 실행 결과를 매핑할 테이블처럼 사용하는 것이다.
뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다.
실수로 @Subselect를 이용한 엔티티 매핑 필드를 수저앟면 update 쿼리가 실행될 것이고, 매핑한 테이블이 없으므로 에러가 발생하게 된다.
이런 문제를 방지하기 위해 @Immutable을 사용해 해당 엔티티의 매핑 필드/프로퍼티가 변경되어도 DB에 반영하지 않고 무시하도록 하자.
다음 코드를 보자.
Order order = orderRepository.findById(orderNumber);
order.changeShippingInfo(newInfo);
List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
Order 상태를 변경한 뒤에 OrderSummary 를 조회하고 있다.
특별한 이유가 없으면 하이버네이트는 커밋 시점에 DB를 동기화하므로 Order 변경 내역을 아직 'purchase_order' 테이블에 반영하지 않은 상태에서 헤딩 테이블을 사용하는 OrderSummary 를 조회하게 된다.
즉, 최신 값이 아닌 이전 값이 담기게 된다.
이런 문제를 해소하기 위한 용도로 @Synchronize를 사용해, 해당 엔티티와 관련된 테이블 목록을 명시한다.
하이버네이트는 엔티티를 로딩하기 전에 지정한 테이블과 관련된 변경이 발생하면 flush를 먼저 한다.
OrderSummary 의 @Synchronize는 'purchase_order' 테이블을 지정하고 있으므로 OrderSummary 를 로딩하기 전에 관련 내역을 먼저 플러시한다.
따라서 OrderSummary 를 로딩하는 시점에서 변경 내역이 반영된다.
@Subselect는 일반 @Entity와 같으므로 EntityManager의 find(), JPQL, Criteria, 스펙을 사용한 조회가 가능하다는 장점이 있다.
@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 iner join order_line ol
on o.order_number = ol.order_number
cross joi 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 쿼리를 사용하거나 MyBatis와 같은 별도 Mapper를 사용해서 조회 기능을 구현해야 한다.