자바 백엔드 입문 50편 — QueryDSL 타입 안전 동적 쿼리

2026-05-17자바 백엔드 입문

자바 백엔드 입문 50편. JPQL 문자열의 함정을 자바 코드로 풀어내는 QueryDSL의 타입 안전 동적 쿼리 표준 패턴을 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 50편 — QueryDSL 타입 안전 동적 쿼리

이 글은 자바 백엔드 입문 시리즈 59편 중 50편이에요. 46편 쿼리 메서드 + 49편 JPA 관계 까지 다루면 — "이름만으로 쿼리 자동 생성" 의 한계가 등장. 검색 조건이 10개+ 인 화면, 어떤 조건이 있고 없는지 동적인 쿼리 — 이때 등장하는 QueryDSL 을 풀어 가요.

JPQL 문자열의 함정

복잡한 검색 쿼리를 JPQL로 짜면:

@Query("""
    SELECT o FROM Order o
    WHERE (:status IS NULL OR o.status = :status)
    AND (:startDate IS NULL OR o.createdAt >= :startDate)
    AND (:endDate IS NULL OR o.createdAt <= :endDate)
    AND (:minAmount IS NULL OR o.amount >= :minAmount)
    AND (:userId IS NULL OR o.user.id = :userId)
    ORDER BY o.createdAt DESC
    """)
List<Order> search(@Param("status") String status,
                   @Param("startDate") LocalDateTime startDate,
                   @Param("endDate") LocalDateTime endDate,
                   @Param("minAmount") Long minAmount,
                   @Param("userId") Long userId);

문제: - 문자열 — IDE 오타 검사 X, 컬럼명 바꾸면 런타임에 폭발 - 모든 조건 OR 패턴 — 6필드면 6개의 IS NULL OR ... - 정렬 동적 변경 = 거의 불가 - 페이징도 별도 처리 - 검색 화면 하나 만들기에 200줄

해결 = QueryDSL. 자바 코드로 SQL 짜는 라이브러리.

QueryDSL — 자바로 SQL 짜기

List<Order> orders = queryFactory
        .selectFrom(order)
        .where(
            statusEq(status),
            createdAtBetween(startDate, endDate),
            amountGoe(minAmount),
            userIdEq(userId))
        .orderBy(order.createdAt.desc())
        .fetch();

깔끔하지 않나요? where 안의 매개변수가 null이면 자동으로 무시 — "동적 조건" 이 자연스럽게 표현.

설정 — Spring Boot 3 + Gradle

dependencies {
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

10편 Maven·Gradle 의 annotationProcessor 가 빌드 시 Q 클래스 자동 생성.

./gradlew compileJavabuild/generated/sources/annotationProcessor/QOrder·QUser 같은 "메타 클래스" 생성.

Q 클래스 — 컴파일 타임 타입 안전

엔티티마다 자동 생성되는 Q 클래스:

// 자동 생성
public class QOrder extends EntityPathBase<Order> {
    public static final QOrder order = new QOrder("order");

    public final NumberPath<Long> id = createNumber("id", Long.class);
    public final StringPath status = createString("status");
    public final DateTimePath<LocalDateTime> createdAt = createDateTime("createdAt", LocalDateTime.class);
    public final NumberPath<Long> amount = createNumber("amount", Long.class);
    public final QUser user;
}

이 Q 클래스로 — order.status·order.createdAt 같은 표현이 "엔티티 필드를 자바 코드로 참조". IDE 자동완성·타입 검사 모두 작동.

빈 등록 — JPAQueryFactory

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager em;

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

또는 클래스 안에서 직접:

@Repository
@RequiredArgsConstructor
public class OrderQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<Order> search(...) {
        return queryFactory.selectFrom(order).fetch();
    }
}

기본 쿼리 — select·from·where·orderBy

import static com.example.QOrder.order;

public List<Order> findActiveOrders() {
    return queryFactory
            .selectFrom(order)
            .where(order.status.eq("ACTIVE"))
            .orderBy(order.createdAt.desc())
            .fetch();
}

JPQL과 비슷한 구조지만 — "문자열이 자바 표현식" 으로.

자주 쓰는 조건 표현

표현 SQL
order.status.eq("ACTIVE") status = 'ACTIVE'
order.status.ne("CANCELED") status <> 'CANCELED'
order.amount.goe(1000) amount >= 1000
order.amount.lt(5000) amount < 5000
order.amount.between(1000, 5000) BETWEEN ...
order.status.in("A", "B") IN ('A', 'B')
order.createdAt.after(date) > date
order.name.contains("foo") LIKE '%foo%'
order.name.startsWith("A") LIKE 'A%'
order.deletedAt.isNull() IS NULL
condition1.and(condition2) AND
condition1.or(condition2) OR

자바 메서드 체이닝 — IDE 자동완성·타입 검사 다 작동.

동적 쿼리 — 진짜 매력

QueryDSL의 진수 — null이면 자동 무시되는 동적 조건.

public List<Order> search(String status, LocalDateTime startDate, Long userId) {
    return queryFactory
            .selectFrom(order)
            .where(
                statusEq(status),                       // null이면 무시
                createdAtAfter(startDate),              // null이면 무시
                userIdEq(userId))                       // null이면 무시
            .orderBy(order.createdAt.desc())
            .fetch();
}

private BooleanExpression statusEq(String status) {
    return status == null ? null : order.status.eq(status);
}

private BooleanExpression createdAtAfter(LocalDateTime dt) {
    return dt == null ? null : order.createdAt.after(dt);
}

private BooleanExpression userIdEq(Long userId) {
    return userId == null ? null : order.user.id.eq(userId);
}

.where(...) 안에 null을 박으면 — QueryDSL이 자동으로 조건에서 제외. 검색 화면 백엔드의 한국 회사 표준 패턴.

BooleanBuilder — 옛 스타일

위 패턴 이전엔 BooleanBuilder 도 자주 사용:

BooleanBuilder builder = new BooleanBuilder();
if (status != null) builder.and(order.status.eq(status));
if (startDate != null) builder.and(order.createdAt.after(startDate));
if (userId != null) builder.and(order.user.id.eq(userId));

return queryFactory.selectFrom(order).where(builder).fetch();

작동은 하지만 — null 자동 무시 패턴이 더 깔끔. 신규는 후자 권장.

DTO 직접 조회 — Projection

엔티티 전체 가져오지 않고 — "필요한 컬럼만" DTO로.

public record OrderSummary(Long id, String status, Long amount) { }

public List<OrderSummary> findSummaries() {
    return queryFactory
            .select(Projections.constructor(OrderSummary.class,
                    order.id, order.status, order.amount))
            .from(order)
            .fetch();
}

Projections.constructor — DTO 생성자 호출. 자바 17+ record와 좋은 조합.

JOIN — 깔끔한 표현

import static com.example.QOrder.order;
import static com.example.QUser.user;

public List<Order> findWithUser() {
    return queryFactory
            .selectFrom(order)
            .join(order.user, user).fetchJoin()        // ← Lazy 회피 (N+1 방지)
            .where(user.status.eq("ACTIVE"))
            .fetch();
}

47편 영속성 컨텍스트 의 N+1 문제 — fetchJoin() 으로 해결.

페이징 — Spring Data 연동

public Page<Order> search(SearchCondition cond, Pageable pageable) {
    List<Order> content = queryFactory
            .selectFrom(order)
            .where(buildWhere(cond))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(order.createdAt.desc())
            .fetch();

    Long total = queryFactory
            .select(order.count())
            .from(order)
            .where(buildWhere(cond))
            .fetchOne();

    return new PageImpl<>(content, pageable, total);
}

검색 화면 페이징 표준 패턴.

동적 정렬

public List<Order> search(SearchCondition cond, String sortBy) {
    OrderSpecifier<?> orderSpec = "amount".equals(sortBy)
            ? order.amount.desc()
            : order.createdAt.desc();

    return queryFactory
            .selectFrom(order)
            .where(buildWhere(cond))
            .orderBy(orderSpec)
            .fetch();
}

JPQL로는 거의 불가능한 동적 정렬 — QueryDSL은 자연스러움.

Spring Data JPA + QueryDSL 통합

JPA Repository에 QueryDSL 메서드를 끼우는 표준 패턴.

public interface OrderRepository extends JpaRepository<Order, Long>, OrderQueryRepository {
    // 기본 메서드 + 쿼리 메서드 + QueryDSL 메서드
}

public interface OrderQueryRepository {
    List<Order> search(SearchCondition cond);
}

@Repository
@RequiredArgsConstructor
public class OrderQueryRepositoryImpl implements OrderQueryRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<Order> search(SearchCondition cond) {
        return queryFactory.selectFrom(order)
                .where(buildWhere(cond))
                .fetch();
    }
}

이름Impl 규칙으로 Spring Data가 자동 연결. 한국 회사 모든 신규 프로젝트의 표준 구조.

함정 5가지

(1) Q 클래스 빌드 안 됨

QOrder cannot be resolved to a type

annotationProcessor 빠짐 또는 빌드 안 함. ./gradlew compileJava 한 번 돌리면 해결.

(2) select(...) 빠뜨림

queryFactory.selectFrom(order)...   // ✅ Order 전체
queryFactory.select(order)...        // ⚠️ from 누락 (가능은 하지만 .from 필수)

selectFrom(...) 한 줄이 select + from 단축. 헷갈리기 쉬움.

(3) fetch() 누락

List<Order> orders = queryFactory.selectFrom(order).where(...);  // ❌ 컴파일 에러 또는 JPAQuery 반환

.fetch() 호출해야 비로소 결과 얻음. JPQL의 getResultList() 와 비슷.

(4) fetchOne() vs fetchFirst()

  • fetchOne() — 결과가 2개 이상이면 NonUniqueResultException
  • fetchFirst() — 첫 결과만, 안전

단건 조회 의도면 — fetchOne() (검증 보너스). "있을 수도 없을 수도" 면 — Optional 로 감싸기.

(5) N+1 발생

List<Order> orders = queryFactory.selectFrom(order).fetch();
orders.forEach(o -> log.info(o.getUser().getName()));   // ← user 매 건 조회 (N+1!)

fetchJoin() 박기. 47편 영속성 컨텍스트 참고.

🎯 한국 회사 사실상 표준

검색 화면 백엔드 = QueryDSL 거의 100%. JPA 쿼리 메서드는 단순 조회, JPQL은 가끔, QueryDSL은 복잡한 동적 쿼리. 자바 백엔드 면접에서 가장 자주 묻는 도구.

한 줄 정리 — QueryDSL = 타입 안전 동적 쿼리 라이브러리. annotationProcessor가 Q 클래스 자동 생성. .where(...) 안 null 자동 무시 패턴이 한국 회사 검색 백엔드 표준. Spring Data JPA + QueryDSL 통합 = 모던 자바 백엔드 사실상 표준.

시험 직전 한 번 더 — QueryDSL 입문자가 매번 헷갈리는 것

  • QueryDSL = 자바 코드로 SQL 짜기 라이브러리
  • 타입 안전 — 컴파일 시점 검증, IDE 자동완성
  • 의존성 = querydsl-jpa:jakarta + annotationProcessor
  • Q 클래스 = 엔티티마다 자동 생성 (QOrder·QUser)
  • 빌드 = ./gradlew compileJavabuild/generated/sources/...
  • JPAQueryFactory = 쿼리 시작점 빈
  • 기본 = selectFrom(...).where(...).orderBy(...).fetch()
  • selectFrom(order) = select + from 단축
  • 조건 표현 = .eq·.ne·.goe·.lt·.between·.in·.contains 등 풍부
  • 동적 조건 = .where(null) 자동 무시 (한국 회사 표준 패턴)
  • BooleanExpression 메서드 분리 = private BooleanExpression statusEq(...) 패턴
  • BooleanBuilder = 옛 스타일 (신규는 null 무시 패턴 권장)
  • Projection = Projections.constructor(DTO.class, ...) — DTO 직접 조회
  • record + Projection 조합 = 모던 표준
  • fetchJoin() = N+1 회피 (Lazy 즉시 로드)
  • 페이징 = .offset(...).limit(...) + 카운트 쿼리 별도
  • 동적 정렬 = OrderSpecifier<?> 변수로 분기
  • Spring Data 통합 = OrderRepository extends JpaRepository<Order, Long>, OrderQueryRepository
  • 구현체 = OrderQueryRepositoryImpl ("Impl" 규칙)
  • fetch() = 리스트, fetchOne() = 단건 (2개+ 시 예외), fetchFirst() = 첫 건
  • 검색 화면 백엔드 = QueryDSL 거의 100%
  • 자바 백엔드 면접 빈출 도구

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!