Spring Data JPA의 몇가지 유용한 정보
공식문서 파헤치기
👏 Spring Data JPA
Spring Data JPA
를 사용하면 Spring Data 프로젝트
에서 제공하는 여러가지 유용한 추상화된 API를 사용할 수 있습니다.
특히 쿼리 메서드 기능이 아주 유용하고 강력한데요, 이 쿼리 메서드를 잘 사용하면 정말 대부분의 상황을 이것 하나로 해결할수 있을 정도입니다.
쿼리 메서드로 부족하다면 Example
이나 Querydsl
등의 사용을 고려해볼 수 있을 것 같습니다.
쿼리 메서드는 메서드이름을 통해 여러가지 쿼리를 생성해내는 기능입니다.
쿼리 메서드로 할 수 있는 대부분의 롤(ROLE)을 정리해두었으니 궁금하시다면 참고하셔도 좋을 것 같습니다.
이번 포스팅에서는 Spring Data JPA를
사용할 때 몇가지 유용한 팁을 정리하였습니다.
💡 반환 타입으로 Page
가 아닌 Slice
를 고려하기
Spring Data JPA
프로젝트를 사용하면 JpaRepository
나 CrudRepository
를 extends
하여 사용하는 경우가 많습니다.
이때 페이징을 하는 경우 무심코 반환타입에 Page
를 사용하는 경우가 많죠.
하지만 Slice
라는 타입으로도 반환받을 수 있습니다.
public interface ItemRepository extends JpaRepository<Item, Long> {
Slice<Item> readAllByNameContaining(String name, Pageable pageable);
Page<Item> findAllByNameContaining(String name, Pageable pageable);
}
시그니처를 살펴보면 Page
는 Slice
를 상속하여 몇가지의 메서드를 더 확장한 인터페이스입니다.
public interface Page<T> extends Slice<T> {
int getTotalPages();
long getTotalElements();
}
두 반환 타입에는 다음과 같은 명확한 차이가 있습니다.
Page
: 카운트 쿼리를 매번 발생시킵니다.- 단, 쿼리 메서드를 호출하며 인수로 넘긴
Pageable
의size
값보다 반환되는 레코드의 수가 적은 경우에는 카운트 쿼리가 발생하지 않습니다. - 이 기능은 쿼리한 페이지의 추가적인 상세한 정보들을 포함합니다.
- 단, 쿼리 메서드를 호출하며 인수로 넘긴
Slice
: 카운트 쿼리를 아예 발생시키지 않습니다.- 카운트 쿼리를 사용하지 않고 다음 페이지가 있음을 알 수 있는 원리는 다음과 같습니다.
- 쿼리 메서드를 호출하며 인수로 넘긴
Pageable
의size
값보다 +1만큼 더 추가로 조회하여 추가로 반환되는 레코드가 있는 경우에 다음 페이지가 있음을 판단합니다.
즉, 테이블의 레코드가 충분히 많다면 반환타입으로 Page
를 선언했을 경우 매번 발생하는 카운트 쿼리
가 부담이 될 수 있기 때문에, 이 경우에는 Slice
로의 반환을 고려하는게 좋습니다.
Page
는 Slice
와 다르게 가져온 페이지의 모든 상세한 정보들을 포함하므로 서로간의 trade-off
가 분명히 존재합니다.
두 반환타입의 차이를 확인하기 위해 간단한 테스트 코드를 작성했습니다.
@DataJpaTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@BeforeEach
void setUp() {
List<Item> items = new ArrayList<>();
for (int i = 1; i <= 50; i++) {
items.add(createItem(i));
}
itemRepository.saveAll(items);
}
// Returned Page<Item>
@Test
void findAllByNameContaining() throws Exception {
itemRepository.findAllByNameContaining("1", PageRequest.of(1, 3))
.forEach(System.out::println);
}
// Returned Slice<Item>
@Test
void readAllByNameContaining() throws Exception {
itemRepository.readAllByNameContaining("1", PageRequest.of(1, 3))
.forEach(System.out::println);
}
private Item createItem(int itemName) {
return Item.builder()
.name("item" + itemName)
.description("item description")
.createdAt(LocalDateTime.now())
.build();
}
}
itemName
이 item1 ~ item50 인 아이템 50개를 저장한 후 itemName
에 문자열 1이 포함된 아이템들을 쿼리하고, 3개 묶음으로 분할한 후 그 중 1페이지를 가져오도록 하는 쿼리입니다.
1부터 50 사이에 문자열 1이 포함된 수는 분명히 4개 이상이므로 위 설명대로라면 findAllByNameContaining
는 카운트 쿼리가 발생할 것이며, readAllByNameContaining
는 카운트 쿼리가 발생하지 않을 것입니다.
- findAllByNameContaining 결과 (카운트 쿼리 발생)
Hibernate:
select
item0_.id as id1_4_,
item0_.created_at as created_2_4_,
item0_.description as descript3_4_,
item0_.name as name4_4_
from
item item0_
where
item0_.name like ? escape ? limit ? offset ?
Hibernate:
select // 카운트 쿼리 발생
count(item0_.id) as col_0_0_
from
item item0_
where
item0_.name like ? escape ?
Item(id=12, name=item12, description=item description, createdAt=2022-01-11T19:06:25.863552900)
Item(id=13, name=item13, description=item description, createdAt=2022-01-11T19:06:25.863552900)
Item(id=14, name=item14, description=item description, createdAt=2022-01-11T19:06:25.863552900)
- readAllByNameContaining 결과 (카운트 쿼리 발생하지 않음)
Hibernate:
select
item0_.id as id1_4_,
item0_.created_at as created_2_4_,
item0_.description as descript3_4_,
item0_.name as name4_4_
from
item item0_
where
item0_.name like ? escape ? limit ? offset ?
Item(id=12, name=item12, description=item description, createdAt=2022-01-11T19:10:14.159593600)
Item(id=13, name=item13, description=item description, createdAt=2022-01-11T19:10:14.159593600)
Item(id=14, name=item14, description=item description, createdAt=2022-01-11T19:10:14.159593600)
The first method lets you pass an org.springframework.data.domain.Pageable instance to the query method to dynamically add paging to your statically defined query. A Page knows about the total number of elements and pages available. It does so by the infrastructure triggering a count query to calculate the overall number. As this might be expensive (depending on the store used), you can instead return a Slice. A Slice knows only about whether a next Slice is available, which might be sufficient when walking through a larger result set.
💡 쿼리 메서드
로도 프로젝션이 가능하다
Spring Data JPA
의 쿼리 메서드
로는 프로젝션이 되지 않기 때문에 Querydsl
을 사용해야 한다는 분들이 간혹 계십니다.
📜 Spring Data JPA의 공식문서 를 살펴보시면 분명히 DTO 프로젝션을 지원하고 있음을 알 수 있으며, 훌륭한 DTO 프로젝션 기능에 대해 놀라실지도 모릅니다.
추가로 이 포스팅에서는 List나 Set만을 제한적으로 사용했으나, java.util.Collection 의 하위 타입이나 알맞은 생성자가 있는 단일 클래스는 대부분 제한없이 사용가능합니다.
생성자 프로젝션
Item
엔티티는 다음과 같은 필드를 갖습니다.
@Entity
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
@CreatedDate
private LocalDateTime createdAt;
}
이중 name
과 createdAt
필드만 쏙 뽑아오고 싶다면 다음과 같이 두 필드를 포함하는 DTO
를 작성합니다.
이 때 주의해야 할것은, 추출하고자 하는 필드에 대한 생성자가 반드시 선언돼있어야 한다는 것입니다.
@ToString
@AllArgsConstructor
public class ItemDto {
private String name;
private LocalDateTime createdAt;
}
이제 다음과 같은 시그니처를 갖는 추상 메서드를 선언합니다.
반환타입이 Item
이 아닌 ItemDto
임을 놓치지 마세요.
public interface ItemRepository extends JpaRepository<Item, Long> {
List<ItemDto> findByNameContaining(String name);
}
아주 간단한 테스트 코드를 작성하고 어떤 결과가 발생하는지 살펴보겠습니다.
itemName
에 문자열 1이 포함돼있는 아이템들의 name
과 createdAt
만 추출하는 코드입니다.
@DataJpaTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@BeforeEach
void setUp() {
List<Item> items = new ArrayList<>();
for (int i = 1; i <= 50; i++) {
items.add(createItem(i));
}
itemRepository.saveAll(items);
}
@Test
void findByNameContaining() throws Exception {
itemRepository.findByNameContaining("1")
.forEach(System.out::println);
}
private Item createItem(int itemName) {
return Item.builder()
.name("item" + itemName)
.description("item description")
.createdAt(LocalDateTime.now())
.build();
}
}
Hibernate:
select
item0_.name as col_0_0_,
item0_.created_at as col_1_0_
from
item item0_
where
item0_.name like ? escape ?
ItemDto(name=item1, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item10, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item11, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item12, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item13, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item14, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item15, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item16, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item17, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item18, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item19, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item21, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item31, createdAt=2022-01-11T19:00:29.901013)
ItemDto(name=item41, createdAt=2022-01-11T19:00:29.901013)
프로젝션이 아주 잘 되는 모습을 보실 수 있습니다.
인터페이스 프로젝션
인터페이스 프로젝션은 객체 참조관계마저도 프로젝션할 수 있습니다.
심지어는 SpEL
로 제공되는 target
변수를 이용해 더욱 디테일한 프로젝션이 가능하지만, 이 경우 언더바(_)
를 사용하여 코딩 컨벤션이 깨지는 약간의 문제가 있어 저는 선호하지는 않습니다.
하지만 기본적인 인터페이스 프로젝션으로도 충분히 강력하기 때문에 알아두시면 좋을 것 같습니다.
우선 Item을 참조하는 간단한 CartItem 엔티티를 작성했습니다.
이때 단방향 OneToMany
에 cascade
를 사용하였는데, 이렇게 하시면 외래키(FK) 지정을 위해 매번 추가적인 update 쿼리가 발생하므로 문제가 있는 코드입니다.
즉, 이것은 단순히 테스트를 조금 더 편하게 하고자함이므로 실제 업무에서 사용하시는 것은 추천드리지 않습니다.
이 경우에는 OneToMany
양방향이나 ManyToMany
사용을 권장드립니다.
@Entity
@Getter
@ToString
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CartItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(cascade = CascadeType.ALL)
private Set<Item> items = new HashSet<>();
private CartItem(Long id, Set<Item> items) {
this.id = id;
this.items = items;
}
public static CartItem of(Long id, Set<Item> items) {
return new CartItem(id, items);
}
public void addItem(Item item) {
items.add(item);
}
public boolean isTenBundles() {
return items.size() == 10;
}
}
이제 CartItem
을 프로젝션하는데 사용할 인터페이스를 정의합니다.
이때 CartItem
이 참조하는 Set<Item>
도 함께 가져올 것입니다.
public interface CartItemProjection {
Long getId();
List<ItemDto> getItems();
interface ItemDto {
Long getId();
String getName();
String getDescription();
}
}
그리고 다음과 같은 리파지토리를 작성합니다.
쿼리 메서드를 호출하며 인수로 넘긴 id
보다 큰 id
를 갖는 CarItem
들을 프로젝션합니다.
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
List<CartItemProjection> findByIdAfter(Long id);
}
마지막으로 결과 확인을 위한 간단한 테스트를 작성했습니다.
총 100개의 Item을 10개씩 분할해 각각 10개의 CartItem에 넣고 저장했습니다.
이후 id가 5보다 큰 CartItem들을 조회하여 DTO로 프로젝션합니다.
@DataJpaTest
class CartItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Autowired
CartItemRepository cartItemRepository;
@BeforeEach
void setUp() {
CartItem cartItem = null;
for (int i = 1; i <= 100; i++) {
if (cartItem == null) {
cartItem = CartItem.of(null, new HashSet<>());
}
if (cartItem.isTenBundles()) {
cartItemRepository.save(cartItem);
cartItem = null;
}
else {
cartItem.addItem(createItem(i));
}
}
}
@Test
void findByIdAfter_interface() throws Exception {
cartItemRepository.findByIdAfter(5L).forEach(System.out::println);
}
private Item createItem(int itemName) {
return Item.builder()
.name("item" + itemName)
.description("item description")
.createdAt(LocalDateTime.now())
.build();
}
}
Hibernate:
select
cartitem0_.id as id1_1_
from
cart_item cartitem0_
where
cartitem0_.id>?
CartItem(id=6, items=[Item(id=51, name=item60, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=52, name=item65, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=53, name=item63, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=54, name=item56, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=55, name=item57, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=56, name=item58, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=57, name=item64, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=58, name=item59, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=59, name=item62, description=item description, createdAt=2022-01-11T19:21:00.009321), Item(id=60, name=item61, description=item description, createdAt=2022-01-11T19:21:00.009321)])
CartItem(id=7, items=[Item(id=61, name=item67, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=62, name=item76, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=63, name=item71, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=64, name=item74, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=65, name=item75, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=66, name=item69, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=67, name=item68, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=68, name=item73, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=69, name=item72, description=item description, createdAt=2022-01-11T19:21:00.016323600), Item(id=70, name=item70, description=item description, createdAt=2022-01-11T19:21:00.016323600)])
CartItem(id=8, items=[Item(id=71, name=item82, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=72, name=item86, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=73, name=item83, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=74, name=item85, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=75, name=item80, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=76, name=item81, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=77, name=item87, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=78, name=item84, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=79, name=item79, description=item description, createdAt=2022-01-11T19:21:00.026320800), Item(id=80, name=item78, description=item description, createdAt=2022-01-11T19:21:00.026320800)])
CartItem(id=9, items=[Item(id=81, name=item89, description=item description, createdAt=2022-01-11T19:21:00.037323800), Item(id=82, name=item91, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=83, name=item97, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=84, name=item93, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=85, name=item90, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=86, name=item95, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=87, name=item94, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=88, name=item96, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=89, name=item98, description=item description, createdAt=2022-01-11T19:21:00.038323100), Item(id=90, name=item92, description=item description, createdAt=2022-01-11T19:21:00.038323100)])
큰 부담 없는 깔끔한 쿼리가 발생하였고, 프로젝션이 아주 잘 되는것을 확인할 수 있습니다.
제네릭을 이용한 동적 프로젝션(Dynamic Projection)
이것은 그냥 반환타입을 제네릭으로 설정해버리는 방법입니다.
제네릭을 사용 해 다음과 같은 추상 메서드를 작성합니다.
두번째 파라미터는 반환할 타입인데, 이것은 java.util.Collection
의 하위타입이거나 알맞은 생성자가 있는 단일 클래스여야 합니다.
저는 위에서 사용한 인터페이스 프로젝션
의 코드를 그대로 재사용하겠습니다.
public interface CartItemRepository extends JpaRepository<CartItem, Long> {
<T> List<T> findByIdAfter(Long id, Class<T> classType);
}
역시 위에서 사용한 테스트코드를 재사용하였습니다.
전체적인 코드는 인터페이스 프로젝션과 큰 차이가 없으나, 쿼리 메서드를 호출하는 부분에서 두번째 인수로 반환타입을 CartItemProjection.class
로 넘겨 쿼리 메서드를 호출합니다.
@DataJpaTest
class CartItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Autowired
CartItemRepository cartItemRepository;
@BeforeEach
void setUp() {
CartItem cartItem = null;
for (int i = 1; i <= 100; i++) {
if (cartItem == null) {
cartItem = CartItem.of(null, new HashSet<>());
}
if (cartItem.isTenBundles()) {
cartItemRepository.save(cartItem);
cartItem = null;
}
else {
cartItem.addItem(createItem(i));
}
}
}
@Test
void findByIdAfter_dynamic() throws Exception {
cartItemRepository.findByIdAfter(5L, CartItemProjection.class)
.forEach(System.out::println);
}
private Item createItem(int itemName) {
return Item.builder()
.name("item" + itemName)
.description("item description")
.createdAt(LocalDateTime.now())
.build();
}
}
Hibernate:
select
cartitem0_.id as id1_1_
from
cart_item cartitem0_
where
cartitem0_.id>?
CartItem(id=6, items=[Item(id=51, name=item60, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=52, name=item65, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=53, name=item56, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=54, name=item63, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=55, name=item57, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=56, name=item59, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=57, name=item61, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=58, name=item62, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=59, name=item64, description=item description, createdAt=2022-01-11T19:27:54.715796700), Item(id=60, name=item58, description=item description, createdAt=2022-01-11T19:27:54.715796700)])
CartItem(id=7, items=[Item(id=61, name=item67, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=62, name=item72, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=63, name=item73, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=64, name=item68, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=65, name=item71, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=66, name=item70, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=67, name=item76, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=68, name=item74, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=69, name=item69, description=item description, createdAt=2022-01-11T19:27:54.722796), Item(id=70, name=item75, description=item description, createdAt=2022-01-11T19:27:54.722796)])
CartItem(id=8, items=[Item(id=71, name=item79, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=72, name=item82, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=73, name=item80, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=74, name=item81, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=75, name=item83, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=76, name=item78, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=77, name=item84, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=78, name=item86, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=79, name=item85, description=item description, createdAt=2022-01-11T19:27:54.729795600), Item(id=80, name=item87, description=item description, createdAt=2022-01-11T19:27:54.729795600)])
CartItem(id=9, items=[Item(id=81, name=item93, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=82, name=item97, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=83, name=item98, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=84, name=item89, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=85, name=item90, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=86, name=item96, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=87, name=item91, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=88, name=item94, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=89, name=item92, description=item description, createdAt=2022-01-11T19:27:54.736794200), Item(id=90, name=item95, description=item description, createdAt=2022-01-11T19:27:54.736794200)])
역시 아주 깔끔하게 잘 되는 모습을 보실 수 있습니다.
💡 Query By Example API
쿼리 메서드는 매우 강력하지만 치명적인 단점이 하나 있습니다.
바로 조건이 조금만 복잡해지면 메서드이름의 길이가 사용할수 없을정도로 길어진다는 것입니다.
id가 일치하면서, name을 포함하고(like 검색), description도 포함하는(like 검색) 조건을 쿼리 메서드로 작성하면 하기와 같습니다.
너무 길죠?
public interface ItemRepository extends JpaRepository<Item, Long> {
List<Item> findByIdAndNameContainingAndDescriptionContaining(Long id, String name, String description);
}
@DataJpaTest
class ItemRepositoryTest {
@Test
void queryMethodLimit() throws Exception {
itemRepository.findByIdAndNameContainingAndDescriptionContaining(1L, "item", "desc") // 너무 장황하다... 🤔
.forEach(System.out::println);
}
}
Hibernate:
select
item0_.id as id1_4_,
item0_.created_at as created_2_4_,
item0_.description as descript3_4_,
item0_.name as name4_4_
from
item item0_
where
item0_.id=?
and (
item0_.name like ? escape ?
)
and (
item0_.description like ? escape ?
)
이러한 문제를 보완하는게 Example API
입니다.
Example API
는 크게 세 부분으로 구성됩니다.
- Probe: 검색을 수행하기 위한 조건을 담은 실제 엔티티 개체
- ExampleMatcher: 세부적인 검색을 설정하는 개체
- Example
: Probe와 ExampleMatcher로 이루어진 최종 개체
말로만 떠들면 이해하기 힘들 수 있으니 장황한 findByIdAndNameContainingAndDescriptionContaining
를 Example API
를 사용해 리팩토링 해보겠습니다.
우선 QueryByExampleExecutor<T>
인터페이스를 추가로 상속해야 합니다.
여기서 T는 검색을 실행할 주체가되는 엔티티입니다.
public interface ItemRepository extends JpaRepository<Item, Long>, QueryByExampleExecutor<Item> {
}
@DataJpaTest
class ItemRepositoryTest {
@Autowired
ItemRepository itemRepository;
@Test
void queryMethodRefactor() throws Exception {
Item probe = Item.of(1L, "item", "desc", null);
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnorePaths("createdAt")
.withStringMatcher(ExampleMatcher.StringMatcher.CONTAINING);
Example<Item> example = Example.of(probe, matcher);
itemRepository.findAll(example).forEach(System.out::println);
}
}
Hibernate:
select
item0_.id as id1_4_,
item0_.created_at as created_2_4_,
item0_.description as descript3_4_,
item0_.name as name4_4_
from
item item0_
where
item0_.id=?
and (
item0_.name like ? escape ?
)
and (
item0_.description like ? escape ?
)
정확히 동일한 쿼리가 발생합니다.
이게 가능한 이유는 QueryByExampleExecutor<T>
인터페이스의 추상 메서드 시그니처를 확인해보면 쉽게 알 수 있습니다.
public interface QueryByExampleExecutor<T> {
<S extends T> Optional<S> findOne(Example<S> example);
<S extends T> Iterable<S> findAll(Example<S> example);
<S extends T> Iterable<S> findAll(Example<S> example, Sort sort);
<S extends T> Page<S> findAll(Example<S> example, Pageable pageable);
<S extends T> long count(Example<S> example);
<S extends T> boolean exists(Example<S> example);
}
Example
타입을 인자로 받아 검색을 수행하고 이에 대한 결과를 반환하고 있음을 확인할 수 있죠.
어떻게 보면 쿼리 메서드를 사용하는것 이상으로 코드를 많이 작성하긴 합니다만, 검색 조건이 복잡하고 유동적으로 변경되어야 하는 상황이라면 분명히 사용해볼 가치가 있습니다.
우선, 검색 조건을 매우 디테일하게 설정할 수 있으며, 메서드명이 아닌 메서드 자체로 작성되기 때문에 별도의 리파지토리에 캡슐화하여 관리하기도 용이합니다.
검색조건이 변경된다면 ExampleMatcher
만 손봐주면 되기 때문입니다.
아래는 공식 문서에서 소개하는 Example API
의 장단점입니다.
Query by Example
은 다음 사례에 적합합니다.- 일련의 정적 또는 동적 제약 조건을 사용하여 데이터 저장소를 쿼리합니다.
- 기존 쿼리 중단에 대한 걱정 없이 도메인 객체를 자주 리팩토링합니다.
- 기본 데이터 저장소 API와 독립적으로 작업합니다.
Query by Example
에도 몇 가지 제약 사항이 있습니다.- 다음과 같이 중첩되거나 그룹화된 속성 제약 조건을 지원하지 않습니다.
firstname = ?0 or (firstname = ?1 and lastname = ?2).
- 문자열에 대한 starts(시작부 일치)/contains(문자열 포함)/ends(끝부분만 일치)/regex(정규식) 일치 및 기타 속성 유형에 대한 정확한 일치만 지원합니다.
- 다음과 같이 중첩되거나 그룹화된 속성 제약 조건을 지원하지 않습니다.
더 상세한 사용법에 대한 내용은 📜 Spring Data JPA Docs - Query by Example 에 기술되어 있으니 관심이 있으시다면 한번 살펴보시는것도 좋겠습니다.
또한, 쿼리 메서드
와 Example API
로도 해결이 안된다면 진지하게 Querydsl
사용을 고려해봐야 할 것 같습니다.