[JPA] QueryDSL
📌 QueryDSL이란?
QueryDSL은 HQL(Hibernate Query Language)를 타입에 안전하게(type-safe) 생성 및 관리하는 프레임워크이다. 정적 타입을 통해 SQL이나 JPQL 쿼리를 자바 코드로 작성할 수 있다.
정적 타입이란 컴파일 시점에 변수나 객체의 데이터 타입이 미리 결정되는 것을 말한다.
‘type-safe’라는 것은 컴파일러가 프로그램 실행 전 데이터 타입을 확인하여 타입 오류를 방지하는 것을 의미한다.
DSL은 Domain-Specific Language의 약자로, 특정 도메인에 초점을 맞춘 소프트웨어 언어이다. QueryDSL에서 도메인이란 데이터베이스라고 볼 수 있다.
📌 등장 배경
QueryDSL 이전에는 Mybatis, JPQL, Criteria과 같이 문자열 형태로 쿼리문을 작성하였다. 따라서 컴파일 시점에 오류를 발견하는 것이 불가능했고, 유지보수가 어려워졌다. 공식 JPA에서 제공하는 Criteria API는 쿼리를 자바 코드로 작성할 수 있도록 지원하지만, 코드가 난해하고 직관성이 떨어진다는 단점이 있었다. 또한 실무에서는 다양한 조건의 동적 쿼리의 필요성이 증가하였다. 이러한 문제들을 해결하기 위해 QueryDSL이 등장하였다.
📌 장점
- 문자열이 아닌 코드로 쿼리를 작성하여 컴파일 시점에 오류를 찾아낼 수 있다.
- Q-Class, 메서드를 사용하여 복잡한 쿼리나 동적 쿼리 작성을 작성할 수 있다.
- JPQL 문법과 유사한 형태로 작성할 수 있다.
📌 Q-Class
Q-Class 는 QueryDSL이 제공하는 메타 모델 클래스이다. 엔티티 클래스의 구조와 필드를 기반으로 자동으로 생성되며, 각 엔티티의 필드 정보를 변수로 가진다.
메타 모델 클래스는 특정 모델의 구조를 컴파일 시점에 정적으로 표현하는 클래스이다. 이를 통해 필드명 오타, 타입 불일치 등과 같은 오류를 실행 전에 발견할 수 있게 된다.
엔티티 클래스에 @Entity 어노테이션이 붙어있다면 Q-Class는 빌드 시점에 JPAAnnotationProcessor 에 의해 자동으로 생성된다. Q-Class는 엔티티 이름 앞에 ‘Q’가 붙어 생성된다. 만약 엔티티 이름이 User 라면 해당 엔티티의 Q-Class는 QUser 가 된다.
JPAAnnotationProcessor는
APT(Annotation Processing Tool)이다. APT는 Java 컴파일러의 일부로, 컴파일 시점에 어노테이션을 분석하고 추가적인 코드를 생성하는 도구이다. 다른 예시로 Lombok의@Getter가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Member {
@Id
private Long id;
private String username;
private int age;
}
public class QMember extends EntityPathBase<Member> {
public static final QMember member = new QMember("member");
public final StringPath username = createString("username");
public final NumberPath<Integer> age = createNumber("age", Integer.class);
public final NumberPath<Long> id = createNumber("id", Long.class);
}
컴파일 시점에 위와 같은 Q-Class가 생성된다.
JPAQueryFactory 를 사용하여 Q-Class 기반으로 쿼리를 빌더 패턴으로 작성할 수 있다.
1
2
3
4
5
6
7
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember member = QMember.member;
Member result = queryFactory
.selectFrom(member)
.where(member.username.eq("whqtker"))
.fetchOne();
1
2
3
4
5
6
7
8
@Configuration
public class QueryDSLConfig {
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
📌 QueryDSL 설정
1
2
3
4
5
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
dependencies 에 QueryDSL 의존성을 추가한다.
1
2
3
clean {
delete file('src/main/generated')
}
위 코드를 추가하면 gradle clean 명령으로 생성된 Q-Class를 자동으로 삭제할 수 있다.
📌 기본 문법
1
2
3
4
5
6
QMember member = QMember.member;
Member result = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"))
.fetchOne();
QueryDSL은 기본적으로 메서드 체이닝을 통해 쿼리를 작성한다.
selectFrom() : 조회할 엔티티를 지정한다.
where() : 조건절을 추가한다.
fetchOne() : 단일 결과를 리턴한다.
조건
1
2
3
4
5
.where(
member.age.between(10, 30)
.and(member.username.like("%user%"))
)
where절에 사용 가능한 메서드 중 일부만 살펴보자.
eq(): 값이 같은지 비교한다.
ne(): 값이 다른지 비교한다.
goe(): 이상(≥)
lt(): 미만(<)
like(): 문자열 패턴을 매칭할 때 사용한다.
조건을 null로 설정하는 경우 해당 조건은 무시된다.
조회
1
2
3
4
QueryResults<Member> results = queryFactory
.selectFrom(member)
.fetchResults();
fetch(): 결과 리스트를 리턴한다. 리턴 타입은 List<T> 이다.
fetchOne(): 단일 결과 객체를 리턴한다. 결과가 없다면 null, 결과가 둘 이상이라면 NonUniqueResultException을 발생시킨다.
fetchFirst(): 쿼리 결과에서 첫 번째 결과만 리턴한다. limit(1).fetchOne() 과 동일한 동작을 수행한다. 결과가 없다면 null 을 리턴한다.
fetchResults(): 쿼리 결과 리스트와 전체 개수를 리턴한다. 리턴 타입은 QueryResults<T> 이며, 내부적으로 getResults(), getTotal() 이 수행된다. 단, 성능적으로 문제가 있으므로 사용을 지양한다.
그룹화를 사용한 복잡한 쿼리에서 정확한 전체 개수를 파악하기 어렵고, 전체 결과를 메모리에 로드한 후
size()로 개수를 계산하므로 성능이 저하될 가능성이 존재하기 때문이다.fetch()후 별도의 count 쿼리를 작성하는 것을 권장한다.
fetchCount() : 조회 쿼리를 count 쿼리로 변환하여 개수를 리턴한다.
내부적으로
fetch().size()로 처리될 가능성이 있어 성능 문제가 발생한다. 이유는 동일하다.
정렬과 페이징
1
2
3
4
5
6
7
List<Member> members = queryFactory
.selectFrom(member)
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.offset(10)
.limit(5)
.fetch();
orderBy() : 정렬 순서를 지정한다.
nullsLast(): null 값을 마지막에 배치한다.
offset(): 쿼리 결과에서 몇 번째 행부터 리턴할지 지정한다.
limit(): 최대 몇 개의 행(페이지 크기)을 리턴할지 지정한다.
집계 함수와 그룹화
1
2
3
4
5
6
7
8
9
10
11
List<Tuple> result = queryFactory
.select(
team.name,
member.age.avg()
)
.from(member)
.join(member.team, team)
.groupBy(team.name)
.having(member.age.avg().gt(20))
.fetch();
groupBy(), having(): 그룹화할 때 사용하는 메서드이다. avg() 과 같이 다양한 집계 함수를 사용할 수 있다.
Projections
1
2
3
4
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
전체 엔티티가 아닌 필요한 필드만 선택하여 결과를 리턴하는 경우 Projections 를 사용한다. QueryDSL의 Tuple 객체를 통해 필드를 조회한다.
결과를 DTO로 변환하는 방법은 크게 4가지가 존재한다.
1. Projections.constructor()
1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
.select(Projections.constructor(
MemberDto.class,
member.username,
member.age
))
.from(member)
.fetch();
DTO의 생성자를 활용하여 쿼리 결과를 매핑하는 방법이다. 생성자를 기반으로 매핑하므로 불변 DTO를 생성할 수 있다. 단, 컴파일 타임에 오류 검출이 어렵고, 매개변수 순서를 일치시켜야 한다. 순서가 일치하지 않으면 IllegalArgumentException 이 발생한다.
불변 DTO는 한 번 생성된 후 내부 상태를 변경할 수 없는 DTO를 말한다. 모든 필드는
final로 선언된다.
2. Projections.fields()
1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
.select(Projections.fields(
MemberDto.class,
member.username,
member.age
))
.from(member)
.fetch();
DTO의 필드에 직접 접근하여 쿼리 결과를 매핑하는 방법이다. DTO의 필드명과 쿼리 결과의 필드명이 일치하면 자동으로 매핑된다. 직접 값을 주입하므로 Setter가 필요 없다. 단, DTO에 @NoArgsConstructor 가 필수로 명시되어야 한다.
3. Projections.bean()
1
2
3
4
5
6
7
8
9
List<MemberDto> result = queryFactory
.select(Projections.bean(
MemberDto.class,
member.username,
member.age
))
.from(member)
.fetch();
DTO의 Setter를 통해 쿼리 결과를 매핑하는 방법이다. 따라서 Setter와 기본 생성자가 필수로 명시되어야 한다.
4. @QueryProjection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Getter
public class UserDTO {
private String username;
private int age;
@QueryProjection
public UserDTO(String username, int age) {
this.username = username;
this.age = age;
}
}
List<UserDTO> dtos = queryFactory
.select(new QUserDTO(member.username, member.age))
.from(member)
.fetch();
DTO의 생성자에 @QueryProjection 을 추가하면 QueryDSL이 해당 DTO에 대한 Q-Class를 자동으로 생성하여 쿼리 결과를 type-safe하게 매핑할 수 있다. 생성자 기반이므로 Setter 없이 불변 DTO를 생성할 수 있다. 일반적으로 가장 추천하는 방법이다.
BooleanBuilder
1
2
3
4
5
6
7
8
9
10
11
12
13
BooleanBuilder builder = new BooleanBuilder();
if (username != null) {
builder.and(member.username.eq(username));
}
if (minAge != null) {
builder.and(member.age.goe(minAge));
}
List<Member> result = queryFactory
.selectFrom(member)
.where(builder)
.fetch();
조건을 유동적으로 조합하고 싶은 경우 BooleanBuilder 를 사용하면 된다. and() 나 or() 을 통한 메서드 체이닝 방법으로 작성할 수 있다.
1
2
BooleanBuilder builder = new BooleanBuilder(Expressions.TRUE);
조건이 하나도 추가되지 않는 경우 위와 같이 작성한다. WHERE 1=1 과 동일한 효과를 가진다.
성능을 최적화하기 위해 자주 걸러지는 조건을 먼저 작성하여 데이터의 범위를 빠르게 축소하는 것이 좋다.
조인
1
2
3
4
5
6
7
List<Order> orders = queryFactory
.selectFrom(order)
.join(order.member, member)
.leftJoin(order.product, product)
.where(member.age.gt(30))
.fetch();
QueryDSL에서 조인은 JPQL과 유사한 방식으로 사용된다. join(), innerjoin(), leftjoin(), fetchJoin()등과 같은 조인 메서드가 존재하며, on() 을 통해 조인 조건을 추가할 수 있다.
서브쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// SELECT 절 서브쿼리
List<Tuple> result = queryFactory
.select(
member.username,
JPAExpressions.select(memberSub.age.max())
.from(memberSub)
)
.from(member)
.fetch();
// WHERE 절 서브쿼리
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
// FROM 절 서브쿼리
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.from(JPAExpressions.selectFrom(memberSub)
.where(memberSub.age.gt(10)), member)
.fetch();
JPAExpressions 를 통해 서브쿼리를 작성할 수 있다. 서브쿼리는 SELECT, WHERE, FROM 절에 작성될 수 있다.
단, FROM 절 서브쿼리는 Hibernate 6 미만에서는 지원하지 않는다.
조건 분기
1
2
3
4
5
6
7
8
List<String> result = queryFactory
.select(new CaseBuilder()
.when(member.age.between(10, 20)).then("10대")
.when(member.age.between(20, 30)).then("20대")
.otherwise("기타"))
.from(member)
.fetch();
CaseBuilder 를 통해 복잡한 조건 분기를 처리할 수 있다. SQL문에서 CASE WHEN 구문과 같은 기능을 수행한다.
수정 및 삭제
1
2
3
4
5
6
long count = queryFactory
.update(member)
.set(member.name, newName)
.where(member.name.eq(oldName))
.execute();
update() 와 delete() 를 통해 데이터를 수정 및 삭제할 수 있다. execute() 를 통해 메서드에 영향을 받은 행의 수를 리턴한다.
📌 참고
https://velog.io/@evan523/JPA-QueryDSL