QueryDSL

Neo-Renaissance·2025년 5월 7일

💡QueryDSL을 사용하는 이유가 뭘까??

우선 QueryDSL을 사용하는 이유를 알아보기 위해서는 Spring Data JPA와 비교를 해봐야합니다.

Spring Data JPA의 문제점

1. 문자열 기반 쿼리 관리의 어려움

JPQL은 문자열로 작성되기 때문에 오타나 문법 오류를 컴파일 시점에 검출할 수 없고 이는 런타임 오류로 이어질 가능성이 높음.

*// JPQL 예시: 컴파일 시 오류 검출 불가*
String jpql = "SELECT m FROM Membr m WHERE m.username = :username"; *// 'Member' 오타*
List<Member> result = em.createQuery(jpql, Member.class)
    .setParameter("username", "john")
    .getResultList();

2. 조건식 재활용 불가

JPQL에서는 조건식을 재사용하기 어려워서 동일한 조건을 여러 쿼리에 반복적으로 작성해야 하며, 이로 인해서 코드 중복과 유지보수 비용 증가.

3. 복잡한 동적 쿼리 작성의 어려움

동적 검색 조건이 많아질 경우 JPQL은 코드가 복잡해지고 가독성이 떨어지고 조건에 따라 문자열을 조합하거나 if-else 문을 남발.

*// JPQL 동적 쿼리 예시*
String jpql = "SELECT m FROM Member m WHERE 1=1";
if (username != null) {
    jpql += " AND m.username = :username";
}
if (age != null) {
    jpql += " AND m.age > :age";
}
Query query = em.createQuery(jpql);

4. 타입 안전성 부족

JPQL은 문자열 기반이므로 필드명 오타나 잘못된 타입 사용을 컴파일 시점에 검출할 수 없습니다.

반면에 QueryDSL은

1. 타입 안전성과 컴파일 시점 오류 방지

QueryDSL은 Java 코드로 쿼리를 작성하므로, 필드명이나 메서드 호출에서 발생하는 오류를 컴파일 시점에 검출할 수 있습니다.

*// QueryDSL 예시: 타입 안전성과 오류 방지*
List<Member> result = queryFactory
    .selectFrom(QMember.member)
    .where(QMember.member.username.eq("john"))
    .fetch();
  • Q클래스 기반 쿼리 작성 QueryDSL은 엔티티 클래스를 기반으로 Q클래스(Query 클래스)를 자동 생성해주는데 이 Q클래스는 엔티티의 모든 필드와 메서드를 정적 타입으로 포함하며 컴파일러가 이를 검증.
*// QMember 클래스 예시 (자동 생성)*
public class QMember extends EntityPathBase<Member> {
    public static final QMember member = new QMember("member");
    public final StringPath username = createString("username");
    public final NumberPath<Integer> age = createNumber("age", Integer.class);
    ...
}
  • QueryDSL의 Q클래스에서 위와 같은 StringPath와 NumberPath는 각각 문자열과 숫자 타입의 필드를 표현하기 위해 사용되는 클래스로 이들은 QueryDSL의 타입 안전성을 지원하는 중요한 요소로, 쿼리 작성 시 데이터 타입에 맞는 연산만 허용하여 오류를 방지함.
  • 타입 정보 보존: Q클래스의 각 필드는 실제 엔티티의 타입을 정확히 반영. 예를 들어 member.age는 NumberPath<Integer> 타입으로 정의되어, 정수 비교만 허용.
    .where(member.age.eq("20"))  *// 컴파일 에러: String → Integer 타입 불일치*

2. 조건식 재활용 가능

BooleanExpression을 사용하여 조건식을 메서드로 분리하고 재사용할 수 있습니다.

*// 조건식 메서드 분리*
private BooleanExpression usernameEq(String username) {
    return username != null ? QMember.member.username.eq(username) : null;
}

private BooleanExpression ageGt(Integer age) {
    return age != null ? QMember.member.age.gt(age) : null;
}

*// 재사용 가능한 쿼리*
List<Member> result = queryFactory
    .selectFrom(QMember.member)
    .where(usernameEq("john"), ageGt(20))
    .fetch();

3. 깔끔한 동적 쿼리 작성

BooleanBuilder를 사용하여 조건을 유연하게 조합할 수 있습니다.

BooleanBuilder builder = new BooleanBuilder();
if (username != null) {
    builder.and(QMember.member.username.eq(username));
}
if (age != null) {
    builder.and(QMember.member.age.gt(age));
}

List<Member> result = queryFactory
    .selectFrom(QMember.member)
    .where(builder)
    .fetch();

1. BooleanExpression: 레고 블록 조립하기

조건을 재사용 가능한 모듈로 만들어 쿼리를 구성하는 방식입니다.

장점: 코드 중복 방지, 가독성 향상, 타입 안전성 보장

*// 1. 조건을 레고 블록처럼 생성*
private BooleanExpression isAdult() {
    return member.age.goe(20); *// 20세 이상*
}

private BooleanExpression isFromSeoul() {
    return member.city.eq("서울");
}

private BooleanExpression hasEmail() {
    return member.email.isNotNull();
}

*// 2. 블록 조립*
List<Member> result = queryFactory
    .selectFrom(member)
    .where(
        isAdult(),
        isFromSeoul(),
        hasEmail()
    )
    .fetch();

실제 활용 사례

  • 공통 필터 조건
  • 복잡한 조건의 조합 (A이면서 B이거나 C인 경우)

2. BooleanBuilder: 자유로운 조립 도구

런타임에 동적으로 조건을 추가할 수 있는 빌더입니다.

장점: 유연한 조건 처리, 복잡한 분기 로직 수용

public List<Member> dynamicSearch(String name, Integer age, String city) {
    BooleanBuilder builder = new BooleanBuilder();
    
    *// 이름 검색 (부분 일치)*
    if (StringUtils.hasText(name)) {
        builder.and(member.name.contains(name));
    }
    
    *// 나이 범위 검색*
    if (age != null) {
        builder.and(member.age.between(age - 5, age + 5));
    }
    
    *// 지역 검색 (다중 선택 가능)*
    if (StringUtils.hasText(city)) {
        builder.and(member.city.in(city.split(",")));
    }
    
    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}

실제 활용 사례

  • 관리자 페이지의 다중 필터 검색
  • 사용자 입력에 따른 실시간 검색 조건 변경

3. 어떤 것을 사용할까?

상황추천 방식예시
공통으로 재사용되는 조건BooleanExpression활성 사용자, 특정 권한 보유
3개 이상의 동적 조건BooleanBuilder관리자 검색 필터
간단한 정적 조건BooleanExpression나이 30세 이상
복잡한 분기 로직BooleanBuilderA/B 테스트 그룹별 필터링

따라서

BooleanExpression은 재사용성과 가독성 제공

BooleanBuilder는 유연성 제공

4. 복잡한 조인과 서브쿼리 처리

QueryDSL은 연관관계가 없는 테이블 간 조인이나 서브쿼리도 쉽게 처리할 수 있습니다.

*// 조인 예시*
List<Order> orders = queryFactory.selectFrom(QOrder.order)
    .join(QOrder.order.member, QMember.member).fetchJoin()
    .where(QMember.member.city.eq("Seoul"))
    .fetch();
-> OrderMember 엔티티를 조인하여 서울에 사는 회원의 주문 목록을 한 번의 
쿼리로 효율적으로 조회하고 N+1 문제 방지를 위해 fetchJoin()을 사용해 
연관된 Member 엔티티를 즉시 로딩합니다.
 
*// 서브쿼리 예시*
QMember subMember = new QMember("subMember");
List<Member> result = queryFactory.selectFrom(QMember.member)
    .where(QMember.member.age.gt(
        JPAExpressions.select(subMember.age.avg())
            .from(subMember)
    ))
    .fetch();
-> 전체 회원의 평균 나이보다 많은 회원을 조회하는 코드로 
JPAEXpressions으로 서브쿼리를 생성해 복잡한 조건을 간결하게 표현합니다.

실제 예시

기존 JPA

List<TripPlan> findByStatus(Status status);

@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING'")
List<TripPlan> findAllOngoingTripPlans();

@Query("SELECT tp FROM TripPlan tp WHERE tp.status = 'ONGOING' OR (tp.status = 'COMPLETED' AND tp.tripPlanType = 'COURSE')")
List<TripPlan> findAllOngoingAndCompletedCourseTripPlans();

QueryDSL 도입

public interface TripPlanRepositoryCustom {
    List<TripPlan> findAllOngoingAndCompletedCourseTripPlans();
}

public class TripPlanRepositoryImpl implements TripPlanRepositoryCustom {
    
    private final JPAQueryFactory queryFactory;
    
    public TripPlanRepositoryImpl(JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    @Override
    public List<TripPlan> findAllOngoingAndCompletedCourseTripPlans() {
        QTripPlan tripPlan = QTripPlan.tripPlan;
        *// 1. 조건 분리 (재사용 가능)*

				BooleanExpression isOngoing = tripPlan.status.eq(Status.ONGOING);
        BooleanExpression isCompletedCourse = tripPlan.status.eq(Status.COMPLETED)
                                                .and(tripPlan.tripPlanType.eq(TripPlanType.COURSE));

				*// 2. 조건 조합*

								return queryFactory
                .selectFrom(tripPlan)
                .where(isOngoing.or(isCompletedCourse))
                .fetch();
	   }
}

결론으로는 QueryDSL은 Spring Data JPA의 대체제가 아니라 함께 사용 가능한 보완재로 서로 부족한 점으로 상호보완하는 도구로 사용하면 좋을 거 같습니다.

profile
if (실패) { 다시 도전; } else { 성공; }

0개의 댓글