1. 검색을 위한 스펙
리포지터리는 애그리거트의 저장소이다. 애그리거트를 저장하고 찾고 삭제하는 것은 리포지터리의 기본 기능인데, 조회시 식별자 외에 다양한 조건으로 애그리거트를 찾아야 할 때가 있다.
스펙(Specification)은 애그리거트가 특정 조건을 충족하는지 여부를 검사한다. 이를 특정 조건을 충족하는 애그리거트를 찾을 수 있다.
public interface Specification<T> {
// 검사 대상 객체가 조건을 충족하면 true, 아니면 false
public boolean isSatisfiedBy(T aggregate);
}
public class MemoryOrderRepository implements OrderRepository {
public List<Order> findAll(Specification spec) {
List<Order> allOrders = findAll();
return allOrders.stream.filter(order -> spec.isSatisfiedBy(order)).collect(toList());
}
}
스펙 조합
스펙의 장점인 조합을 통해 AND 연산자나 OR 연산자로 조합해서 더 복잡한 스펙을 만들 수 있다.
(원래는 Specification을 상속받아 AndSpec<T>
을 구현해서 사용해야 하지만 실무에선 스프링 데이터 JPA 를 통해 간단하게 사용할 수 있으니 생략한다.)
2. JPA를 위한 스펙 구현
앞서 예로 보여준 리포지터리 코드는 모든 애그리거트를 조회한 다음에 스펙을 이용해 필터링하는 방식이기 때문에 성능상의 문제가 있다. 실제 구현에서는 쿼리의 where 절에 조건을 붙여 필요한 데이터를 걸러야 한다. JPA는 다양한 검색 조건을 조합하기 위해 CriteriaBuilder와 Predict를 사용한다.
(JPA 자체에 대한 설명은 생략)
JPA 스펙 구현
public interface Specification<T> {
Predict toPredicate(Root<T> root, CriteriaBuilder cb);
}
public class OrdererSpec implements Specification<Order> {
private String ordererId;
public OrdererSpec(String ordererId) {
this.ordererId = ordererId;
}
// Order의 orderer.memberId.id 프로퍼티가 생성자로 전달받은 ordererId와 비교하는 Predicate를 리턴한다.
@Override
public Predicate toPredicate(Root<Order> root, CriteriaBuilder cb) {
return cb.equal(root.get(Order_.orderer)
.get(Orderer_.memberId).get(MemberId_.id),
ordererId);
}
}
JPA 정적 메타 모델
위 코드의 Order_.orderer 의 Order_ 클래스는 JPA의 정적 메타 모델을 정의한 코드이다. 정적 메타 모델은 @StaticMetamodel 애노테이션을 이용해서 관련 모델을 지정한다. 기존 모델 클래스의 이름뒤에 ‘_‘을 붙인 이름을 갖는다.
정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다. 이 정적 필드는 프로퍼티 타입에 따라 SingularAttribute, ListAttribute 등의 타입을 사용해 메타 모델을 정의한다.
정적 메타 모델을 사용하는 대신 문자열로 프로퍼티를 지정할 수도 있다.root.get(“orderer”).get(“memberId”).get(“id”)
하지만 문자열은 오타 가능성이 있고 IDE의 코드 자동 완성 기능을 사용 못해 입력할 코드가 많아진다. 따라서 코드 안정성과 생산성 측면에서 정적 메타 모델 클래스를 이용하는 것이 유리하다.
정적 메타 모델 클래스를 직접 작성할 수 있지만, 하이버네이트와 같은 JPA 프로바이더는 정적 메타 모델을 생성하는 도구를 제공하고 있어 이를 사용하면 편리하다.
Specification 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.
public class OrderSpecs {
public static Specification<Order> orderer(String ordererId) {
return (root, cb) -> cb.equal(
root.get(Order_.orderer).get(Order_.memberId).get(MemberId_.id),
ordererId);
}
public static Specification<Order> between(Date from, Date to) {
return (root, cb) -> cb.between(root.get(Order_.orderDate), from, to);
}
}
Specification<Order> betweenSpec = OrderSpecs.between(fromTime, toTime);
AND/OR 스펙 조합을 위한 구현
본래 Specification을 상속받아서나 또는 팩토리 클래스를 통해 직접 And/Or Spec을 직접 구현하여 사용할 수 있다.
public class AndSpecification<T> implements Specification<T> {
// 대략 spec들을 인자로 받아, List<Predicate>를 만든후 cb.and(predcates)를 리턴하는 코드
// 실무에서 직접 구현할 일이 없어 생략한다. (스프링 데이터 JPA가 자동으로 해줌)
...
}
public class Specs {
public static <T> Specification<T> and(Specfication<T> ...specs) {
return new AndSpecification<>(specs);
}
...
}
Specification<Order> specs = Specs.and(OrderSpecs.orderer("madvirus"), OrderSpecs.between(fromTime, toTime));
스펙을 사용하는 JPA 리포지터리 구현
이제 스펙을 사용하도록 리포지터리를 구현하는 것이 남았다.
public interface OrderRepository {
public List<Order> findAll(Specification<Order> spec);
...
}
@Repository
public class JpaOrderRepository implements OrderRepository {
...
@Override
public List<Order> findAll(Specification<Order> spec) {
CriteriaBuidler cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> criteriaQuery = cb.createQueury(Order.class);
// 검새 조건 대상이 되는 루트 생성
Root<Order> root = criteriaQuery.from(Order.class);
// 파라미터로 전달받은 스펙을 이용해 Predicate 생성
Predicate predicate = spec.toPredicate(root, cb);
// 쿼리의 조건으로 생성한 Predicate 전달
criteriaQuery.where(predicate);
criteriaQuery.orderBy(
cb.desc(root.get(Order_.number).get(OrderNo_.number)));
TypedQuery<Order> query = entityManager.createQuery(criteriaQuery);
return query.getResultList();
}
}
리포지터리 구현 기술 의존
본래 도메인 모델은 구현 기술에 의존하지 않아야 한다. 그러나 JPA용 Specification 인터페이스는 toPredicate() 메서드가 JPA의 Root와 CriteriaBuilder에 의존하고 있으므로 사용하는 리포지터리 인터페이스는 이미 JPA에 의존하고 있다.
이런 경우 구현 기술에 완전히 독립하여 완전히 의존하지 않도록 만들어야 할까? ‘아니다’. 리포지터리 구현 기술에 의존하지 않는 Specification을 만들려면 많은 부분 추상화를 해야하는데 이에 비해 실제 얻는 이점은 크지 않다. 왜냐하면 리포지터리 구현 기술을 바꿀 정도의 변화는 드물기 때문이다. 한 애플리케이션에서 다양한 리포지터리 구현 기술을 사용하고 각 리포지터리에 대해 동일한 스펙 인터페이스를 사용해야 하는 경우에만 스펙을 추상화하는 노력을 해야한다.
3. 정렬 구현
JPA의 CriteriaQuery#orderBy()를 이용해서 정렬 순서를 지정한다
TypedQuery<Order> query = entityManager.createQuery(
"select o from Order o " +
"where o.orderer.memberId.id = :ordererId " +
"order by o.number.number desc",
Order.class);
정렬순서가 고정된 경우 위와 같이 하면 되지만, 응용 서비스에서 순서를 결정하는 경우엔 아래와 같이 문자열을 사용할 수 있다.
List<Order> orders = orderRepository.findAll(someSpec, "number.number desc");
JPA 리포지터리 구현 클래스는 위의 문자열을 파싱해서 JPA Criteria의 Order로 변환하거나 JPQL의 order by 절로 변경하면 된다. 책에서는 실제 JpaOrderRepository 그리고 JpaQueryUtils 에서 위의 파싱 코드를 각각 구현해 놓았지만 실제로 이를 구현할 일은 없다(스프링 데이터 JPA에서 제공). 때문에 구현 코드는 생략한다.
4. 페이징과 개수 구하기 구현
JPA 쿼리는 setFirtsResult():읽어올 첫 번째 행 번호
와 setMaxResults():읽어올 행 개수
를 이용하여 페이징을 구현할 수 있다.
@Override
public List<Order> findByOrdererId(String ordererId, int startRow, int fetchSize) {
TypedQuery<Order> query = entityManager.createQuery(...);
query.setParameter(...);
query.setFirstResult(startRow);
query.setMaxResults(fetchSize);
return qeury.getResultList();
}
JPQL을 이용해 아래와 같이 전체 개수를 구할 수 있다.
@Repository
public class JpaOrderRepository implements OrderRepository {
...
@Override
public Long countsAll() {
TypedQuery<Long> query = entityManager.createQuery(
"select count(o) from Order o", Long.class);
return query.getSingleResult();
}
...
}
스프링 데이터 JPA
지금까지 리포지터리의 스펙, 정렬 순서, 페이징을 위한 코드 구현에 대해 알아봤는데 위에서 여러 번 말했듯이 구현의 대부분을 자동으로 해주는 모듈이 있다. 바로 갓 스프링 데이터 JPA이다. 이를 사용하면 지금까지 설명한 많은 내용을 인터페이스 작성만으로 구현할 수 있게 된다.
스프링 데이터 JPA를 사용하는 방법 : http://projects.spring.io/spring-data-jpa
5. 조회 전용 기능 구현
리포지터리는 애그리거트의 저장소를 표현하는 것으로서 다음 용도로 사용하는 것은 적합하지 않다.
- 여러 애그리거트를 조합해서 한 화면에 보여주는 데이터 제공
- 각종 통계 데이터 제공
첫 번째 기능을 제공하려다 보면 JPA의 지연 로딩과 즉시 로딩 설정, 연관 매핑으로 골치가 아플 것이다. 게다가 애그리거트 간 직접 연관을 맺으면 ID 참조의 장점을 활용할 수 없게 된다.
두 번째 기능 역시 다양한 테이블을 조인하거나 DBMS 전용 기능을 사용해야 구할 수 있는데, 이는 JPQL이나 Criteria로 처리하기 힘들다.
애초에 이런 기능들은 조회 전용 쿼리로 처리해야 하는 것들이다. JPA와 하이버네이트를 사용하면 동적 인스턴스 생성, 하이버네이트의 @Subselect 확장 기능, 네이티브 쿼리를 이용해 조회 전용 쿼리를 구현할 수 있다.
동적 인스턴스 생성
JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.
@Repository
public class JpaOrderViewDao implements OrderViewDao {
...
@Override
public List<OrderView> selectByOrderer(String ordererId) {
String selectQuery =
// new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름 지정후 인자로 전달할 값을 지정한다.
"select new OrderView(o, m, p) " +
"from Order o join o.orderLines ol, Member m, Product p " +
"...";
TypedQuery<OrderView> query =
em.createQuery(selectQuery, OrderView.class);
query.setParameter("ordererId", ordererId);
return query.getResultList();
}
}
// 조회 전용 모델을 따로 만들어 표현 영역을 통해 사용자에게 데이터를 보여준다.
// 대부분 웹 프레임워크는 새로 추가한 밸류 타입을 알맞은 형식으로 출력하지 못하므로 아래와 같이 기본 타입으로 변환하면 편리하다.
public class OrderView {
private String number;
private long totalAmounts;
...
public class OrderView(Order order, Member member, Product product) {
this.number = order.getNumber().getNumber();
this.totalAmounts = order.getTotalAmounts().getValue();
...
}
... // get 메서드
}
모델의 개별 프로퍼티를 생성자에 전달할 수도 있다. 예를 들어 주문 목록을 보여줄 목적으로 OrderView를 사용한다면 생성자로 필요한 값만 전달한다.
---- JPQL
select new OrderView(o.number.number, o.totalAmounts, o.orderDate, m.id.id, m.name, p.name)
...(생략)
---- Java Constructor
public class OrderView {
...
public OrderView(String number, long totalAmounts, Date orderDate, String memberId, String memberName, String productName) {
this.number = number;
...
}
}
동적 인스턴스의 장점은 JPQL을 그대로 사용하므로, 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다.
하이버네이트 @Subselect 사용
하이버네이트는 JPA의 확장 기능으로 @Subselect를 제공한다. @Subselect는 쿼리 결과를 @Entity로 매핑할 수 있는 유용한 기능이다. 아래 @Immutable, @Subselect, @Synchronize 등의 하이버네이트 전용 애노테이션을 이용하여 @Entity로 매핑한다.
@Entity
// @Subselect를 이용한 @Entity의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리를 실행한다.
// 그러나, 매핑한 ㅌ테이블이
@Immutable
@Subselect("select o.order_number as number, " +
"o.orderer_id, o.orderer_name, o.total_amounts, " +
"...")
@Synchronize({"puchase_order", "order_line", "product"})
public class OrderSummaray {
@Id
private String number;
private String ordererId;
...
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "orderDate")
...
}
@Subselect는 조회(select) 쿼리를 갖고, 하이버네이트는 이 결과를 매핑할 테이블처럼 사용한다. DBMS가 여러 테이블을 조인하여 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼, @Subselect를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다.
뷰를 수정할 수 없든 @Subselect로 조회한 @Entity 역시 수정할 수 없는데 실수로 @Subselect를 이용한 @Entity의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리를 실행할 것이다. 그런데, 매핑한 테이블이 없으므로 에러가 발생하는데 이를 방지하기 위해 @Immutable을 사용한다. 이는 해당 엔티티의 매핑 필드/프로퍼티가 변경되어도 DB에 반영하지 않고 무시한다.
하지만 이렇게 되면 아래와 같은 동기화 문제가 발생한다.
// purchase_order 테이블에서 조회
Order order = orderRepository.findById(orderNumber);
order.changeShippingInfo(newInfo); // 상태 변경
// 변경 내역이 DB 반영 안되었는데 purchase_order 테이블에서 조회
List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
이 문제를 해결하기 위해 사용하는 것이 @Synchronize이다. 이는 해당 엔티티와 관련된 테이블 목록을 명시한다. 하이버네이트는 엔티티를 로딩하기 전 지정한 테이블과 관련된 변경이 발생하면 flush를 먼저한다.
@Subselect를 사용해도 일반 @Entity와 같기 때문에 EntityManager#find(), JPQL, Criteria 를 사용해서 조회할 수 있다는 것이 @Subselect의 장점이다. (Spec 포함)
@Subselect를 적용한 @Entity는 일반 Entity와 동일한 방법으로 조회할 수 있다.
@Subselect는 이름처럼 @Subselect의 값으로 지정한 쿼리를 from 절의 서브쿼리로 사용한다.
select osm.number, osm.orderer_id,...
...생략
from (
// 위의 @Subselect
select o.order_number as number,
o.orderer_id, o.orderer_name, o.total_amounts,
...
) osm
where osm.number = ?
따라서 위의 형태를 갖는다는 점을 유념해야 한다. 서브쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리 또는 MyBatis와 같은 별도 매퍼를 사용해 조회 기능을 구현해야 한다.