Spring Data JPA의 몇가지 유용한 정보

Spring Data JPA의 몇가지 유용한 정보

공식문서 파헤치기


👏 Spring Data JPA


Spring Data JPA를 사용하면 Spring Data 프로젝트에서 제공하는 여러가지 유용한 추상화된 API를 사용할 수 있습니다.

특히 쿼리 메서드 기능이 아주 유용하고 강력한데요, 이 쿼리 메서드를 잘 사용하면 정말 대부분의 상황을 이것 하나로 해결할수 있을 정도입니다.

쿼리 메서드로 부족하다면 Example이나 Querydsl등의 사용을 고려해볼 수 있을 것 같습니다.


쿼리 메서드는 메서드이름을 통해 여러가지 쿼리를 생성해내는 기능입니다.

쿼리 메서드로 할 수 있는 대부분의 롤(ROLE)을 정리해두었으니 궁금하시다면 참고하셔도 좋을 것 같습니다.


이번 포스팅에서는 Spring Data JPA를 사용할 때 몇가지 유용한 팁을 정리하였습니다.


💡 반환 타입으로 Page가 아닌 Slice를 고려하기


Spring Data JPA 프로젝트를 사용하면 JpaRepositoryCrudRepositoryextends하여 사용하는 경우가 많습니다.

이때 페이징을 하는 경우 무심코 반환타입에 Page를 사용하는 경우가 많죠.

하지만 Slice라는 타입으로도 반환받을 수 있습니다.


public interface ItemRepository extends JpaRepository<Item, Long> {
    Slice<Item> readAllByNameContaining(String name, Pageable pageable);

    Page<Item> findAllByNameContaining(String name, Pageable pageable);
}


시그니처를 살펴보면 PageSlice를 상속하여 몇가지의 메서드를 더 확장한 인터페이스입니다.


public interface Page<T> extends Slice<T> {
    int getTotalPages();
    
    long getTotalElements();
}


두 반환 타입에는 다음과 같은 명확한 차이가 있습니다.


  • Page: 카운트 쿼리를 매번 발생시킵니다.
    • 단, 쿼리 메서드를 호출하며 인수로 넘긴 Pageablesize값보다 반환되는 레코드의 수가 적은 경우에는 카운트 쿼리가 발생하지 않습니다.
    • 이 기능은 쿼리한 페이지의 추가적인 상세한 정보들을 포함합니다.
  • Slice: 카운트 쿼리를 아예 발생시키지 않습니다.
    • 카운트 쿼리를 사용하지 않고 다음 페이지가 있음을 알 수 있는 원리는 다음과 같습니다.
    • 쿼리 메서드를 호출하며 인수로 넘긴 Pageablesize값보다 +1만큼 더 추가로 조회하여 추가로 반환되는 레코드가 있는 경우에 다음 페이지가 있음을 판단합니다.


즉, 테이블의 레코드가 충분히 많다면 반환타입으로 Page를 선언했을 경우 매번 발생하는 카운트 쿼리가 부담이 될 수 있기 때문에, 이 경우에는 Slice로의 반환을 고려하는게 좋습니다.

PageSlice와 다르게 가져온 페이지의 모든 상세한 정보들을 포함하므로 서로간의 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)


📜 Spring Data JPA Docs

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;
}


이중 namecreatedAt 필드만 쏙 뽑아오고 싶다면 다음과 같이 두 필드를 포함하는 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이 포함돼있는 아이템들의 namecreatedAt만 추출하는 코드입니다.


@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 엔티티를 작성했습니다.

이때 단방향 OneToManycascade를 사용하였는데, 이렇게 하시면 외래키(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로 이루어진 최종 개체


말로만 떠들면 이해하기 힘들 수 있으니 장황한 findByIdAndNameContainingAndDescriptionContainingExample 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 사용을 고려해봐야 할 것 같습니다.



© 2022. All rights reserved.