개요

검색해봤을 때 나오는 결과나 옛날 예제들에 익숙해져있었고 배우는 것을 소홀히 한 결과 여러 실수를 했다.

이번 기회에 체계적으로 정리해보자.

QueryDSL?

QueryDSL은 Query + DSL의 합성어로 DSL이란 어떤 도메인의 문제를 해결하는데 특화된 언어를 말한다.

순수 Spring JPA를 사용하며 겪는 문제는 무엇일까?

메소드 이름이 쿼리가 되는 쿼리 메소드를 사용할 경우 메소드 이름을 커스텀할 수 없기 때문에 조건이 많아질 경우 굉장히 길고 직관적이지 않은 메소드가 탄생한다.

그래서 JPQL을 이용하여 직접 쿼리를 작성해보면 이번엔 메소드 이름을 커스텀할 수 있지만 쿼리문이 Sring타입으로 들어간다.

따라서 만약 쿼리문이 굉장히 많고 꼼꼼히 테스트하지 않았다면 오류가 나도 실제 메소드를 호출하는 시점에 알 수 있다.

좀 과장하면 코딩을 하는데 메모장에 코드를 입력하고 실제 동작여부는 실행을 해봐야 알 수 있는 것과 비슷한 상황이다.

또한 쿼리가 고정되어있기 때문에 여러 복합적인 조건이 생긴다면 이 경우의 수만큼 메소드를 작성해야한다는 한계가 있다.

이런 문제를 방지하기 위해 Query를 타입 세이프하게 작성할 수 있는 방법이 바로 QueryDSL이다.
이래서 DSL이라는 이름이 붙은 것이다.

Querydsl은 기본적으로 JPAQueryFactory만 있다면 사용할 수 있다.

일반적으론 ...RepositoryCustom이라는 이름의 인터페이스를 선언한 뒤 이를 구현하는 …RepositoryCustomImpl구현체를 선언한다.

그리고 이를 기존 레포지토리 인터페이스 extends ...Custom과 같이 확장하여 사용한다.

이렇게 하지 않는다면 QuerydslRepositorySupport라는 클래스를 상속받아서 작성하는 방법이 있다.

일단 Custom이라는 이름은 필수가 아니다. 그러나 일종의 관습이므로 RepositoryCustom이라는 이름을 본다면 대부분이 어떤 기능을 하는 클래스인지 쉽게 알 아볼 것이다.

Impl는 필수 네이밍으로 이렇게 작성하지 않으면 제대로 동작하지 않는다.

@Repository
public class PostSerach extends QuerydslRepositorySupport {
  public PostSerach() {
    super(Post.class);
  }
}

이렇게 작성할 수도 있지만 기본적으로 JPAQueryFactory만 있다면 Querydsl을 사용할 수 있다.

따라서 @Repository를 선언해주고 JPAQueryFactory를 주입받으면 인터페이스나 QuerydslRepositorySupport 없이도 Querydsl을 사용할 수 있는 것이다.

그러나 이렇게 생각해볼 수도 있다.
기존 레포지토리에서 Querydsl을 확장해서 사용한다면 사용하는 쪽에서는 해당 메소드가 Querydsl을 이용하여 구현된 것인지 아닌지 알 필요가 없다. 따라서 하나의 일원화된 인터페이스를 통해 해당 엔티티에 대한 쿼리를 사용한다는 관점에서 본다면 나쁘지 않다고 생각한다.

우아한 형제들의 이동욱 개발자님의 말에 따르면 어떤 기능을 구현할 때 A레포지토리의 역할로 봐야할지 B레포지토리의 역할로 봐야할지 모호한 경우 어떻게 해야할지 고민해보시다가 이런 방법을 찾아내셨다고 한다.
이동욱님의 발표

QueryRepositorySupport

QueryRepositorySupport는 Querydsl 3점대 버전을 기준으로 나온 클래스로 JPA와 Querydsl을 같이 사용할 수 있도록 한다.

내부 구현 코드를 보면…

@Repository
public abstract class QuerydslRepositorySupport {
    private final PathBuilder<?> builder;
    @Nullable
    private EntityManager entityManager;
    @Nullable
    private Querydsl querydsl;

    public QuerydslRepositorySupport(Class<?> domainClass) {
        Assert.notNull(domainClass, "Domain class must not be null");
        this.builder = (new PathBuilderFactory()).create(domainClass);
    }
    ...
}

PathBuilder는 동적으로 쿼리를 생성하기 위한 클래스로 Querydsl쿼리를 작성할 때 select("post")와 같이 사용하지 않고 select(post)와 같이 Q도메인을 넣어주는 식이다.
말하자면 내부에 이러한 조건을 넣어줄 때를 위한 클래스다.

위의 생성자를 보면 도메인 클래스를 생성자의 인자로 받아 path를 생성하고 있다.

이렇게 해서 실제 쿼리를 작성할 때 select(post)와 같이 넣어주면 된다.

QueryRepositorySupport는 Querydsl 3점대 버전(2013)에서 쓰이던 방식으로 더 이상 쓰이지 않는다.

사용법

필수적인 사용법은 일단 쿼리를 생성해야하는데 작성하는 방법은 크게 두 가지가 있다.

QPost post = QPost.post;
JPAQuery<Post> query  = from(post);
query.where(post.title.contains("title"));
query.where(post.id.eq(1))
...

이렇게 사용하는 방법과 JPAQueryFactory를 사용하는 방법이 있다.

JPAQueryFactory는 말 그대로 JPAQuery를 만드는 공장이라고 보면 된다.

내부 코드를 보면…

@Override
  public <T> JPAQuery<T> select(Expression<T> expr) {
      return query().select(expr);
  }

이렇게 JPAQuery를 리턴하고있다.

Querydsl 3점대 버전까지는 JPQLQuery가 JPQL쿼리를 다루는 주요한 객체로 사용되었다. 그러다 Querydsl 4점대 버전부터는 JPAQuery가 도입되었고 JPQLQuery의 상위 클래스에 속한다. JPAQuery는 JPA와의 통합을 더 잘 지원하며 다양한 기능과 최적화를 제공한다.

이러한 이유로 최신 버전인 JPAQuery를 사용하는 것이 좋다.

아무튼 위에서 살펴본 두 방식의 차이는 JPQLQuery의 경우 from절 부터 시작한다.
JPAQueryFactory의 경우는 select절부터 시작한다. 따라서 좀 더 직관적이라고 볼 수 있겠다.

고민
위에서 본 QueryRepositorySupport는 더 이상 사용하지 않는다고한다. 이유를 검색해봤을 때 여러가지 이유가 나왔는데 딱 와닿는다 싶은 이유는 못찾았다.

QueryRepositorySupport가 편리한 이유는 바로 페이징처리인데 이것도 마법처럼 자동으로 페이징을 해주는 것이 아니라 내부 로직을 보면 직접 사용자의 입력값을 받아 페이징시키고 있다.만약 QueryRepositorySupport를 걷어내고 페이징을 해야한다면 이 내부 코드들을 바깥으로 끄집어 내어 사용하는 클래스마다 정의해주어야한다는 의미인데 아직까지 정확한 이유를 찾지 못했다.

아무튼 이렇게 쿼리를 만들고 fetch를 통해 쿼리를 실행하면 된다. fetch의 종류는 다음과 같다.

  • fetch(): 조회가 여러 건인 경우 사용하는 메소드로 리턴 타입은 리스트
  • fetchOne(): 고유한 결과를 가져오거나 결과가 없는 경우 null을 반환
  • fetchFirst(): 첫 결과를 가져오거나 결과가 없는 경우 null을 반환
  • fetchResults(: (5점대 버전부터 deprecated)
  • fetchCount(: 결과의 개수를 가져온다.(5점대 버전부터 deprecated)

이제 간단한 사용법을 알아보자.

List<SampleResoonseDTO> content = jpaQueryFactory
.select(Projections.constructor(
  SampleResponseDTO.class,
  sample.title,
  sample.content
)).from(sample)
.where(sample.containsIgnoreCase(keyword))
.limit(size)
.offset(pageable.getOffset())
.orderBy(sample.createdAt)
.fetch();

SQL로 쿼리를 작성하는 것과 크게 다른 게 없다. Projections는 쿼리 실행 결과를 별도의 클래스로 매핑할 수 있는 클래스다.

만약 동적 정렬을 하고싶다면 별도의 메소드를 작성해주어야한다. OrderBy내부의 인자는 OderSpecifier타입으로 넘겨주어야하기 때문이다.

여기서 아까 위에서 말한 고민이 생긴 것이다.

public <T> JPQLQuery<T> applyPagination(Pageable pageable, JPQLQuery<T> query) {
        Assert.notNull(pageable, "Pageable must not be null");
        Assert.notNull(query, "JPQLQuery must not be null");
        if (pageable.isUnpaged()) {
            return query;
        } else {
            query.offset(pageable.getOffset());
            query.limit((long)pageable.getPageSize());
            return this.applySorting(pageable.getSort(), query);
        }
    }
public <T> JPQLQuery<T> applySorting(Sort sort, JPQLQuery<T> query) {
    Assert.notNull(sort, "Sort must not be null");
    Assert.notNull(query, "Query must not be null");
    if (sort.isUnsorted()) {
        return query;
    } else if (sort instanceof QSort) {
        QSort qsort = (QSort)sort;
        return this.addOrderByFrom(qsort, query);
    } else {
        return this.addOrderByFrom(sort, query);
    }
}
private <T> JPQLQuery<T> addOrderByFrom(QSort qsort, JPQLQuery<T> query) {
    List<OrderSpecifier<?>> orderSpecifiers = qsort.getOrderSpecifiers();
    return (JPQLQuery)query.orderBy((OrderSpecifier[])orderSpecifiers.toArray(new OrderSpecifier[0]));
}
private OrderSpecifier<?> toOrderSpecifier(Sort.Order order) {
    return new OrderSpecifier(order.isAscending() ? Order.ASC : Order.DESC, this.buildOrderPropertyPathFrom(order), this.toQueryDslNullHandling(order.getNullHandling()));
}

이 코드를 참조해서 구현하면 된다.

쿼리를 계속 이어 작성하지 않아도 동작하는 이유

JPQL의 메소드들은 JPQLQuery 객체를 반환하도록 되어있다. 각 메소드 호출이 기존의 객체를 수정하는 방식으로 동작한다.

예를 들어 위의 코드의 경우 where()은 해당 조건을 담고 있는 query객체를 반환한다. 따라서 비유하자면 선언한 JPQLQuery 객체는 일반적인 자바 객체로 볼 수 있고 .접근하여 작성하는 코드는 .setXXX()으로 세터를 사용하는 것에 비유할 수 있다.

실제 코드를 보면…

public final T where(Predicate... o) {
    for (Predicate e : o) {
        metadata.addWhere(convert(e, Role.WHERE));
    }
    return self;
}

이런식으로 동작한다.