왜 QueryDSL인가?
Spring Data JPA는 간단한 CRUD 쿼리에는 매우 강력하지만, 복잡한 동적 쿼리를 작성하기에는 한계가 있습니다. JPQL은 문자열 기반이라 컴파일 시점에 오류를 잡을 수 없고, Criteria API는 코드가 너무 복잡해 가독성이 떨어집니다. QueryDSL은 타입 안전한 자바 코드로 쿼리를 작성할 수 있게 해주며, 특히 동적 쿼리 작성에 탁월한 성능을 발휘합니다.
1. QueryDSL 설정 (Spring Boot 3.x 기준)
Spring Boot 3.x(Jakarta EE) 기준으로 QueryDSL 설정 방법을 안내합니다.
Gradle 설정 (build.gradle)
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}
JPAQueryFactory 빈 등록
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
프로젝트를 빌드하면 @Entity 클래스를 기반으로 Q클래스(예: QMember, QOrder)가 자동 생성됩니다. 이 Q클래스를 통해 타입 안전한 쿼리를 작성합니다.
2. 기본 쿼리 작성
먼저 예제에 사용할 엔티티를 정의합니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
private int age;
@Enumerated(EnumType.STRING)
private MemberStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
기본 조회 쿼리
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<Member> findByName(String name) {
QMember member = QMember.member;
return queryFactory
.selectFrom(member)
.where(member.name.eq(name))
.fetch();
}
@Override
public List<Member> findByAgeGreaterThan(int age) {
QMember member = QMember.member;
return queryFactory
.selectFrom(member)
.where(member.age.gt(age))
.orderBy(member.age.desc())
.fetch();
}
}
3. 동적 쿼리 - BooleanExpression 활용
QueryDSL의 가장 강력한 기능은 BooleanExpression을 활용한 동적 쿼리입니다. 검색 조건이 있을 때만 WHERE 절에 추가되도록 구현할 수 있습니다.
검색 조건 DTO
@Getter
@Setter
public class MemberSearchCondition {
private String name;
private String email;
private Integer ageGoe; // 이상
private Integer ageLoe; // 이하
private MemberStatus status;
private String teamName;
}
BooleanExpression 기반 동적 쿼리
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<MemberResponse> search(MemberSearchCondition condition) {
QMember member = QMember.member;
QTeam team = QTeam.team;
return queryFactory
.select(new QMemberResponse(
member.id,
member.name,
member.email,
member.age,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(condition.getName()),
emailContains(condition.getEmail()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe()),
statusEq(condition.getStatus()),
teamNameEq(condition.getTeamName())
)
.fetch();
}
private BooleanExpression nameContains(String name) {
return StringUtils.hasText(name) ? QMember.member.name.contains(name) : null;
}
private BooleanExpression emailContains(String email) {
return StringUtils.hasText(email) ? QMember.member.email.contains(email) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? QMember.member.age.goe(ageGoe) : null;
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? QMember.member.age.loe(ageLoe) : null;
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? QMember.member.status.eq(status) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return StringUtils.hasText(teamName) ? QTeam.team.name.eq(teamName) : null;
}
}
핵심은 각 조건 메서드가 BooleanExpression을 반환하고, 조건이 없으면 null을 반환하는 것입니다. QueryDSL의 where()는 null 조건을 자동으로 무시하므로, 매우 깔끔한 동적 쿼리가 만들어집니다.
BooleanExpression 조합
BooleanExpression은 .and()와 .or()로 조합할 수 있어, 복잡한 조건도 재사용 가능한 형태로 구성할 수 있습니다.
private BooleanExpression ageBetween(Integer ageGoe, Integer ageLoe) {
return ageGoe(ageGoe).and(ageLoe(ageLoe));
}
private BooleanExpression isActiveMember() {
return statusEq(MemberStatus.ACTIVE)
.and(QMember.member.age.goe(18));
}
4. 페이징 처리
QueryDSL에서 페이징을 구현하는 방법입니다. Spring Data의 Pageable과 자연스럽게 연동됩니다.
@Override
public Page<MemberResponse> searchWithPaging(MemberSearchCondition condition, Pageable pageable) {
QMember member = QMember.member;
QTeam team = QTeam.team;
List<MemberResponse> content = queryFactory
.select(new QMemberResponse(
member.id,
member.name,
member.email,
member.age,
team.name))
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(condition.getName()),
statusEq(condition.getStatus())
)
.orderBy(member.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> countQuery = queryFactory
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
nameContains(condition.getName()),
statusEq(condition.getStatus())
);
// 마지막 페이지이거나 첫 페이지인데 전체 데이터가 pageSize보다 작으면
// count 쿼리를 실행하지 않는 최적화
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
PageableExecutionUtils.getPage()는 count 쿼리가 필요 없는 경우(마지막 페이지 등) count 쿼리 실행을 생략하는 최적화를 자동으로 수행합니다.
5. 서브쿼리
QueryDSL에서 서브쿼리는 JPAExpressions를 사용하여 작성합니다.
@Override
public List<Member> findMembersOlderThanAverage() {
QMember member = QMember.member;
QMember subMember = new QMember("subMember"); // 별칭으로 구분
return queryFactory
.selectFrom(member)
.where(member.age.gt(
JPAExpressions
.select(subMember.age.avg())
.from(subMember)
))
.fetch();
}
@Override
public List<Member> findMembersInLargestTeam() {
QMember member = QMember.member;
QMember subMember = new QMember("subMember");
return queryFactory
.selectFrom(member)
.where(member.team.id.eq(
JPAExpressions
.select(subMember.team.id)
.from(subMember)
.groupBy(subMember.team.id)
.orderBy(subMember.count().desc())
.limit(1)
))
.fetch();
}
주의: JPA 표준 JPQL은 FROM 절의 서브쿼리를 지원하지 않습니다. WHERE절, SELECT절에서만 서브쿼리를 사용할 수 있습니다. FROM절 서브쿼리가 필요한 경우 네이티브 쿼리를 사용하거나, 쿼리를 분리하여 애플리케이션 레벨에서 조합하는 것을 검토하세요.
6. Projection - DTO 직접 조회
엔티티 전체가 아닌 필요한 필드만 조회하여 DTO로 반환하는 방법입니다.
@QueryProjection 활용
@Getter
public class MemberResponse {
private final Long id;
private final String name;
private final String email;
private final int age;
private final String teamName;
@QueryProjection
public MemberResponse(Long id, String name, String email, int age, String teamName) {
this.id = id;
this.name = name;
this.email = email;
this.age = age;
this.teamName = teamName;
}
}
@QueryProjection을 생성자에 붙이면 QMemberResponse 클래스가 생성되어, 컴파일 시점에 타입 체크가 가능해집니다.
Projections.constructor 활용
@QueryProjection은 DTO가 QueryDSL에 의존하게 되는 단점이 있습니다. 의존을 피하려면 Projections를 사용합니다.
public List<MemberResponse> findMemberDtos() {
QMember member = QMember.member;
QTeam team = QTeam.team;
return queryFactory
.select(Projections.constructor(MemberResponse.class,
member.id,
member.name,
member.email,
member.age,
team.name))
.from(member)
.leftJoin(member.team, team)
.fetch();
}
7. 성능 최적화 팁
Fetch Join으로 N+1 문제 해결
public List<Member> findAllWithTeam() {
QMember member = QMember.member;
QTeam team = QTeam.team;
return queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.fetch();
}
exists 쿼리 최적화
// 비효율적: count로 존재 여부 확인
public boolean existsByEmailBad(String email) {
Integer count = queryFactory
.selectOne()
.from(QMember.member)
.where(QMember.member.email.eq(email))
.fetchFirst(); // limit(1).fetchOne()
return count != null;
}
// 효율적: fetchFirst로 하나만 조회
public boolean existsByEmail(String email) {
return queryFactory
.selectOne()
.from(QMember.member)
.where(QMember.member.email.eq(email))
.fetchFirst() != null;
}
커버링 인덱스 활용 페이징
// 대량 데이터 페이징 시 커버링 인덱스로 ID만 먼저 조회
public List<MemberResponse> searchWithCoveringIndex(MemberSearchCondition condition, Pageable pageable) {
QMember member = QMember.member;
// 1단계: 커버링 인덱스로 ID만 조회
List<Long> ids = queryFactory
.select(member.id)
.from(member)
.where(nameContains(condition.getName()))
.orderBy(member.id.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
if (ids.isEmpty()) {
return Collections.emptyList();
}
// 2단계: ID 목록으로 필요한 데이터 조회
QTeam team = QTeam.team;
return queryFactory
.select(new QMemberResponse(
member.id, member.name, member.email, member.age, team.name))
.from(member)
.leftJoin(member.team, team)
.where(member.id.in(ids))
.orderBy(member.id.desc())
.fetch();
}
8. Custom Repository 패턴
Spring Data JPA와 QueryDSL을 함께 사용할 때 권장하는 패턴입니다.
// 1. Custom Repository 인터페이스
public interface MemberRepositoryCustom {
List<MemberResponse> search(MemberSearchCondition condition);
Page<MemberResponse> searchWithPaging(MemberSearchCondition condition, Pageable pageable);
}
// 2. Custom Repository 구현체
@Repository
@RequiredArgsConstructor
public class MemberRepositoryCustomImpl implements MemberRepositoryCustom {
private final JPAQueryFactory queryFactory;
// 구현 코드...
}
// 3. JpaRepository에 Custom Repository 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
// Spring Data JPA 기본 메서드 + QueryDSL 커스텀 메서드 모두 사용 가능
}
이 패턴을 사용하면 MemberRepository 하나로 기본 CRUD와 복잡한 QueryDSL 쿼리를 모두 사용할 수 있습니다.
마치며
QueryDSL은 복잡한 조회 쿼리를 타입 안전하게 작성할 수 있는 강력한 도구입니다. 특히 BooleanExpression을 활용한 동적 쿼리 패턴은 실무에서 검색 기능을 구현할 때 필수적입니다. 이 글에서 다룬 동적 쿼리, 페이징, 서브쿼리, Projection, 성능 최적화 기법들을 프로젝트에 적용하면, 유지보수성과 성능 모두를 잡을 수 있습니다. 처음 설정이 번거롭게 느껴질 수 있지만, 한 번 익숙해지면 JPQL이나 Criteria API로는 돌아가기 어려울 정도로 개발 생산성이 향상될 것입니다.
'JPA' 카테고리의 다른 글
| Spring Data JPA 고급 기능 - Specification, Projection, Auditing (0) | 2026.04.03 |
|---|---|
| JPA 성능 최적화 실전 - 벌크 연산부터 2차 캐시까지 (0) | 2026.04.02 |
| JPA 영속성 컨텍스트 완벽 이해 - 1차 캐시부터 변경 감지까지 (0) | 2026.03.26 |
| JPA N+1 문제 완벽 정리 - 원인부터 해결까지 실무 가이드 (0) | 2026.03.24 |