Querydsl
개요
검색해봤을 때 나오는 결과나 옛날 예제들에 익숙해져있었고 배우는 것을 소홀히 한 결과 여러 실수를 했다.
이번 기회에 체계적으로 정리해보자.
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;
}
이런식으로 동작한다.