Spring QueryDSL 동적 쿼리

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이 더 자연스럽다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux