QueryDSL — 타입 안전한 동적 쿼리를 작성하는 방법
JPQL을 문자열로 작성하다가 오타 하나 때문에 런타임에 터진 경험, 한 번쯤 있지 않나요? 쿼리도 자바 코드처럼 컴파일 시점에 검증할 수는 없을까요?
QueryDSL이란
QueryDSL은 자바 코드로 SQL이나 JPQL을 타입 안전하게 작성할 수 있도록 해주는 라이브러리입니다. 문자열 기반 쿼리의 문제점 — 오타, 타입 불일치, 리팩토링 시 누락 — 을 컴파일 시점에 잡아줍니다.
핵심 아이디어는 간단합니다. 엔티티 클래스에서 Q클래스 라는 메타 모델을 자동 생성하고, 이 Q클래스를 조합해서 쿼리를 만드는 것입니다.
// 문자열 JPQL — 오타가 있어도 컴파일은 통과
em.createQuery("SELECT m FROM Membr WHERE m.age > :age"); // Membr 오타!
// QueryDSL — 컴파일 시점에 오류 감지
queryFactory
.selectFrom(member)
.where(member.age.gt(20))
.fetch();
Q클래스 생성과 설정
Q클래스는 annotationProcessor가 @Entity 어노테이션이 붙은 클래스를 스캔해서 컴파일 시점에 자동 생성합니다. Member 엔티티가 있으면 QMember가 만들어지는 식입니다.
Gradle 설정 (Spring Boot 3.x + OpenFeign 포크)
// build.gradle
dependencies {
// OpenFeign 포크 — Jakarta EE 지원
implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.x'
annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.x:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}
설정 후 빌드를 실행하면 build/generated/sources/annotationProcessor 경로에 Q클래스가 생성됩니다. IDE에서 이 경로를 소스 디렉토리로 인식하도록 설정해야 자동 완성이 동작합니다.
공부하다 보니 Q클래스가 생성되지 않아서 막히는 경우가 꽤 많았는데, 대부분 annotationProcessor 의존성 누락이거나 빌드 한 번 안 돌린 경우였습니다.
기본 사용법 — JPAQueryFactory
QueryDSL의 모든 쿼리는 JPAQueryFactory에서 시작합니다. 보통 빈으로 등록해두고 주입받아 사용합니다.
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
기본 조회
@RequiredArgsConstructor
@Repository
public class MemberQueryRepository {
private final JPAQueryFactory queryFactory;
public List<Member> findByAge(int age) {
QMember member = QMember.member;
return queryFactory
.selectFrom(member)
.where(member.age.eq(age))
.orderBy(member.name.asc())
.fetch();
}
}
주요 메서드를 정리하면 이렇습니다.
selectFrom(entity): SELECT + FROM을 한 번에 지정select(필드).from(entity): 프로젝션이 필요할 때 분리where(): 검색 조건 —eq,ne,gt,lt,between,like,in등orderBy(): 정렬 —asc(),desc()offset(),limit(): 페이징fetch(): 결과 리스트 반환fetchOne(): 단건 조회 (결과가 2건 이상이면 예외)fetchFirst(): 첫 번째 결과 반환 (limit 1)
조인
// 내부 조인
queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("개발팀"))
.fetch();
// 왼쪽 외부 조인 + on 절
queryFactory
.selectFrom(member)
.leftJoin(member.team, team)
.on(team.active.isTrue())
.fetch();
DTO 프로젝션
엔티티 전체가 아니라 필요한 필드만 조회할 때는 Projections를 사용합니다.
// 생성자 방식
queryFactory
.select(Projections.constructor(MemberDto.class,
member.name,
member.age))
.from(member)
.fetch();
// @QueryProjection 방식 — DTO 생성자에 어노테이션 추가
queryFactory
.select(new QMemberDto(member.name, member.age))
.from(member)
.fetch();
@QueryProjection은 컴파일 시점 검증이 가능하다는 장점이 있지만, DTO가 QueryDSL에 의존하게 됩니다. 이 트레이드오프를 알고 선택하면 됩니다.
동적 쿼리 — 진짜 강력한 부분
QueryDSL을 쓰는 가장 큰 이유가 바로 동적 쿼리입니다. 검색 조건이 사용자 입력에 따라 달라지는 상황에서 문자열 기반 JPQL로는 코드가 금방 스파게티가 됩니다.
방법 1: BooleanBuilder
public List<Member> search(String name, Integer minAge, Integer maxAge) {
QMember member = QMember.member;
BooleanBuilder builder = new BooleanBuilder();
if (name != null) {
builder.and(member.name.contains(name));
}
if (minAge != null) {
builder.and(member.age.goe(minAge));
}
if (maxAge != null) {
builder.and(member.age.loe(maxAge));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
직관적이지만, 조건이 많아지면 if문이 늘어나서 가독성이 떨어집니다.
방법 2: BooleanExpression 메서드 분리 (권장)
public List<Member> search(String name, Integer minAge, Integer maxAge) {
return queryFactory
.selectFrom(member)
.where(
nameContains(name),
ageGoe(minAge),
ageLoe(maxAge)
)
.fetch();
}
// 각 조건을 별도 메서드로 분리 — 재사용 가능
private BooleanExpression nameContains(String name) {
return name != null ? member.name.contains(name) : null;
}
private BooleanExpression ageGoe(Integer minAge) {
return minAge != null ? member.age.goe(minAge) : null;
}
private BooleanExpression ageLoe(Integer maxAge) {
return maxAge != null ? member.age.loe(maxAge) : null;
}
where()에 null이 들어가면 해당 조건은 무시됩니다. 이 방식이 더 깔끔하고, 조건 메서드를 다른 쿼리에서도 재사용할 수 있어서 실무에서 많이 쓰입니다.
Spring Data JPA 연동
QuerydslPredicateExecutor
Spring Data JPA는 QuerydslPredicateExecutor 인터페이스를 제공합니다. Repository에 상속만 추가하면 됩니다.
public interface MemberRepository extends JpaRepository<Member, Long>,
QuerydslPredicateExecutor<Member> {
}
// 사용
QMember member = QMember.member;
Predicate predicate = member.age.gt(20).and(member.name.contains("김"));
Iterable<Member> result = memberRepository.findAll(predicate);
간단한 조건에는 편리하지만, 조인이 필요한 복잡한 쿼리에는 한계가 있습니다.
커스텀 Repository 패턴
복잡한 쿼리는 커스텀 Repository로 분리하는 것이 정석입니다.
// 1. 커스텀 인터페이스 정의
public interface MemberRepositoryCustom {
List<MemberDto> searchMembers(MemberSearchCondition condition);
}
// 2. 구현 클래스 — 반드시 Impl 접미사
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<MemberDto> searchMembers(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberDto(
member.name,
member.age,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(condition.getName()),
ageGoe(condition.getMinAge()),
teamNameEq(condition.getTeamName())
)
.fetch();
}
}
// 3. 기존 Repository에 상속 추가
public interface MemberRepository extends JpaRepository<Member, Long>,
MemberRepositoryCustom {
}
이 패턴을 사용하면 단순 CRUD는 Spring Data JPA가, 복잡한 동적 쿼리는 QueryDSL이 처리하도록 깔끔하게 분리됩니다.
페이징 처리
fetchResults()는 deprecated되었기 때문에, content 쿼리와 count 쿼리를 분리해서 작성하는 것이 좋습니다.
public Page<MemberDto> searchPage(MemberSearchCondition condition, Pageable pageable) {
List<MemberDto> content = queryFactory
.select(new QMemberDto(member.name, member.age))
.from(member)
.where(nameContains(condition.getName()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(member.id.desc())
.fetch();
// count 쿼리 — 조인이 필요 없으면 단순화 가능
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.where(nameContains(condition.getName()));
// count 쿼리 최적화 — 마지막 페이지이거나 첫 페이지에서 전체가 다 나오면 count 생략
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
PageableExecutionUtils.getPage()는 Spring Data JPA가 제공하는 유틸리티로, 불필요한 count 쿼리 실행을 건너뛰어 줍니다.
현재 상태 (2025-2026)
여기서 한 가지 짚고 넘어가야 할 부분이 있습니다. QueryDSL의 생태계 상황입니다.
- 원본 QueryDSL (
com.querydsl): 마지막 안정 릴리스가 5.0.0(2021년)이며, Jakarta EE 지원이 불안정합니다. 이슈와 PR이 쌓여 있지만 반영이 느립니다. - OpenFeign 포크 (
io.github.openfeign.querydsl): Jakarta EE를 공식 지원하고, 커뮤니티가 활발하게 유지보수하고 있습니다. Spring Boot 3.x 프로젝트에서는 이 포크를 사용하는 것이 권장됩니다.
새 프로젝트를 시작한다면 OpenFeign 포크를 선택하는 것이 안전합니다. 기존 프로젝트도 마이그레이션이 크게 어렵지 않습니다 — 패키지 경로만 바꾸면 대부분 동작합니다.
대안 비교
| 기준 | QueryDSL | Specification | Criteria API |
|---|---|---|---|
| ** 타입 안전성** | 매우 높음 (Q클래스) | 중간 (문자열 속성 참조) | 중간 (Metamodel 사용 시 높음) |
| ** 가독성** | 좋음 (SQL과 유사) | 보통 | 나쁨 (장황함) |
| ** 코드 생성** | 필요 (APT) | 불필요 | 불필요 (Metamodel은 필요) |
| ** 학습 곡선** | 중간 | 낮음 | 높음 |
| ** 동적 쿼리** | 매우 편리 | 편리 | 가능하지만 복잡 |
| ** 외부 의존성** | 있음 | 없음 (JPA 표준) | 없음 (JPA 표준) |
프로젝트에 외부 의존성을 추가하기 부담스럽다면 Specification을, 복잡한 동적 쿼리가 핵심이라면 QueryDSL을 선택하는 것이 일반적입니다.
Spring Data 2026.0의 새로운 대안
Spring Data 2026.0(Woolston 릴리스 트레인)에서는 메서드 레퍼런스 기반의 타입 세이프 프로퍼티 참조가 도입되었습니다. Q클래스 없이도 타입 안전한 쿼리를 작성할 수 있는 방향입니다.
// 기존 — 문자열 기반 (오타 위험)
Sort.by("name");
// 새로운 방식 — 메서드 레퍼런스 기반 (컴파일 시점 검증)
Sort.by(TypedSort.sort(Member.class).by(Member::getName));
아직 QueryDSL만큼의 복잡한 동적 쿼리 표현력은 갖추지 못했지만, JPA 표준 생태계 안에서 타입 안전성을 확보하려는 방향성은 주목할 만합니다. QueryDSL의 유지보수 불안정성이 걱정된다면 중장기적으로 이 흐름을 지켜보는 것도 좋겠습니다.
기억해둘 포인트
- QueryDSL의 핵심 가치는 ** 컴파일 시점 검증 **과 ** 깔끔한 동적 쿼리 **입니다.
- 동적 쿼리는 BooleanBuilder보다 BooleanExpression 메서드 분리 방식이 가독성과 재사용성에서 유리합니다.
fetchResults()는 deprecated — content 쿼리와 count 쿼리를 분리하고PageableExecutionUtils를 활용하세요.- 2026년 기준 OpenFeign 포크 를 사용하는 것이 안전한 선택입니다.
- 커스텀 Repository 패턴(Custom + Impl)으로 단순 CRUD와 복잡한 쿼리를 분리하면 코드가 훨씬 깔끔해집니다.