2026년 02월 08일

🔍 QueryDSL 완벽 가이드

Java Spring Boot
Cover Image

🔍 QueryDSL 완벽 가이드

타입 안전한 자바 코드로 복잡한 쿼리를 작성하는 최고의 방법

💡 QueryDSL이란?

QueryDSL: 자바 코드로 타입 안전한(Type-Safe) 쿼리를 작성하는 프레임워크

핵심 개념

문자열 JPQL ❌ → 자바 코드 ✅
런타임 오류 ❌ → 컴파일 오류 ✅
오타 가능 ❌ → IDE 자동완성 ✅

간단한 비교

// ❌ JPQL (문자열 기반)
String jpql = "SELECT m FROM Member m WHERE m.age >= :age";
List<Member> result = em.createQuery(jpql, Member.class)
    .setParameter("age", 20)
    .getResultList();
// 오타 → 런타임 에러!

// ✅ QueryDSL (자바 코드)
QMember member = QMember.member;

List<Member> result = queryFactory
    .selectFrom(member)
    .where(member.age.goe(20))  // greater or equal
    .fetch();
// 오타 → 컴파일 에러! (IDE에서 빨간 밑줄)

🎯 QueryDSL의 4가지 핵심 특징

1️⃣ 타입 안전성 (Type Safety)

// ❌ JPQL - 런타임 오류
String jpql = "SELECT m FROM Member m WHERE m.agee = 20";  // 오타!
// 실행하기 전까지 모름 → 프로덕션에서 터질 수 있음

// ✅ QueryDSL - 컴파일 오류
queryFactory
    .selectFrom(member)
    .where(member.agee.eq(20))  // 컴파일 에러! IDE가 즉시 알려줌
    .fetch();

2️⃣ IDE 자동완성

QMember member = QMember.member;

queryFactory
    .selectFrom(member)
    .where(member.  // <-- 여기서 Ctrl+Space
        // age ✅
        // name ✅
        // email ✅
        // ... 모든 필드 자동완성!
    )

3️⃣ 객체 지향적 쿼리

// 메서드 체이닝으로 읽기 쉬운 코드
List<Member> result = queryFactory
    .selectFrom(member)
    .where(
        member.age.between(20, 30),
        member.status.eq(MemberStatus.ACTIVE)
    )
    .orderBy(member.name.asc())
    .limit(10)
    .fetch();

4️⃣ 동적 쿼리에 강력함

// ✅ 조건이 있을 때만 추가 (null 안전)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
    return queryFactory
        .selectFrom(member)
        .where(
            nameEq(name),      // null이면 무시
            ageGoe(age),       // null이면 무시
            statusEq(status)   // null이면 무시
        )
        .fetch();
}

private BooleanExpression nameEq(String name) {
    return name != null ? member.name.eq(name) : null;
}

private BooleanExpression ageGoe(Integer age) {
    return age != null ? member.age.goe(age) : null;
}

private BooleanExpression statusEq(MemberStatus status) {
    return status != null ? member.status.eq(status) : null;
}

🛠️ QueryDSL 설정하기

1️⃣ Gradle 의존성 추가

// build.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"
}

// Q타입 클래스 생성 위치 지정
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
    main.java.srcDirs += [querydslDir]
}

tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslDir)
}

clean {
    delete file(querydslDir)
}

2️⃣ Config 설정

@Configuration
public class QueryDslConfig {

    @PersistenceContext
    private EntityManager entityManager;

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

3️⃣ Q타입 클래스 생성

# Gradle 빌드 실행
./gradlew clean build

# build/generated/querydsl 디렉토리에 Q타입 클래스 생성됨
# QMember.java
# QOrder.java
# QProduct.java
# ...

📝 실무 사용 사례

사례 1️⃣: 동적 검색 조건 (검색 필터)

@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable) {
        QMember member = QMember.member;

        List<Member> content = queryFactory
            .selectFrom(member)
            .where(
                nameContains(condition.getName()),
                emailContains(condition.getEmail()),
                ageBetween(condition.getMinAge(), condition.getMaxAge()),
                statusEq(condition.getStatus()),
                createdDateBetween(condition.getStartDate(), condition.getEndDate())
            )
            .orderBy(getOrderSpecifier(pageable.getSort()))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();

        Long total = queryFactory
            .select(member.count())
            .from(member)
            .where(
                nameContains(condition.getName()),
                emailContains(condition.getEmail()),
                ageBetween(condition.getMinAge(), condition.getMaxAge()),
                statusEq(condition.getStatus()),
                createdDateBetween(condition.getStartDate(), condition.getEndDate())
            )
            .fetchOne();

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

    // 조건 메서드들 (null 안전)
    private BooleanExpression nameContains(String name) {
        return hasText(name) ? member.name.contains(name) : null;
    }

    private BooleanExpression emailContains(String email) {
        return hasText(email) ? member.email.contains(email) : null;
    }

    private BooleanExpression ageBetween(Integer minAge, Integer maxAge) {
        if (minAge != null && maxAge != null) {
            return member.age.between(minAge, maxAge);
        }
        if (minAge != null) {
            return member.age.goe(minAge);
        }
        if (maxAge != null) {
            return member.age.loe(maxAge);
        }
        return null;
    }

    private BooleanExpression statusEq(MemberStatus status) {
        return status != null ? member.status.eq(status) : null;
    }

    private BooleanExpression createdDateBetween(LocalDate start, LocalDate end) {
        if (start != null && end != null) {
            return member.createdDate.between(
                start.atStartOfDay(),
                end.atTime(23, 59, 59)
            );
        }
        return null;
    }
}

사용:

@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public Page<MemberResponse> searchMembers(
        MemberSearchCondition condition,
        Pageable pageable
    ) {
        Page<Member> members = memberRepository.searchMembers(condition, pageable);
        return members.map(MemberResponse::from);
    }
}

사례 2️⃣: 복잡한 조인 쿼리

@Repository
@RequiredArgsConstructor
public class OrderRepositoryImpl implements OrderRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<OrderDto> findOrdersWithMemberAndProduct() {
        QOrder order = QOrder.order;
        QMember member = QMember.member;
        QOrderItem orderItem = QOrderItem.orderItem;
        QProduct product = QProduct.product;

        return queryFactory
            .select(Projections.constructor(OrderDto.class,
                order.id,
                member.name,
                order.orderDate,
                order.status,
                product.name,
                orderItem.quantity,
                orderItem.orderPrice
            ))
            .from(order)
            .join(order.member, member)
            .join(order.orderItems, orderItem)
            .join(orderItem.product, product)
            .where(
                order.status.eq(OrderStatus.COMPLETED),
                order.orderDate.after(LocalDateTime.now().minusDays(30))
            )
            .orderBy(order.orderDate.desc())
            .fetch();
    }
}

사례 3️⃣: 집계 쿼리 (통계)

@Repository
@RequiredArgsConstructor
public class OrderStatisticsRepository {

    private final JPAQueryFactory queryFactory;

    public OrderStatistics getMonthlyStatistics(int year, int month) {
        QOrder order = QOrder.order;
        QOrderItem orderItem = QOrderItem.orderItem;

        Tuple result = queryFactory
            .select(
                order.count(),
                orderItem.orderPrice.sum(),
                orderItem.orderPrice.avg(),
                orderItem.orderPrice.max(),
                orderItem.orderPrice.min()
            )
            .from(order)
            .join(order.orderItems, orderItem)
            .where(
                order.orderDate.year().eq(year),
                order.orderDate.month().eq(month),
                order.status.eq(OrderStatus.COMPLETED)
            )
            .fetchOne();

        return OrderStatistics.builder()
            .totalOrders(result.get(order.count()))
            .totalRevenue(result.get(orderItem.orderPrice.sum()))
            .averageOrderValue(result.get(orderItem.orderPrice.avg()))
            .maxOrderValue(result.get(orderItem.orderPrice.max()))
            .minOrderValue(result.get(orderItem.orderPrice.min()))
            .build();
    }

    public List<DailyStatistics> getDailyStatistics(LocalDate start, LocalDate end) {
        QOrder order = QOrder.order;

        return queryFactory
            .select(Projections.constructor(DailyStatistics.class,
                order.orderDate.yearMonth().stringValue(),
                order.orderDate.dayOfMonth(),
                order.count(),
                order.totalPrice.sum()
            ))
            .from(order)
            .where(
                order.orderDate.between(
                    start.atStartOfDay(),
                    end.atTime(23, 59, 59)
                )
            )
            .groupBy(
                order.orderDate.yearMonth(),
                order.orderDate.dayOfMonth()
            )
            .orderBy(order.orderDate.asc())
            .fetch();
    }
}

사례 4️⃣: 서브쿼리

@Repository
@RequiredArgsConstructor
public class ProductRepository {

    private final JPAQueryFactory queryFactory;

    // 평균 가격보다 비싼 상품 조회
    public List<Product> findProductsAboveAveragePrice() {
        QProduct product = QProduct.product;
        QProduct productSub = new QProduct("productSub");

        return queryFactory
            .selectFrom(product)
            .where(
                product.price.gt(
                    JPAExpressions
                        .select(productSub.price.avg())
                        .from(productSub)
                )
            )
            .fetch();
    }

    // 주문이 한 번도 없는 상품
    public List<Product> findNeverOrderedProducts() {
        QProduct product = QProduct.product;
        QOrderItem orderItem = QOrderItem.orderItem;

        return queryFactory
            .selectFrom(product)
            .where(
                product.id.notIn(
                    JPAExpressions
                        .select(orderItem.product.id)
                        .from(orderItem)
                )
            )
            .fetch();
    }
}

🆚 비교: JPQL vs QueryDSL vs Spring Data JPA

JPQL

// ❌ 문자열 기반 - 오타 위험
String jpql = """
    SELECT m FROM Member m
    JOIN FETCH m.orders o
    WHERE m.age >= :age
    AND m.status = :status
    ORDER BY m.name ASC
    """;

List<Member> members = em.createQuery(jpql, Member.class)
    .setParameter("age", 20)
    .setParameter("status", MemberStatus.ACTIVE)
    .getResultList();

QueryDSL

// ✅ 자바 코드 - 타입 안전
QMember member = QMember.member;
QOrder order = QOrder.order;

List<Member> members = queryFactory
    .selectFrom(member)
    .join(member.orders, order).fetchJoin()
    .where(
        member.age.goe(20),
        member.status.eq(MemberStatus.ACTIVE)
    )
    .orderBy(member.name.asc())
    .fetch();

Spring Data JPA

// 🔶 단순한 경우에만 가능
public interface MemberRepository extends JpaRepository<Member, Long> {

    // 메서드 이름으로 쿼리 생성 - 길어지면 복잡함
    List<Member> findByAgeGreaterThanEqualAndStatus(
        int age,
        MemberStatus status
    );

    // @Query - JPQL 문자열 (오타 위험)
    @Query("""
        SELECT m FROM Member m
        WHERE m.age >= :age AND m.status = :status
        """)
    List<Member> findMembers(
        @Param("age") int age,
        @Param("status") MemberStatus status
    );
}

📊 언제 무엇을 사용할까?

상황추천 도구이유
단순 CRUDSpring Data JPA코드 간결
단순 조회 (조건 1~2개)Spring Data JPA메서드 이름으로 충분
복잡한 조회 (조건 3개↑)QueryDSL동적 쿼리 우수
여러 테이블 조인QueryDSL가독성↑
집계/통계 쿼리QueryDSL타입 안전한 집계
관리자 검색 기능QueryDSL필터 조건 다수
네이티브 SQL 필요@Query(nativeQuery)DB 전용 기능

🎯 QueryDSL 사용이 필수인 경우

1️⃣ 검색 조건이 많은 경우

// ❌ JPQL로 동적 쿼리 (if/else 지옥)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
    StringBuilder jpql = new StringBuilder("SELECT m FROM Member m WHERE 1=1");

    if (name != null) {
        jpql.append(" AND m.name = :name");
    }
    if (age != null) {
        jpql.append(" AND m.age >= :age");
    }
    if (status != null) {
        jpql.append(" AND m.status = :status");
    }

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

    if (name != null) query.setParameter("name", name);
    if (age != null) query.setParameter("age", age);
    if (status != null) query.setParameter("status", status);

    return query.getResultList();
}

// ✅ QueryDSL (깔끔!)
public List<Member> searchMembers(String name, Integer age, MemberStatus status) {
    return queryFactory
        .selectFrom(member)
        .where(
            nameEq(name),
            ageGoe(age),
            statusEq(status)
        )
        .fetch();
}

2️⃣ 관리자 페이지 / 대시보드

실무 필터 조건 예시:

@Data
public class ProductSearchCondition {
    private String name;           // 상품명
    private String category;       // 카테고리
    private Integer minPrice;      // 최소 가격
    private Integer maxPrice;      // 최대 가격
    private ProductStatus status;  // 판매 상태
    private LocalDate startDate;   // 등록일 시작
    private LocalDate endDate;     // 등록일 종료
    private String manufacturer;   // 제조사
    private Boolean inStock;       // 재고 여부
}

// QueryDSL로 모든 조건 우아하게 처리
public Page<Product> searchProducts(
    ProductSearchCondition condition,
    Pageable pageable
) {
    return queryFactory
        .selectFrom(product)
        .where(
            nameContains(condition.getName()),
            categoryEq(condition.getCategory()),
            priceBetween(condition.getMinPrice(), condition.getMaxPrice()),
            statusEq(condition.getStatus()),
            registeredDateBetween(condition.getStartDate(), condition.getEndDate()),
            manufacturerEq(condition.getManufacturer()),
            inStockEq(condition.getInStock())
        )
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
}

3️⃣ 대규모·장기 운영 프로젝트

// 엔티티 필드명 변경 시
// Before
private String userName;

// After
private String name;

// ❌ JPQL: 모든 문자열을 수동으로 찾아서 수정
"SELECT m FROM Member m WHERE m.userName = :name"  // 오타 위험!

// ✅ QueryDSL: 자동으로 컴파일 에러 발생
member.userName  // IDE가 즉시 에러 표시
member.name      // 자동완성으로 수정

⚡ QueryDSL 성능 최적화 팁

1️⃣ Fetch Join으로 N+1 방지

// ❌ N+1 문제 발생
List<Member> members = queryFactory
    .selectFrom(member)
    .fetch();

for (Member m : members) {
    m.getOrders().size();  // 각 Member마다 쿼리 실행!
}

// ✅ Fetch Join 사용
List<Member> members = queryFactory
    .selectFrom(member)
    .join(member.orders, order).fetchJoin()
    .fetch();
// 단 1번의 쿼리로 해결!

2️⃣ DTO로 직접 조회 (필요한 컬럼만)

// ❌ 엔티티 전체 조회 (불필요한 컬럼도 포함)
List<Member> members = queryFactory
    .selectFrom(member)
    .fetch();

// ✅ DTO Projection (필요한 컬럼만)
List<MemberDto> members = queryFactory
    .select(Projections.constructor(MemberDto.class,
        member.id,
        member.name,
        member.email
    ))
    .from(member)
    .fetch();

3️⃣ Count 쿼리 최적화

// ❌ 전체 데이터 조회 후 count (비효율)
List<Member> content = queryFactory
    .selectFrom(member)
    .fetch();
long total = content.size();

// ✅ count 쿼리 분리
List<Member> content = queryFactory
    .selectFrom(member)
    .offset(0)
    .limit(10)
    .fetch();

Long total = queryFactory
    .select(member.count())
    .from(member)
    .fetchOne();

🚫 QueryDSL이 필요 없는 경우

❌ 단순 CRUD만 있는 프로젝트

// Spring Data JPA로 충분
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email);
    List<Member> findByStatus(MemberStatus status);
}

❌ 조건 없는 단순 조회

// 복잡도가 낮으면 Spring Data JPA가 더 간단
List<Member> findAll();
Optional<Member> findById(Long id);

❌ 아주 작은 토이 프로젝트


📚 실무 패턴: Custom Repository

Repository 구조

// 1. 기본 Repository
public interface MemberRepository
    extends JpaRepository<Member, Long>, MemberRepositoryCustom {

    // 단순 쿼리는 Spring Data JPA
    Optional<Member> findByEmail(String email);
}

// 2. Custom Interface
public interface MemberRepositoryCustom {
    Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable);
    List<MemberStatistics> getMonthlyStatistics(int year, int month);
}

// 3. QueryDSL 구현체
@Repository
@RequiredArgsConstructor
public class MemberRepositoryImpl implements MemberRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Member> searchMembers(
        MemberSearchCondition condition,
        Pageable pageable
    ) {
        // QueryDSL 구현
        QMember member = QMember.member;

        List<Member> content = queryFactory
            .selectFrom(member)
            .where(/* 동적 조건 */)
            .fetch();

        // ...
    }
}

🎯 핵심 요약

항목내용
정의타입 안전한 자바 코드 기반 쿼리 빌더
핵심 장점컴파일 오류 검출, IDE 자동완성, 동적 쿼리
주요 사용처검색 필터, 관리자 페이지, 복잡한 조회
권장 조합Spring Data JPA (단순) + QueryDSL (복잡)
대체 기술JPQL, Criteria API, MyBatis

💡 한 줄 요약

QueryDSL은 타입 안전한 자바 코드로 동적이고 복잡한 쿼리를 작성하기 위해 사용되며, 검색 조건이 많은 실무 프로젝트에서 필수입니다.


🚀 학습 로드맵

1단계: 기본 문법 익히기
├─ select, from, where
├─ join, fetchJoin
└─ orderBy, limit, offset

2단계: 동적 쿼리 마스터
├─ BooleanExpression
├─ BooleanBuilder
└─ null 안전 처리

3단계: 고급 기능
├─ Projection (DTO 조회)
├─ 서브쿼리
└─ 집계 함수

4단계: 실무 적용
├─ Custom Repository 패턴
├─ 페이징 처리
└─ N+1 문제 해결

📌 추가 자료

공식 문서: http://www.querydsl.com/ 실무 예제: Spring Data JPA + QueryDSL 조합 패턴

Backend 개발자라면 QueryDSL은 필수 스킬입니다! 동적 쿼리가 많은 실무에서 생산성을 획기적으로 높여줍니다. 💪

← 목록으로 돌아가기