QueryDSL과 동적 쿼리: 검색 조건이 늘어날 때 코드가 망가지지 않는 법

📖 24min read

검색 조건 3개가 10개가 되었을 때 벌어진 일

Spring Security로 인증/인가를 잡고 나니, 드디어 핵심 기능을 본격적으로 만들 차례가 왔다. 그런데 문제는 예상치 못한 곳에서 터졌다. ‘검색’.

처음에 프로젝트 목록 조회는 단순했다. 전체 목록을 가져오면 끝이었다.

List<Project> projects = projectRepository.findAll();

그런데 담당자의 요구사항이 하나씩 늘어나기 시작했다.

“상태별로 필터링 좀 해주세요.”

“기간 검색도 넣어주세요.”

“담당자 이름으로도 검색되어야 해요.”

“지역이랑 카테고리도요.”

처음엔 Spring Data JPA의 메서드 이름 쿼리로 해결했다.

// 조건 1개
List<Project> findByStatus(String status);

// 조건 2개
List<Project> findByStatusAndRegion(String status, String region);

// 조건 3개... 이쯤 되니 메서드 이름이 끔찍해진다
List<Project> findByStatusAndRegionAndCategoryAndManagerNameContaining(
    String status, String region, String category, String managerName);

더 큰 문제는, 이 조건들이 ‘선택적’이라는 것이었다. 사용자가 상태만 선택할 수도 있고, 기간만 넣을 수도 있고, 전부 넣을 수도 있다. 메서드 이름 쿼리로는 모든 조합을 만들 수 없었다.

결국 JPQL과 if문 조합으로 직접 쿼리를 만들기 시작했다.

public List<Project> searchProjects(String status, String region,
                                     String category, String managerName) {
    StringBuilder jpql = new StringBuilder("SELECT p FROM Project p WHERE 1=1");

    if (status != null) {
        jpql.append(" AND p.status = :status");
    }
    if (region != null) {
        jpql.append(" AND p.region = :region");
    }
    if (category != null) {
        jpql.append(" AND p.category = :category");
    }
    if (managerName != null) {
        jpql.append(" AND p.managerName LIKE :managerName");
    }

    TypedQuery<Project> query = em.createQuery(jpql.toString(), Project.class);

    if (status != null) query.setParameter("status", status);
    if (region != null) query.setParameter("region", region);
    if (category != null) query.setParameter("category", category);
    if (managerName != null) query.setParameter("managerName", "%" + managerName + "%");

    return query.getResultList();
}

작동은 한다. 하지만 코드를 보면 알 수 있다. if문이 두 번 반복되고, 문자열로 쿼리를 조립하고, 오타가 나도 컴파일 시점에 잡히지 않는다. 조건이 10개로 늘어나면 이 코드는 관리 불가능해진다.

‘동적 쿼리를 깔끔하게 작성하는 방법이 분명 있을 텐데.’ 그때 알게 된 것이 QueryDSL이었다.

문자열 조립 vs 타입 안전한 빌더. QueryDSL은 쿼리를 ‘코드’로 만든다.

동적 쿼리란 무엇인가

동적 쿼리(Dynamic Query)란, 실행 시점에 조건이 달라지는 쿼리를 말한다.

정적 쿼리는 쿼리 문장이 고정되어 있다. “상태가 ‘ACTIVE’인 프로젝트를 조회해라.” 반면 동적 쿼리는 사용자의 입력에 따라 쿼리가 변한다. “상태가 ‘ACTIVE’이고, 지역이 ‘서울’이고, 카테고리가 ‘농업’인 프로젝트를 조회해라.” 조건이 있을 수도 있고 없을 수도 있다.

구분정적 쿼리동적 쿼리
조건고정실행 시점에 결정
예시findAll(), findByStatus(“ACTIVE”)사용자가 선택한 필터 조합
복잡도낮음조건 수에 비례하여 증가
사용 시점단순 CRUD검색, 필터링, 대시보드

웹 애플리케이션에서 검색 기능이 있는 곳이라면, 어딘가에 반드시 동적 쿼리가 필요하다.


동적 쿼리를 작성하는 방법들

JPA 생태계에서 동적 쿼리를 작성하는 방법은 여러 가지가 있다. 핵심적인 세 가지만 비교해 보자.

1. JPQL + 문자열 조합

앞에서 본 방식이다. 문자열로 쿼리를 직접 조립한다.

StringBuilder jpql = new StringBuilder("SELECT p FROM Project p WHERE 1=1");
if (status != null) jpql.append(" AND p.status = :status");
  • 장점: 별도 라이브러리 불필요, JPA만 있으면 됨
  • 단점: 오타를 컴파일 시점에 잡을 수 없음, if문 반복, 가독성 나쁨

2. Criteria API

JPA 표준에 포함된 타입 세이프 쿼리 빌더다. QueryDSL이 나오기 전에 ‘동적 쿼리의 정석’이었다.

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Project> cq = cb.createQuery(Project.class);
Root<Project> root = cq.from(Project.class);

List<Predicate> predicates = new ArrayList<>();
if (status != null) predicates.add(cb.equal(root.get("status"), status));
if (region != null) predicates.add(cb.equal(root.get("region"), region));

cq.where(predicates.toArray(new Predicate[0]));

타입 안전하지만, 코드가 장황하고 읽기 어렵다. 실무에서 Criteria API를 직접 쓰는 팀은 드물다. 대부분 QueryDSL로 대체한다.

3. QueryDSL

컴파일 시점에 엔티티 기반으로 Q 클래스를 생성하고, 자바 코드로 쿼리를 작성하는 라이브러리다.

QProject project = QProject.project;

List<Project> result = queryFactory
    .selectFrom(project)
    .where(
        statusEq(status),
        regionEq(region),
        categoryEq(category)
    )
    .fetch();

같은 동적 쿼리가 이렇게 짧아진다. 각 조건은 별도의 메서드로 분리되어 있어서, 조건이 10개로 늘어나도 코드 구조가 망가지지 않는다.

비교 정리

항목JPQL + 문자열Criteria APIQueryDSL
타입 안전성없음 (문자열)있음있음
가독성나쁨나쁨좋음
동적 쿼리if문 반복가능하지만 장황깔끔한 조건 조합
추가 설정없음없음Q 클래스 생성 필요
실무 선호도간단한 쿼리거의 안 씀동적 쿼리의 사실상 표준

그렇다고 모든 쿼리를 QueryDSL로 작성할 필요는 없다. 단순한 CRUD는 Spring Data JPA의 메서드 이름 쿼리로 충분하고, 복잡한 집계나 DB 고유 기능이 필요하면 Native Query나 MyBatis가 적합하다. 도구를 구분해서 쓰는 것이 핵심이다.

상황추천 도구
단순 CRUD (findById, save, delete)Spring Data JPA
정적 조회 쿼리 (고정 조건)JPQL (@Query)
동적 검색/필터링QueryDSL
복잡한 집계, DB 전용 함수Native Query
SQL 통제가 중요한 읽기 전용 쿼리MyBatis
모든 쿼리를 하나의 도구로 해결하려 하지 마라. 상황에 맞는 도구를 골라 쓰자.

QueryDSL 시작하기

QueryDSL을 사용하려면 빌드 설정에서 Q 클래스를 생성하도록 해야 한다. 엔티티 클래스를 기반으로 QProject, QMember 같은 쿼리 전용 클래스를 컴파일 시점에 자동 생성하는 것이다.

// build.gradle (Spring Boot 2.x + Java 8 기준)
dependencies {
    implementation 'com.querydsl:querydsl-jpa'
    annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
    annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}

Spring Boot 3 + Hibernate 6 환경에서는 querydsl-jpa에 ‘:jakarta’ classifier를 붙이고, javax 대신 jakarta 패키지를 사용한다. 이 글에서는 회사 스택 기준으로 Boot 2.x 설정을 따른다.

빌드하면 target(또는 build) 폴더에 Q 클래스가 생성된다.

// 엔티티
@Entity
public class Project {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private String status;
    private String region;
    private String category;
    private String managerName;
    private LocalDate startDate;
    private LocalDate endDate;
}

// 자동 생성되는 Q 클래스 (직접 작성하지 않는다)
// QProject.project 로 접근

QueryDSL을 사용하기 위한 JPAQueryFactory를 Bean으로 등록한다.

@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }
}

이것으로 설정은 끝이다.


동적 쿼리 실전: 두 가지 접근법

같은 검색 요구사항을 두 가지 방식으로 구현해 보자. “프로젝트를 상태, 지역, 카테고리, 담당자 이름, 기간으로 검색한다. 모든 조건은 선택적이다.”

접근법 1: BooleanBuilder

BooleanBuilder는 조건을 하나씩 추가하는 방식이다. 빠르게 구현하기에 좋다.

public List<Project> searchWithBuilder(String status, String region,
                                        String category, String managerName,
                                        LocalDate startDate, LocalDate endDate) {
    QProject project = QProject.project;
    BooleanBuilder builder = new BooleanBuilder();

    if (status != null) {
        builder.and(project.status.eq(status));
    }
    if (region != null) {
        builder.and(project.region.eq(region));
    }
    if (category != null) {
        builder.and(project.category.eq(category));
    }
    if (managerName != null) {
        builder.and(project.managerName.contains(managerName));
    }
    if (startDate != null && endDate != null) {
        builder.and(project.startDate.between(startDate, endDate));
    }

    return queryFactory
        .selectFrom(project)
        .where(builder)
        .fetch();
}

JPQL 문자열 조립보다 훨씬 낫다. 타입 안전하고, 오타가 컴파일에 잡힌다. 하지만 여전히 if문이 반복된다. 조건이 늘어나면 메서드가 길어지고, 다른 조회에서 같은 조건을 재사용하기 어렵다.

접근법 2: BooleanExpression 조합 (추천)

조건마다 별도의 메서드로 분리하고, where 절에서 조합하는 방식이다.

public List<Project> searchWithExpression(String status, String region,
                                           String category, String managerName,
                                           LocalDate startDate, LocalDate endDate) {
    QProject project = QProject.project;

    return queryFactory
        .selectFrom(project)
        .where(
            statusEq(status),
            regionEq(region),
            categoryEq(category),
            managerNameContains(managerName),
            dateBetween(startDate, endDate)
        )
        .fetch();
}

// 조건 메서드들 - null을 반환하면 where절에서 자동 무시됨
private BooleanExpression statusEq(String status) {
    return status != null ? QProject.project.status.eq(status) : null;
}

private BooleanExpression regionEq(String region) {
    return region != null ? QProject.project.region.eq(region) : null;
}

private BooleanExpression categoryEq(String category) {
    return category != null ? QProject.project.category.eq(category) : null;
}

private BooleanExpression managerNameContains(String name) {
    return name != null ? QProject.project.managerName.contains(name) : null;
}

private BooleanExpression dateBetween(LocalDate start, LocalDate end) {
    if (start == null || end == null) return null;
    return QProject.project.startDate.between(start, end);
}

핵심은 where 절에 null이 들어가면 QueryDSL이 해당 조건을 무시한다는 것이다. 그래서 if문 없이도 선택적 조건이 자연스럽게 처리된다.

두 방식 비교

항목BooleanBuilderBooleanExpression
if문여전히 필요불필요 (null 반환으로 처리)
조건 재사용어려움 (builder 내부에 묶임)쉬움 (메서드 단위로 분리)
가독성보통좋음 (where절이 선언적)
조합builder.and/or로 수동 조합메서드끼리 .and()/.or()로 조합
추천 시점빠른 프로토타입, 단순한 경우실무 프로덕션 코드

BooleanExpression 방식의 또 다른 장점은 조건을 조합할 수 있다는 것이다.

// 두 조건을 합쳐서 새로운 조건 생성
private BooleanExpression activeInRegion(String region) {
    return statusEq("ACTIVE").and(regionEq(region));
}

다른 조회 메서드에서도 statusEq, regionEq 같은 조건 메서드를 그대로 가져다 쓸 수 있다. 조건이 20개가 되어도 where 절은 깔끔하게 유지된다.

BooleanBuilder는 수동 검사, BooleanExpression은 자동 조립. 실무에서는 후자가 유지보수에 강하다.

실무 조언

1. Repository 구조: Custom Repository 패턴

QueryDSL 코드는 Spring Data JPA의 Repository 인터페이스와 분리하는 것이 일반적이다.

// 1. 커스텀 인터페이스 정의
public interface ProjectRepositoryCustom {
    List<Project> search(ProjectSearchCondition condition);
}

// 2. 구현체 (Impl 접미사 필수)
public class ProjectRepositoryImpl implements ProjectRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public ProjectRepositoryImpl(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    @Override
    public List<Project> search(ProjectSearchCondition condition) {
        QProject project = QProject.project;

        return queryFactory
            .selectFrom(project)
            .where(
                statusEq(condition.getStatus()),
                regionEq(condition.getRegion()),
                categoryEq(condition.getCategory())
            )
            .fetch();
    }

    // BooleanExpression 메서드들...
}

// 3. 기존 Repository에 상속 추가
public interface ProjectRepository extends JpaRepository<Project, Long>,
                                            ProjectRepositoryCustom {
}

이렇게 하면 ProjectRepository 하나로 기본 CRUD와 동적 검색을 모두 사용할 수 있다.

2. 검색 조건은 DTO로 묶어라

조건이 5개 이상이면 파라미터 나열 대신 조건 DTO를 만드는 것이 좋다.

@Getter @Setter
public class ProjectSearchCondition {
    private String status;
    private String region;
    private String category;
    private String managerName;
    private LocalDate startDate;
    private LocalDate endDate;
}

3. QueryDSL로 전부 해결하려 하지 마라

앞서 정리한 도구 선택 기준을 다시 강조한다. 단순 조회는 Spring Data JPA로, 동적 검색은 QueryDSL로, 복잡한 통계/집계는 Native Query나 MyBatis로. 회사에서 QueryDSL과 MyBatis를 함께 쓰는 것도 이 때문이다. “이 쿼리에 가장 적합한 도구가 뭔가?”를 매번 판단하는 것이 중요하다.

4. Q 클래스 관리

Q 클래스는 빌드 시 자동 생성되므로 Git에 커밋하지 않는다. .gitignore에 추가해 두자.

# QueryDSL generated
**/generated/**
**/Q*.java

마치며: 코드가 망가지지 않는 검색

검색 조건이 3개에서 10개로 늘어나는 건 흔한 일이다. 문제는 조건이 늘어날 때 코드 구조가 함께 망가지느냐, 아니냐의 차이다.

JPQL 문자열 조합은 빠르지만 금방 한계에 부딪히고, QueryDSL의 BooleanExpression 패턴은 조건이 아무리 늘어나도 where 절의 구조가 변하지 않는다. 각 조건이 독립된 메서드이므로, 추가도 쉽고 재사용도 된다.

그런데 QueryDSL로 검색을 깔끔하게 만들었더니, 이번에는 다른 문제가 보이기 시작했다. 목록 조회에서 관련 데이터를 함께 가져올 때, 쿼리가 예상보다 훨씬 많이 실행되고 있었다. 로그를 열어보니 프로젝트 10개를 조회하는데 쿼리가 11개 나가고 있었다.

다음 글에서는 JPA를 쓰면 거의 반드시 만나게 되는 이 ‘N+1 문제’와, QueryDSL에서 fetch join으로 이를 해결하는 방법을 다뤄보겠다.

“QueryDSL과 동적 쿼리: 검색 조건이 늘어날 때 코드가 망가지지 않는 법”에 대한 1개의 생각

댓글 남기기