9.7 Querydsl — 타입 세이프 동적 쿼리
Querydsl은 자바에서 타입에 안전한(Type-safe) 쿼리를 작성할 수 있게 해주는 라이브러리입니다. 메서드 이름 쿼리나 JPQL 문자열은 조건이 복잡해지면 한계가 있는데, Querydsl을 쓰면 조건 조합·JOIN·동적 쿼리를 코드로 안전하게 표현할 수 있습니다.
작성 기준: Spring Boot 3.x, Querydsl 5.x (querydsl-jpa), Java 17+
1. Querydsl을 쓰는 이유
| 방식 | 장점 | 한계 |
|---|---|---|
| 메서드 이름 | 간단한 조건에 유리 | 조건이 많거나 OR/동적이면 메서드 폭발 |
| @Query JPQL | 복잡한 쿼리 표현 가능 | 문자열이라 오타·리팩터 시 위험, 동적 조건 시 문자열 조합 |
| Querydsl | 타입 세이프, IDE 자동완성, 동적 조건을 코드로 조합 | 설정·Q클래스 생성 필요 |
실무에서는 검색 조건이 많거나, 동적으로 where 절이 바뀌는 목록 API에서 Querydsl을 많이 사용합니다.
2. 의존성 및 Q클래스 생성 (Gradle)
Querydsl JPA를 쓰려면 querydsl-jpa와 APT(Annotation Processing Tool) 로 Q클래스를 생성해야 합니다. Q클래스는 엔티티마다 생성되는 쿼리용 메타 모델입니다.
build.gradle (Kotlin DSL 예시는 주석으로)
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' // JPA (Jakarta)
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
:jakarta: Jakarta EE (JPA 3.x) 기준. Spring Boot 3 / JPA 3 사용 시 필수.- 컴파일 시 엔티티를 스캔해
build/generated/sources/annotationProcessor(또는 설정한 경로) 아래에 QMember, QOrder 같은 Q클래스가 생성됩니다.
Q클래스 예시
엔티티 Member에 대해 QMember.member가 생성되고, 필드는 QMember.member.name, QMember.member.id 등으로 참조합니다.
// 생성된 Q클래스 사용 예
QMember member = QMember.member;
member.name.eq("홍길동"); // name = '홍길동'
member.id.eq(1L); // id = 1
3. JPAQueryFactory 주입
Querydsl로 쿼리를 실행하려면 JPAQueryFactory를 빈으로 등록하고, Repository나 Service에서 주입해 사용합니다.
@Configuration
public class QuerydslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
@Service
@RequiredArgsConstructor
public class MemberSearchService {
private final JPAQueryFactory queryFactory;
}
4. 기본 조회 (select, where)
QMember member = QMember.member;
List<Member> list = queryFactory
.selectFrom(member)
.where(
member.name.eq("홍길동"),
member.status.eq(MemberStatus.ACTIVE)
)
.orderBy(member.id.desc())
.fetch();
- selectFrom(member):
FROM Member+SELECT member에 해당. - where(...): 조건은 콤마로 AND 연결.
eq,ne,like,in,isNotNull등 메서드로 표현. - fetch(): 리스트 반환. 단건은 fetchOne(), 존재 여부는 fetchFirst() 등.
자주 쓰는 조건 메서드
| 메서드 | JPQL/SQL 예시 |
|---|---|
| eq(value) | = ? |
| ne(value) | <> ? |
| like("%" + v + "%") | LIKE '%v%' |
| in(collection) | IN (?, ?, ?) |
| isNull() / isNotNull() | IS NULL / IS NOT NULL |
| goe / loe (value) | >= , <= |
| between(a, b) | BETWEEN ? AND ? |
5. JOIN과 FETCH JOIN
QOrder order = QOrder.order;
QMember member = QMember.member;
List<Order> list = queryFactory
.selectFrom(order)
.join(order.member, member)
.where(member.name.eq("홍길동"))
.fetch();
- join(order.member, member): INNER JOIN. leftJoin, rightJoin도 사용 가능.
- 연관 엔티티를 한 번에 로딩하려면 join().fetchJoin():
List<Order> list = queryFactory
.selectFrom(order)
.join(order.member, member).fetchJoin()
.where(order.id.eq(1L))
.fetch();
- N+1 방지를 위해 필요할 때만 **fetchJoin()**을 붙여 사용합니다.
6. 동적 쿼리 (BooleanBuilder)
검색 조건이 선택적일 때 BooleanBuilder로 조건을 조합합니다.
public List<Member> search(String name, MemberStatus status, Integer minAge) {
QMember member = QMember.member;
BooleanBuilder builder = new BooleanBuilder();
if (name != null && !name.isBlank()) {
builder.and(member.name.containsIgnoreCase(name));
}
if (status != null) {
builder.and(member.status.eq(status));
}
if (minAge != null) {
builder.and(member.age.goe(minAge));
}
return queryFactory
.selectFrom(member)
.where(builder)
.orderBy(member.id.desc())
.fetch();
}
- builder.and(...): 조건을 AND로 계속 추가. or(...) 도 가능.
- containsIgnoreCase: 대소문자 무시 LIKE. startsWith, endsWith 등도 제공됩니다.
7. 페이징 (offset, limit / Pageable)
단순 offset/limit
List<Member> list = queryFactory
.selectFrom(member)
.where(...)
.orderBy(member.id.desc())
.offset(10)
.limit(20)
.fetch();
Spring Data Pageable 연동
전체 개수와 내용을 나눠 조회해 Page를 만듭니다. **fetchCount()**는 Querydsl 5.x에서 deprecated일 수 있으므로, count 쿼리를 별도로 작성하는 방식을 권장합니다.
public Page<Member> searchPage(Pageable pageable, String name) {
QMember member = QMember.member;
BooleanBuilder builder = new BooleanBuilder();
if (name != null && !name.isBlank()) {
builder.and(member.name.containsIgnoreCase(name));
}
List<Member> content = queryFactory
.selectFrom(member)
.where(builder)
.orderBy(member.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.selectFrom(member)
.where(builder)
.fetchCount(); // 또는 select(member.id.count()).fetchOne() 등
return new PageImpl<>(content, pageable, total);
}
- Querydsl 최신 버전에서는 fetchCount() 대신
select(Wildcard.count).from(member).where(builder).fetchOne()형태로 count 전용 쿼리를 두는 경우가 많습니다.
8. DTO 프로젝션
엔티티 대신 DTO로 바로 조회할 때 Projections.constructor 또는 Q클래스 생성자를 사용합니다.
// Record 또는 DTO
public record MemberSummaryDto(Long id, String name, String email) {}
List<MemberSummaryDto> list = queryFactory
.select(
Projections.constructor(MemberSummaryDto.class,
member.id,
member.name,
member.email)
)
.from(member)
.where(member.status.eq(MemberStatus.ACTIVE))
.fetch();
- Projections.constructor(클래스, 필드들): 생성자 인자 순서가 DTO와 일치해야 합니다.
- **Projections.fields()**는 리플렉션 기반으로 setter에 주입하는 방식(필드명 일치 필요)입니다.
9. Spring Data JPA와 함께 쓰기 (QuerydslPredicateExecutor)
Repository에서 QuerydslPredicateExecutor를 상속하면 findAll(Predicate), count(Predicate) 등을 사용할 수 있습니다. 단순 조건 조합에 유용합니다.
public interface MemberRepository extends JpaRepository<Member, Long>, QuerydslPredicateExecutor<Member> {
}
QMember member = QMember.member;
Predicate predicate = member.name.eq("홍길동").and(member.status.eq(MemberStatus.ACTIVE));
List<Member> list = memberRepository.findAll(predicate);
- 복잡한 동적 쿼리나 DTO 프로젝션·서브쿼리 등은 JPAQueryFactory를 쓰는 편이 더 유연합니다.
10. 요약
| 목적 | 방법 |
|---|---|
| 타입 세이프 + 동적 조건 | JPAQueryFactory + BooleanBuilder |
| JOIN / FETCH JOIN | join(), fetchJoin() |
| 페이징 | offset(), limit() + count 쿼리 또는 Pageable |
| DTO로 조회 | Projections.constructor |
| Repository에 간단 연동 | QuerydslPredicateExecutor |
Querydsl은 복잡한 검색 API나 다양한 필터 조합이 필요한 목록 조회에서 JPQL 문자열보다 유지보수와 리팩터링에 유리합니다. 9.6 JPQL 조인과 고급 조회에서 다룬 JOIN·N+1 개념은 Querydsl에서도 fetchJoin() 등으로 그대로 적용됩니다.