검색 기능 - queryDsl적용

ttomy·2023년 9월 17일
0

querydsl의 사용 이유

우리 요즘카페 프로젝트에 검색 기능을 넣으려 한다. 이때 querydsl을 도입하기로 했다.
도입한 이유는 아래와 같다.

  • 검색을 위해 여러 분기문과 비슷한 쿼리문이 존재하게 되는 것을 없앨 수 있다
  • QType을 이용해 컴파일 시점부터 쿼리의 type-safe체크를 할 수 있다.

적용하기

요즘카페에서 검색 시 고려할 도메인은 카페,카페 이미지, 메뉴이다.

Menu:Cafe다대일 관계로 단방향 참조이고,
Cafe:Images는 Images를 @Embedded로 가지며, Images안에는 이미지url String이 @oneToMany로 일대다 매핑된 단방향 참조이다.
테이블로는 image테이블이 존재하며 cafe_id를 참조키로 여러 개의 urls를 저장한다.

검색어가 카페명/ 카페 메뉴/ 카페 주소에 포함된 카페를 찾아 해당 카페의 정보와 이미지를 반환해야 한다.

동적 where조건 - Boolean Expression

카페이름, 카페 주소를 통해 카페를 검색할 수 있게 해보자.
QuerydslRepositorySupport을 extends해서 사용했다.

    public List<Cafe> findAllBy(final String cafeNameWord, final String menuWord, final String addressWord) {
        return from(cafe)
                .where(
                        contains(cafe.name, cafeNameWord),
                        contains(cafe.address, addressWord))
                .fetch();
    }
    
    private BooleanExpression contains(final StringPath target, final String string) {
        if (isBlank(string)) {
            return null;
        }

        return target.containsIgnoreCase(string);
    }

querydsl의 where은 null이면 넘어가고, null이 아니라면 and조건으로 추가되는 식으로
구현이 되었기에 동적 쿼리 조건을 가동성 있게 작성할 수 있다.

where절의 조건이 복잡하다면 BooleanBuilder를 사용할 수도 있다. 아래 공식문서에 사용법을 설명한다.
http://querydsl.com/static/querydsl/latest/reference/html/ch03.html
하지만 builder를 통해 조건을 조합함으로써 where절을 한 눈에 보기 어려워질 수 있다. 실행될 쿼리를 파악하기 더 어렵게 되는 건 아닌지 생각하고 사용해야 한다.

join하기

참조가 없는 엔티티 join하기

cafe 엔티티는 menu를 참조하고 있지 않다. 때문에 우리는 menu쪽에서 cafe를 참조하는 걸 이용해 메뉴이름을 검사해 cafe를 조회해야한다. querydsl에서 이 경우 어떻게 join할까?

           from(cafe)
                .leftJoin(menu).on(menu.cafe.eq(cafe))
                .where(
                        contains(menu.name, menuWord),
                        contains(cafe.name, cafeNameWord),
                        contains(cafe.address, addressWord))
                .fetch();

이런 식으로 hibernate 5.1버전부터 참조가 없는 엔티티도 join이 가능하다.
https://in.relation.to/2016/02/10/hibernate-orm-510-final-release/
https://www.baeldung.com/jpa-query-unrelated-entities
이게 querydsl버전 몇부터 적용되었는지는 잘 모르겠다...

상황마다 다르게 join 하는 것 가능한가요?

cafe를 찾을 때 카페명과 주소는 엔티티에 포함되어 있기에 join할 필요가 없다.
또 menu검색어 외에 다른 검색어를 or조건으로 검색한다면, 교집합이 아닌 데이터도 조회해야 하기에 outer join해야 한다.
이때 menu검색어로만 검색할 때에는 inner join을 해도 된다.
이렇게 상황에 따라 join을 다르게 할 수는 없을까?

아쉽에도 querydsl은 join의 조건은 동적으로 작성 가능하나, 다른join을 하는 것 까지는 아직 지원하지는 않는다. 현 상황에서는 아래 글처럼 메서드 분리를 하거나, 분기문을 통해서 다르게 join을 할 수 밖에 없겠다.
https://stackoverflow.com/questions/73017612/can-i-use-dynamic-join-in-querydsl

때문에 우리 서비스에서도 menu검색어의 존재여부에 따라 메서드 분리를 했다.

일대다 관계 join을 어떻게 처리할까?

cafe에는 Images가 Embedded엔티티로 존재한다. 그리고 이 Images에는 url이 oneToMany로 존재한다. 즉 간접적으로 cafe에 image url이 일대다로 연관된다.

이 images를 어떻게 해야 N+1문제 없이 효율적으로 가져올 수 있을까? 2가지 정도의 방법이 있다.

    1. cafe조회 시 images까지 fetch join하기
    1. images의 urls에 lazy로딩을 걸어놓고 images에 접근시 fetch join하기

images fetch join

1번 방법대로 cafe와 images를 한번에 fetch join하면 아래처럼 구현할 수 있다.

public List<Cafe> findAllBy(final String cafeNameWord, final String menuWord, final String addressWord) {
        return from(cafe)
                .leftJoin(menu).on(menu.cafe.eq(cafe))
                .innerJoin(cafe.images.urls).fetchJoin()
                .where(
                        contains(menu.name, menuWord),
                        contains(cafe.name, cafeNameWord),
                        contains(cafe.address, addressWord))
                .fetch();
    }

아래와 같은 쿼리가 나간다.

이 경우 카타시안 곱이 일어나기에 쿼리 1번에 images까지 가져온다 하더라도 효율적이라고 보장할 수는 없다. 한 카페 당 메뉴 개수 * 카페 이미지 개수로 많은 수의 데이터가 가져와진다.

images를 lazy로딩 시 batch size통해 한번에 조회

2번 방법으로 batch_size 설정을 통해 lazy로딩 시 image urls를 한 번에 여러개 조회해오도록 할 수 있다. 이렇게 n+1을 해결하는 법은 우리 팀원 오션이 잘 정리해주었다.
요즘카페 N+1해결
batch size를 이용한 방법도 단점이 존재한다. 어쨋든 쿼리 한번이 더 나가는 것이기에 어떤 방법이 효율적일지 생각해봐야 한다.

2가지의 방식 중 고민한 결과 서비스의 특성 상 2번의 방법을 선택했다. 이미지 개수가 늘어날수록 불필요하게 조회되는 레코드까지 늘어나는 건 이미지 중심적인 서비스에서는 치명적이라 생각했다. 1번의 쿼리가 더 날아가더라도 이미지 개수에 영향을 적게 받도록 lazy로딩 시 where-in절로 조회하는 게 낫다고 판단했다.

join말고 서브 쿼리로 대체할 수 있지 않나요?

public List<Cafe> findAllBy(final String cafeName, final String menu, final String address) {
        return from(cafe)
                .where(
                        containsMenu(menu),
                        contains(cafe.name, cafeName),
                        contains(cafe.address, address))
                .fetch();
    }

    private BooleanExpression containsMenu(final String searchWord) {
        if (isBlank(searchWord)) {
            return null;
        }

        return cafe.id.in(
                select(menu.cafe.id)
                        .from(menu)
                        .where(menu.name.containsIgnoreCase(searchWord))
        );
    }

이런 식으로 서브 쿼리 사용으로 대체할 수도 있다.
'이러면 join을 하지 않으니 성능도 괜찮지 않을까?' 하는 생각도 든다.
쿼리는 아래와 같이 실행된다.

서브쿼리의 사용은 단점이 있다. 서브 쿼리는 실체적인 결과를 저장하지 않고, 쿼리에 대한 메타 데이터도 없다. 때문에 db엔진의 최적화를 받기 어렵고 서브 쿼리 접근 시마다 select구문이 실행된다. 이러한 단점으로 서브쿼리를 사용하지 않기로 했다.

join시 distinct를 해야할까?

jpql에서는 oneToMany관계로 매핑된 엔티티를 fetch join한다면 중복된 엔티티를 조회해올 수도 있다. 꼭 fetch join이 아니더라도 join문이면 중복조회의 위험성이 있다.

그러면 쿼리에 distinct를 붙이면 해결될까? 아니다.
심지어 querydsl의 distinct()메서드로 쿼리에 distict문을 포함시켜보았자
엔티티의 중복을 제거해주지 못하면서 불필요한 작업을 유발한다. sql단에서 포함되는 distinct문은 필드 데이터들의 조합 상 유니크 한 걸 제거해주는 방식이기에 카타시안 곱 같은 현상의 결과는 각각 유니크 하므로 제거해주지 못한다.

그러면 어떻게 중복된 엔티티가 제거되어 조회되는 걸까?
hibernate 6부터는 distinct처리가 자동으로 하며 조회하기에 직접 작성할 필요가 없다. 때문에 querydsl에서도 직접 distinct를 써줄 필요가 없고 쓰면 오히려 불필요한 추가 작업을 시키게 된다.
엔티티 중복은 hibernate에 의해 자동으로 어플리케이션 단에서 제거된다.

참고: https://docs.jboss.org/hibernate/orm/6.0/migration-guide/migration-guide.html#query-sqm-distinct
https://www.baeldung.com/java-hql-distinct

fetch join시의 on,where조건 사용해도 될까?

이번 검색 기능 구현에는 fetch join하며 조건을 걸 상황이 없었지만 querydsl fetch join 사용 시 조심해야 하기에 알아두어야 한다.

우선 jpql에서 fetch join과 on절은 같이 사용할 될 수 없게 설계되었다. jpql에서부터 (with-clause not allowed on fetched association)에러가 발생한다.

이유는 fetch join하는 대상에 조건을 걸면 데이터의 일관성이 깨질 수 있기 때문이다.
fetch join대상에 조건문을 통해 컬렉션의 일부만 조회된 후, 이 영속성 컨텍스트가 flush된다면 조회되지 않은 컬렉션 데이터는 삭제될 수 있다.
그렇다고 fetch join하는 대상이 아닌 엔티티에 조건을 걸거면 on이 아닌 where절은 사용하는 게 맞기에 jpql fetch join에 on절은 허용되지 않는다.

그러면 fetch join할 때 where조건을 걸며 조회하는 건 가능한가?
실행은 가능하지만 oneToMany인 엔티티를 fetch join하며 join대상에 대해 조건을 건다면 컬렉션의 일부만 가져오게 되고, 이게 commit된다면 db에서 데이터가 삭제될 수 있기에 사용하지 않는 게 좋다.

꼭 fetch join하며 조건을 걸어야 하겠다면
엔티티와 db의 일관성을 고려하지 않아도 되도록 Dto로 조회해 해결할 수 있다.
사실 dto로 필요한 필드만 조회하는 게 추가적인 N+1문제를 방지할 수도 있기에 바람직하다.

https://www.inflearn.com/questions/15876/fetch-join-%EC%8B%9C-%EB%B3%84%EC%B9%AD%EA%B4%80%EB%A0%A8-%EC%A7%88%EB%AC%B8%EC%9E%85%EB%8B%88%EB%8B%A4

https://www.inflearn.com/questions/59632/fetch-join-%EA%B4%80%EB%A0%A8-%EC%A7%88%EB%AC%B8-%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

요약

  • querydsl을 통해 분기문,비슷한 쿼리들의 제거와 type-safe한 쿼리 작성이 가능하다.

  • querydsl로 fetch join도 가능하지만 상황에 따라 batch_size를 이용하거나 서브 쿼리 사용, 혹은 아예 다른 쿼리로 분리해 조회하는 방법등을 비교해 결정하자.

  • hinernate6부터는 자동으로 distinct처리를 해주니 중복 엔티티 제거를 직접 고려하지 않아도 된다.

  • querydsl에서 fetch join할 때에는 on절은 불가능하고, where조건 거는 것을 매우 유의해야 한다. 필요한 경우 dto로 조회하는 게 좋다.

Reference

http://querydsl.com/static/querydsl/latest/reference/html/
https://www.baeldung.com/intro-to-querydsl
https://www.baeldung.com/querydsl-with-jpa-tutorial
https://jojoldu.tistory.com/
https://stackoverflow.com/questions/tagged/querydsl?tab=frequent
https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl
https://www.inflearn.com/questions/15876/fetch-join-%EC%8B%9C-%EB%B3%84%EC%B9%AD%EA%B4%80%EB%A0%A8-%EC%A7%88%EB%AC%B8%EC%9E%85%EB%8B%88%EB%8B%A4

0개의 댓글