Spring QueryDSL이란?
QueryDSL은 Java 코드로 타입 안전한 SQL/JPQL 쿼리를 작성할 수 있게 해주는 프레임워크다. JPA Criteria API의 복잡함 없이, 컴파일 타임에 쿼리 오류를 잡을 수 있다는 점이 핵심이다. Spring Data JPA의 Specification과 함께 사용하면 동적 쿼리를 깔끔하게 구현할 수 있다.
QueryDSL 설정: Gradle + APT
Spring Boot 3.x + QueryDSL 5.x 기준 설정이다. APT(Annotation Processing Tool)로 Q클래스를 자동 생성한다.
// build.gradle.kts
plugins {
kotlin("kapt") version "1.9.22" // Kotlin 사용 시
}
dependencies {
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
annotationProcessor("com.querydsl:querydsl-apt:5.1.0:jakarta")
annotationProcessor("jakarta.persistence:jakarta.persistence-api")
annotationProcessor("jakarta.annotation:jakarta.annotation-api")
}
// Q클래스 생성 경로
tasks.withType<JavaCompile> {
options.generatedSourceOutputDirectory.set(
file("${buildDir}/generated/querydsl")
)
}
./gradlew compileJava 실행 시 QMember, QOrder 같은 메타모델 클래스가 자동 생성된다.
Q클래스와 기본 쿼리
엔티티마다 Q접두사가 붙은 메타모델 클래스가 생성되며, 이를 통해 필드를 참조한다.
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
private int age;
@Enumerated(EnumType.STRING)
private MemberStatus status;
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
}
// 기본 조회
QMember member = QMember.member;
List<Member> result = queryFactory
.selectFrom(member)
.where(
member.age.goe(20),
member.status.eq(MemberStatus.ACTIVE)
)
.orderBy(member.name.asc())
.fetch();
where()에 조건을 콤마로 나열하면 AND로 결합된다. member.age.goe(20)처럼 메서드 체이닝으로 조건을 표현하므로, 오타나 타입 불일치를 컴파일 시점에 잡는다.
동적 쿼리: BooleanExpression 조합
QueryDSL의 가장 강력한 기능은 동적 쿼리다. BooleanExpression을 반환하는 메서드를 만들어 조합한다.
public class MemberPredicates {
public static BooleanExpression nameContains(String name) {
return name != null
? QMember.member.name.containsIgnoreCase(name)
: null;
}
public static BooleanExpression ageBetween(Integer minAge, Integer maxAge) {
if (minAge != null && maxAge != null) {
return QMember.member.age.between(minAge, maxAge);
}
if (minAge != null) {
return QMember.member.age.goe(minAge);
}
if (maxAge != null) {
return QMember.member.age.loe(maxAge);
}
return null;
}
public static BooleanExpression statusEq(MemberStatus status) {
return status != null
? QMember.member.status.eq(status)
: null;
}
}
// 사용: null인 조건은 자동으로 무시됨
List<Member> result = queryFactory
.selectFrom(member)
.where(
nameContains(searchName), // null이면 무시
ageBetween(minAge, maxAge), // null이면 무시
statusEq(status) // null이면 무시
)
.fetch();
where()에 null을 넘기면 해당 조건이 무시된다. 이 특성을 활용해 if문 없이 깔끔한 동적 쿼리를 구현할 수 있다. 이는 Criteria API나 문자열 JPQL로는 불가능한 수준의 가독성이다.
BooleanBuilder vs BooleanExpression
동적 쿼리를 만드는 두 가지 접근법이 있다.
| 구분 | BooleanBuilder | BooleanExpression |
|---|---|---|
| 스타일 | 명령형 (if-and 추가) | 선언형 (메서드 조합) |
| 재사용성 | 낮음 | 높음 (메서드 추출) |
| 가독성 | 조건 많으면 복잡 | 깔끔한 체이닝 |
| 권장 상황 | OR 조건이 복잡할 때 | 일반적인 동적 필터링 |
// BooleanBuilder 방식 (명령형)
BooleanBuilder builder = new BooleanBuilder();
if (name != null) builder.and(member.name.contains(name));
if (age != null) builder.and(member.age.eq(age));
// BooleanExpression 방식 (선언형) — 권장
.where(nameContains(name), ageEq(age))
일반적으로 BooleanExpression 방식을 권장한다. 코드가 선언적이고 재사용이 쉽다. BooleanBuilder는 복잡한 OR 그룹이 필요할 때만 사용한다.
Projection: DTO 직접 조회
엔티티 전체가 아닌 필요한 필드만 조회하면 성능이 향상된다. QueryDSL은 세 가지 Projection 방식을 제공한다.
// 1. Projections.constructor — 생성자 매핑
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,
member.name,
member.age,
member.team.name
))
.from(member)
.join(member.team)
.fetch();
// 2. Projections.fields — 필드 직접 접근 (setter 불필요)
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class,
member.name,
member.age,
member.team.name.as("teamName") // 필드명 다르면 alias
))
.from(member)
.fetch();
// 3. @QueryProjection — 컴파일 타임 안전 (권장)
public class MemberDto {
@QueryProjection
public MemberDto(String name, int age, String teamName) {
this.name = name;
this.age = age;
this.teamName = teamName;
}
}
// Q클래스 생성 후 사용
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.name, member.age, member.team.name))
.from(member)
.join(member.team)
.fetch();
@QueryProjection은 DTO가 QueryDSL에 의존하게 되지만, 타입 안전성이 가장 높다. 필드 순서나 타입이 틀리면 컴파일 에러가 발생한다.
JOIN과 서브쿼리
QMember member = QMember.member;
QTeam team = QTeam.team;
QOrder order = QOrder.order;
// Inner Join + On절 필터
List<Tuple> result = queryFactory
.select(member.name, team.name)
.from(member)
.join(member.team, team)
.on(team.status.eq(TeamStatus.ACTIVE))
.fetch();
// Left Join + fetchJoin (N+1 방지)
List<Member> members = queryFactory
.selectFrom(member)
.leftJoin(member.team, team).fetchJoin()
.where(member.age.gt(20))
.fetch();
// 서브쿼리: JPAExpressions 사용
QMember subMember = new QMember("subMember");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(subMember.age.max())
.from(subMember)
))
.fetch();
// EXISTS 서브쿼리
List<Member> result = queryFactory
.selectFrom(member)
.where(JPAExpressions
.selectOne()
.from(order)
.where(order.member.eq(member)
.and(order.amount.gt(10000)))
.exists()
)
.fetch();
주의: JPA의 JPQL은 FROM절 서브쿼리를 지원하지 않는다. 필요하면 네이티브 SQL이나 뷰를 사용해야 한다.
페이징과 정렬
// Offset 기반 페이징
public Page<MemberDto> searchPage(MemberSearchCond cond, Pageable pageable) {
List<MemberDto> content = queryFactory
.select(new QMemberDto(member.name, member.age, member.team.name))
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(cond.getName()),
ageBetween(cond.getMinAge(), cond.getMaxAge())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(member.id.desc())
.fetch();
// count 쿼리 최적화: 데이터가 페이지 사이즈보다 작으면 count 생략
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(cond.getName()),
ageBetween(cond.getMinAge(), cond.getMaxAge())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
// Cursor 기반 페이징 (대용량 데이터 권장)
public List<MemberDto> searchAfter(Long lastId, int size) {
return queryFactory
.select(new QMemberDto(member.name, member.age, member.team.name))
.from(member)
.where(
lastId != null ? member.id.lt(lastId) : null // cursor 조건
)
.orderBy(member.id.desc())
.limit(size)
.fetch();
}
PageableExecutionUtils.getPage()를 사용하면 첫 페이지나 마지막 페이지에서 불필요한 count 쿼리를 생략한다. 대용량 테이블에서는 커서 기반 페이징이 offset보다 훨씬 빠르다.
Spring Data JPA + QueryDSL 통합
커스텀 리포지토리 패턴으로 Spring Data JPA와 QueryDSL을 통합한다.
// 1. 커스텀 인터페이스 정의
public interface MemberRepositoryCustom {
Page<MemberDto> search(MemberSearchCond cond, Pageable pageable);
List<MemberDto> searchAfter(Long lastId, int size);
}
// 2. 구현체 — Impl 접미사 필수
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public Page<MemberDto> search(MemberSearchCond cond, Pageable pageable) {
// 위의 searchPage 구현과 동일
}
}
// 3. 기존 리포지토리에 상속 추가
public interface MemberRepository
extends JpaRepository<Member, Long>, MemberRepositoryCustom {
}
// 4. JPAQueryFactory Bean 등록
@Configuration
public class QueryDslConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
// 5. 서비스에서 사용 — 하나의 리포지토리로 통합
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Page<MemberDto> search(MemberSearchCond cond, Pageable pageable) {
return memberRepository.search(cond, pageable);
}
}
이 패턴의 핵심은 Impl 접미사다. Spring Data JPA가 이 규칙으로 커스텀 구현체를 자동 감지한다. 관련 내용은 Spring JPA Specification 동적 쿼리 글에서도 확인할 수 있다.
GroupBy·집계 함수
// 팀별 평균 나이, 회원 수
List<Tuple> result = queryFactory
.select(
team.name,
member.age.avg(),
member.count()
)
.from(member)
.join(member.team, team)
.groupBy(team.name)
.having(member.count().gt(3))
.fetch();
// transform + groupBy (Collectors 대체)
Map<String, List<MemberDto>> teamMembers = queryFactory
.from(member)
.join(member.team, team)
.transform(
GroupBy.groupBy(team.name).as(
GroupBy.list(
Projections.constructor(MemberDto.class,
member.name, member.age, team.name)
)
)
);
transform()과 GroupBy를 사용하면 DB에서 한 번의 쿼리로 그룹핑된 Map을 반환받을 수 있다. Java Stream의 groupBy보다 효율적이다.
벌크 연산
// 벌크 UPDATE
long updatedCount = queryFactory
.update(member)
.set(member.status, MemberStatus.INACTIVE)
.where(member.lastLoginAt.before(
LocalDateTime.now().minusMonths(6)
))
.execute();
// 벌크 DELETE
long deletedCount = queryFactory
.delete(member)
.where(member.status.eq(MemberStatus.WITHDRAWN))
.execute();
// ⚠️ 벌크 연산 후 영속성 컨텍스트 초기화 필수!
em.flush();
em.clear();
중요: 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 실행된다. 반드시 flush() → clear() 후 조회해야 데이터 불일치를 방지할 수 있다. 관련 내용은 Spring JPA N+1 해결 전략에서도 다루고 있다.
성능 최적화 팁
QueryDSL을 실무에서 쓸 때 놓치기 쉬운 최적화 포인트를 정리한다.
// 1. select 필드 최소화 — 엔티티 전체 조회 피하기
// ❌ selectFrom(member) → 모든 컬럼 + 영속성 컨텍스트 관리
// ✅ select(member.name, member.age) → 필요한 필드만
// 2. fetchJoin 대신 DTO 프로젝션
// fetchJoin은 엔티티를 영속성 컨텍스트에 올림
// DTO 프로젝션은 순수 데이터만 조회 → GC 부담 감소
// 3. exists() 최적화: fetchFirst 활용
public boolean existsByName(String name) {
return queryFactory
.selectOne()
.from(member)
.where(member.name.eq(name))
.fetchFirst() != null; // LIMIT 1로 최적화
}
// 4. Constant 표현식
// 고정값을 쿼리 결과에 포함
.select(
member.name,
Expressions.constant("ACTIVE") // 쿼리에 상수 추가
)
테스트 전략
@DataJpaTest
@Import(QueryDslConfig.class) // JPAQueryFactory Bean 필요
class MemberRepositoryTest {
@Autowired
MemberRepository memberRepository;
@Autowired
JPAQueryFactory queryFactory;
@Test
void 동적_쿼리_이름_필터() {
// given
memberRepository.save(new Member("Alice", 25, team));
memberRepository.save(new Member("Bob", 30, team));
MemberSearchCond cond = new MemberSearchCond();
cond.setName("Ali");
// when
Page<MemberDto> result = memberRepository.search(
cond, PageRequest.of(0, 10));
// then
assertThat(result.getContent()).hasSize(1);
assertThat(result.getContent().get(0).getName()).isEqualTo("Alice");
}
@Test
void 조건_null이면_전체_조회() {
// given
memberRepository.save(new Member("Alice", 25, team));
memberRepository.save(new Member("Bob", 30, team));
MemberSearchCond cond = new MemberSearchCond(); // 모든 필드 null
// when
Page<MemberDto> result = memberRepository.search(
cond, PageRequest.of(0, 10));
// then
assertThat(result.getContent()).hasSize(2);
}
}
@DataJpaTest는 JPA 관련 Bean만 로드하므로, @Import(QueryDslConfig.class)로 JPAQueryFactory를 수동 등록해야 한다.
마무리: QueryDSL vs 대안 비교
| 기술 | 타입 안전 | 동적 쿼리 | 학습 비용 | 유지보수 |
|---|---|---|---|---|
| JPQL 문자열 | ❌ | 어려움 | 낮음 | 나쁨 |
| Criteria API | ✅ | 가능 | 높음 | 나쁨 |
| Specification | ✅ | 좋음 | 중간 | 보통 |
| QueryDSL | ✅ | 최고 | 중간 | 좋음 |
| jOOQ | ✅ | 최고 | 중간 | 좋음 |
QueryDSL은 JPA 기반 프로젝트에서 동적 쿼리가 필요할 때 가장 실용적인 선택이다. BooleanExpression 조합으로 조건을 선언적으로 관리하고, DTO 프로젝션으로 성능을 최적화하며, Spring Data JPA와 매끄럽게 통합된다. jOOQ는 SQL 중심이고, QueryDSL은 JPA/JPQL 중심이라는 차이가 있으므로, ORM 기반 프로젝트라면 QueryDSL이 더 자연스럽다.