본문 바로가기
독서/도메인 주도 개발 시작하기

5장 스프링 데이터 JPA를 이용한 조회 기능

by Thinking 2024. 12. 2.

5.1 시작에 앞서

시작에 앞서 CQRS 알고가자. 명령(command) 모델과, 조회(query) 모델을 분리하는 패턴이다. 명령 모델은 상태를 변경하는 기능을 구현할 때 사용하고, 조회 모델은 데이터를 조회하는 기능을 구현할 때 사용한다.

 

엔티티, 애그리거트, 리포지터리 등 앞에서 살펴본 모델은 주문 취소, 배송지 변경과 같이 상태를 변경할 때 주로 사용된다. 즉 도메인 모델은 주문 취소, 배송지 변경과 같이 상태를 변경할 때 주로 사용된다. 명령 모델로 주로 사용된다.

 

반면에 이 장에서 설명할 정렬, 페이징, 검색 조건 지정과 같은 기능은 조회 기능에 사용된다. 즉 이 장에서 살펴볼 구현 방법은 조회 모델을 구현할 때 주로 사용한다.

 

 

5.2 검색을 위한 스펙

목록 조회와 같은 기능은 다양한 검색 조건을 조합해야 할 때가 있다. 필요한 조합마다 find 메서드를 정의할 수 있지만 좋은 방법이 아니다. 이렇게 검색 조건을 다양하게 조합할 때 사용할 수 있는 것이 스펙이다. 스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다. 스펙 인터페이스는 아래와 같이 정의한다.

 

public interface Specificiation<T> {
	public boolean isSatisfiedBy(T agg);
}

 

agg 파라미터는 검사 대상이 되는 객체다. 스펙을 리포지터리에 사용하면 agg는 애그리거트 루트가 되고, DAO에 사용하면 agg는 검색 결과로 리턴할 데이터 객체가 된다. 리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다.

 

하지만 실제 스펙은 이렇게 구현되지 않는다. 모든 애그리거트 객체를 메모리에 보관하기도 어렵고 조회 성능에 문제가 발생하기 때문이다. 실제 스펙은 사용하는 기술에 맞춰 구현하게 된다. 이 장에서는 스프링 데이터 JPA를 이용한 스펙 구현에 대해 알아볼 것이다.

 

 

5.3 스프링 데이터 JPA를 이용한 스펙 구현

스프링 데이터 JPA는 검색 조건을 표현하기 위한 인터페이스인 Specification을 제공한다. 스펙 인터페이스에서 제네릭 타입 파라미터 T는 JPA 엔티티 타입을 의미한다. toPredicate() 메서드는 JPA 크리테리아 API에서 조건을 표현하는 Predicate을 생성한다. 또한 스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.

 

스펙 인터페이스는 함수형 인터페이스이므로 람다식을 이용해서 객체를 생성할 수 있다. 스펙 생성이 필요한 코드는 스펙 생성 기능을 제공하는 클래스를 이용해 더 간결하게 스펙을 생성할 수 있다.

 

 

5.4 리포지터리/DAO에서 스펙 사용하기

스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다. findAll() 메서드는 스펙 인터페이스를 파라미터로 갖는다.

public interface OrderSummaryDao extends Repository<OrderSummary, String> {
	List<OrderSummary> findAll(Specification<OrderSummary> spec);
}

// 스펙 객체를 생성하고
Specification<OrderSummary> spec = new OrderIdSpec("user1");
// findAll() 메서드를 이용해서 검색
List<OrderSummary> results = orderSummaryDao.findAll(spec);

 

결국 선언한 findAll() 메서드는 OrderSummary에 대한 검색 조건을 표현하는 스펙 인터페이스를 파라미터로 갖는다. 이 메서드와 앞서 작성한 스펙 구현체를 사용하면 특정 조건을 충족하는 엔티티를 검색할 수 있다.

 

 

5.5 스펙 조합

스프링 데이터 JPA가 제공하는 스펙 인터페이스는 스펙을 조합할 수 있는 두 메서드를 제공한다. and와 or다. 이 둘은 기본 구현을 가진 디폴트 메서드이다. and() 메서드두 스펙을 모두 충족하는 조건을 표현하는 스펙을 생성하고, or() 메서드두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성한다.

 

Specification<OrderSummary> nullableSpec = createNullableSpec();
Specification<OrderSummary> createOhterSpec = createOtherSpec();

Specification<OrderSummary> spec = 
		nullable == null ? otherSpec : nullableSpec.and(otherSpec);
        
Specification<OrderSummary> spec = 
		Specification.where(createNullableSpec()).and(createOtherSpec());

 

또한 not() 메서드도 제공하고 정적 메서드로 조건을 반대로 적용할 때 사용한다. null 가능성이 있는 스펙 객체와 다른 스펙을 조합해야 할 때가 있다. null 여부를 판단해서 NullPointerException이 발생하는 것을 방지해야 하는데 null 여부를 매번 검사하려면 다소 귀찮다. 이때 where() 메서드를 사용하면 이런 귀찮음을 줄일 수 있다. 스펙 인터페이스의 정적 메서드로 null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고 null이 아니면 인자로 받은 스펙 객체를 그대로 리턴한다.

 

 

5.6 정렬 지정하기

스프링 데이터 JPA는 다음 2가지 방법으로 정렬을 지정할 수 있다.

1) 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정

2) Sort를 인자로 전달

 

특정 프로퍼티로 조회하는 find 메서드는 이름 뒤에 OrderBy를 사용해서 정렬 순서를 지정할 수 있다.

public interface OrderSummaryDao extends Repository<OrderSummary, String> {

	List<OrderSummary> findByOrdererIdOrderByNumberDesc(String orderId);
}

 

위 메서드는 다음 조회 쿼리를 생성한다.

1) ordererId 프로퍼티 값을 기준으로 검색 조건 지정

2) number 프로퍼티 값 역순으로 정렬

 

또한 2개 이상의 프로퍼티에 대한 정렬 순서를 지정할 수도 있다. OrderBy를 사용하는 방법은 간단하지만 메서드 이름이 길어지는 단점이 있다. 또한 메서드 이름으로 정렬 순서가 정해지기 때문에 상황에 따라 순서를 변경할 수도 없다. 이럴 때 Sort 타입을 사용하면 된다. 스프링 데이터 JPA는 정렬 순서를 지정할 때 사용할 수 있는 Sort 타입을 제공한다.

 

public interface OrderSummaryDao extends Repository<OrderSummary, String> {
	
    List<OrderSummary> findByOrdererId(String orderId, Sort sort);
    List<OrderSummary> findAll(Specification<OrderSummary> spec, Sort sort);
}

 

파리미터로 전달받은 Sort를 사용해서 알맞게 정렬 쿼리를 생성한다. find 메서드를 사용하는 코드는 알맞은 Sort 객체를 생성해서 전달하면 된다.

 

Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);

 

위 코드는 "number" 프로퍼티 기준 오름차순 정렬을 표현하는 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());

 

 

 

5.7 페이징 처리하기

목록을 보여줄 때 전체 데이터 중 일부만 보여주는 페이징 처리는 기본이다. 스프링 데이터 JPA는 페이징 처리를 위해 Pageable 타입을 이용한다. 

 

import org.springframework.data.domain.Pageable;

public interface MemberDataDao extends Repository<MemberData, String> {

	List<MemberData> findByNameLike(String name, Pageable pageable);
}

 

Pageable 타입은 인터페이스로 실제 Pageable 타입 객체는 PageRequest 클래스를 이용해서 생성한다. 다음 코드는 findByNameLike() 메서드를 호출하는 예를 보여준다.

 

import org.springframework.data.domain.PageRequest;

PageRequest pageReq = PageRequest.of(1, 10);
List<MemberData> user = memberDataDao.findByNameLike("사용자%", pageReq);

 

PageRequest.of() 메서드의 첫 번째 인자는 페이지 번호를, 두 번째 인자는 한 페이지의 개수를 의미한다. 페이지 번호는 0번부터 시작하므로 위 코드는 한 페이지에 10개씩 표시한다고 했을 때 두 번째 페이지를 조회한다. 즉 11번째부터 20번째까지 데이터를 조회한다. 또한 PageRequest와 Sort를 사용하면 정렬 순서를 지정할 수 있다.

 

Page 타입을 사용하면 데이터 목록뿐만 아니라 조건에 해당하는 전체 개수도 구할 수 있다. Pageable을 사용하는 메서드의 리턴 타입이 Page일 경우 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 개수를 구한다. Page는 전체 개수, 페이지 개수 등 페이징 처리에 필요한 데이터도 함께 제공한다. 또한 스펙을 사용하는 findAll() 메서드도 Pageable을 사용할 수 있다.

 

-> 프로퍼티를 비교하는 findBy 프로퍼티 형식의 메서드는 Pageable 타입을 사용하더라도 리턴 타입이 List면 COUNT 쿼리를 실행하지 않는다. 아래 두 메서드 중 두 번째 메서드는 COUNT 쿼리를 실행하지 않는다.

Page<MemberData> findByBlocked(boolean blocked, Pageable pageable);
List<MemberData> findByNameLike(String name, Pageable pageable);
List<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);

 

페이징 처리와 관련된 정보가 필요 없다면 Page 리턴 타입이 아닌 List를 사용해서 불필요한 COUNT 쿼리를 실행하지 않아도 된다. 반면 스펙을 사용하는 findAll 메서드에 Pageable 타입을 사용하면 리턴 타입이 Page가 아니어도 COUNT 쿼리를 실행한다. 

 

스펙을 사용하고 페이징 처리를 하면서 COUNT 쿼리는 실행하고 싶지 않다면 스프링 데이터 JPA가 제공하는 커스텀 리포지터리 기능을 이용해 직접 구현하면 된다.

 

처음부터 N개의 데이터가 필요하다면 Pageable을 사용하지 않고 findFirstN 형식의 메서드를 사용할 수 있다. 예로 name 프로퍼티 기준으로 like를 검색한 결과를 name 프로퍼티를 기준으로 오름차순으로 정렬해서 처음 3개를 조회한다.

List<MemberData> findFirst3ByNameLikeOrderByName(String name)

 

First 대신 Top을 사용해도 되고, First나 Top 뒤에 숫자가 없으면 한 개 결과만 리턴한다.

 

 

5.8 페이징 처리하기

스펙을 생성하다 보면 조건에 따라 스펙을 조합해야 할 때가 있다. 실수하기 좋고, 복잡한 구조를 갖는다. 이점을 보완하기 위해 스펙 필더를 만들어 사용할 수 있다. 메서드를 사용해서 조건을 표현하고 메서드 호출 체인으로 연속된 변수 할당을 줄여 코드 가독성을 높이고, 구조가 단순해지는 장점이 있다.

 

 

5.9 동적 인스턴스 생성

JPA는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다. new 키워드 뒤에 생성할 인스턴스의 완전한 클래스 이름을 지정하고, 괄호 안에 생성자에 인자로 전달할 값을 지정한다. OrderView 생성자에 인자로 각각 Order의 number, member 등 필요한 값을 전달한다. OrderView 생성자는 생성자로 전달받은 데이터를 저장한다.

 

조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다. 동적 인스턴스의 장점은 JPQL을 그대로 사용하므로 객체 기준으로 쿼리를 작성하면서도 동시에 지연/즉시 로딩과 같은 고민 없이 원하는 모습으로 데이터를 조회할 수 있다는 점이다.

 

 

5.10 하이버네이트 @Subselect 사용

하이버네이트는 JPA 확장 기능으로 @Subselect 를 제공한다. 이는 쿼리 결과를 @Entity로 매핑할 수 있는 유용한 기능이다. @Immutable, @Subselect, @Synchronize는 하이버네이트 전용 애너테이션인데, 이 태그를 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.

 

@Subselect는 조회 쿼리를 값으로 갖는다. 하이버네이트는 이 select 값의 쿼리의 결과를 매핑할 테이블처럼 사용한다. DBMS가 여러 테이블을 조인해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼 이를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다.

 

뷰를 수정할 수 없듯이 @Subselect로 조회한 @Entity 역시 수정할 수 없다. 실수로 @Entity의 매핑 필드를 수정하면 하이버네이트는 update 쿼리를 실행하는데, 매핑 한 테이블이 없으므로 에러가 발생한다. 이를 방지하기 위해 @Immutable을 사용한다.

 

만약 Order 상태를 변경한 뒤 OrderSummary를 조회한다 했을 때, 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 저장하므로, Order 변경 내역을 아직 purchase_order 테이블에 반영하지 않은 상태에서 purchase_order 테이블을 사용하는 OrderSummary를 조회한다. 이는 OrderSummary에는 최신 값이 아닌 이전 값을 담게 된다. 이를 해결하기 위해 @Synchronize이다.