JPA

QueryDSL 실전 가이드 - 동적 쿼리부터 페이징까지

백엔드 개발자 김승원 2026. 3. 26. 18:00

왜 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로는 돌아가기 어려울 정도로 개발 생산성이 향상될 것입니다.