프로젝트에서 일정 목록 조회 조건이 늘어나면서, 파생 메서드와 JPQL 문자열만으로는 조회 의도를 유지하기 어려워졌습니다. 진행 중인 일정은 그대로 보여줘야 했고, 완료된 일정 중에서도 코스형 일정은 목록에 함께 남겨야 했습니다.

즉, 단순한 상태 필터가 아니라 상태 + 타입 조합이 필요한 조회가 생기기 시작한 것입니다. 문제는 QueryDSL 문법이 아니었습니다. 제품 요구사항이 복잡해질 때, 그 조건을 코드 단위로 분리하고 조합할 방법이 필요했습니다.

시작은 문자열 기반 쿼리의 한계였습니다

Spring Data JPA와 JPQL은 간단한 조회에는 충분합니다. 다만 조건이 늘어나면 문제가 생깁니다.

첫 번째 문제는 문자열 자체가 쿼리의 진실이 된다는 점입니다.

java
String jpql = "SELECT m FROM Membr m WHERE m.username = :username";

이 쿼리는 Member 오타가 있어도 컴파일 시점에는 알 수 없습니다. 잘못된 필드명, 잘못된 타입, 잘못된 엔티티 이름이 런타임까지 넘어갈 수 있습니다.

두 번째 문제는 조건식 재사용이 어렵다는 점입니다. 같은 조건을 여러 메서드에서 반복하게 되고, 조금만 바뀌어도 문자열을 여러 군데에서 함께 수정해야 했습니다.

세 번째 문제는 동적 조건입니다. 검색 필터가 늘어나면 쿼리 문자열을 직접 조립해야 하고, 코드가 빠르게 읽기 어려워집니다.

java
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";
}

결국 이 문제의 핵심은 문법이 장황하다는 점이 아니라, 조건이 코드 안에서 재사용 가능한 단위가 되지 않는다는 점이었습니다.

실제로는 TripPlan 조회에서 먼저 한계가 드러났습니다

문제가 분명하게 드러난 건 TripPlan 조회 로직이었습니다. 이 조회는 단순히 상태 하나를 기준으로 필터링하는 작업이 아니었습니다. 사용자 화면에서는 진행 중인 일정은 계속 보여줘야 했고, 완료된 일정이라도 코스형 일정은 목록에 함께 남겨야 했습니다.

즉, 제품 요구사항 자체가 이미 상태 한 개가 아니라 상태 + 타입 조합을 요구하고 있었습니다. 그런데 이 조건을 파생 메서드와 JPQL 문자열로만 유지하려고 하니, 조회 이름은 길어지고 조건은 코드 곳곳에 퍼지기 시작했습니다.

단순 상태 조회는 메서드 하나로 충분했습니다.

java
List findByStatus(Status status);

하지만 실제 요구사항을 반영하려면 곧바로 이런 쿼리가 등장했습니다.

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

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

한두 개라면 버틸 수 있습니다. 하지만 같은 패턴이 다른 목록 조회로 퍼지기 시작하면, 문자열 쿼리와 메서드 이름만으로는 어떤 조건 조합이 실제 화면 요구사항을 반영하는지 유지하기 어려워집니다.

이 시점부터 필요한 것은 “더 쉽게 쿼리를 쓰는 방법”이 아니라, 제품 요구사항에서 나온 조건을 코드 단위로 분리하고 다시 조합할 수 있는 방법이었습니다.

QueryDSL은 조건을 코드로 다루게 해줬습니다

QueryDSL을 도입하고 나서 가장 먼저 달라진 것은 쿼리를 Java 코드로 쓴다는 점이 아닙니다. 조건을 메서드로 분리하고 조합할 수 있게 됐다는 점입니다.

java
public class TripPlanRepositoryImpl implements TripPlanRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public List findAllOngoingAndCompletedCourseTripPlans() {
        QTripPlan tripPlan = QTripPlan.tripPlan;

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

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

여기서 중요한 건 문법이 아니라 구조입니다.

  • 조건을 이름 있는 변수나 메서드로 분리할 수 있습니다.
  • 조합 방식이 코드 수준에서 드러납니다.
  • 필드명이나 타입 오류를 컴파일 시점에 확인할 수 있습니다.

즉, QueryDSL의 장점은 “동적 쿼리가 쉽다”보다 “조건이 늘어나도 쿼리의 의도를 유지하기 쉽다”는 데 더 가깝습니다.

Q 클래스가 주는 이점은 타입 안정성 그 이상이었습니다

QueryDSL은 엔티티를 기준으로 Q 클래스를 생성합니다.

java
public class QMember extends EntityPathBase {
    public static final QMember member = new QMember("member");
    public final StringPath username = createString("username");
    public final NumberPath age = createNumber("age", Integer.class);
}

이 구조 덕분에 필드명 오타를 줄일 수 있는 건 맞습니다. 하지만 실제로 더 중요한 건 조건을 다룰 때 어떤 타입인지 계속 잃어버리지 않는다는 점이었습니다.

예를 들어 member.age는 숫자 필드이기 때문에 숫자 비교 메서드만 연결됩니다. 문자열을 넣으면 바로 컴파일 에러가 납니다. 런타임까지 문제를 끌고 가지 않아도 되는 셈입니다.

동적 쿼리는 두 방식으로 나눠서 보는 편이 좋았습니다

QueryDSL을 쓰면 동적 조건을 다룰 때 보통 두 가지 방식이 등장합니다.

조건 재사용이 목적이면 BooleanExpression

공통 조건을 메서드로 분리할 수 있습니다.

java
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 result = queryFactory
    .selectFrom(QMember.member)
    .where(usernameEq("john"), ageGt(20))
    .fetch();

이 방식은 조건 자체가 재사용 가능한 단위가 됩니다. 서비스에서 공통 필터를 여러 조회에 섞어 써야 할 때 특히 유용합니다.

런타임 분기가 더 복잡하면 BooleanBuilder

조건이 많고 입력값 조합이 유동적이면 BooleanBuilder가 더 적합합니다.

java
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 result = queryFactory
    .selectFrom(QMember.member)
    .where(builder)
    .fetch();

정리하면 기준은 단순합니다.

  • 공통 조건을 여러 곳에서 재사용해야 하면 BooleanExpression을 사용합니다.
  • 필터 조합이 많이 달라지면 BooleanBuilder를 사용하는 편이 낫습니다.

복잡한 조회일수록 QueryDSL의 장점이 더 분명했습니다

QueryDSL은 연관관계가 있는 조인, fetch join, 서브쿼리처럼 조합이 복잡해질수록 장점이 더 분명해집니다.

java
List orders = queryFactory.selectFrom(QOrder.order)
    .join(QOrder.order.member, QMember.member).fetchJoin()
    .where(QMember.member.city.eq("Seoul"))
    .fetch();

QMember subMember = new QMember("subMember");
List result = queryFactory.selectFrom(QMember.member)
    .where(QMember.member.age.gt(
        JPAExpressions.select(subMember.age.avg())
            .from(subMember)
    ))
    .fetch();

이런 쿼리는 JPQL 문자열로도 쓸 수 있습니다. 다만 조회 조건이 바뀌거나 재사용 로직이 늘어날수록, 문자열로 남겨두는 편보다 QueryDSL로 명시적으로 표현하는 편이 유지보수에 훨씬 유리했습니다.

그래서 QueryDSL은 대체제가 아니라 정리 도구에 가까웠습니다

실제로는 모든 쿼리를 QueryDSL로 바꿀 필요는 없었습니다. 단순한 조회 메서드는 여전히 Spring Data JPA만으로 충분합니다.

중요한 건 어디서부터 QueryDSL이 필요한지 구분하는 일입니다.

  • 단순 조회와 고정 조건은 파생 메서드로 충분합니다.
  • 조합 가능한 조건이 늘어나는 조회는 QueryDSL이 더 유리합니다.
  • 상태와 타입 조건이 섞이는 복잡한 목록 조회는 QueryDSL이 훨씬 관리하기 쉽습니다.

즉, QueryDSL의 가치는 “멋진 DSL을 쓴다”가 아니라, 복잡해지는 조회 조건을 계속 읽을 수 있는 코드로 유지하게 해준다는 데 있습니다.

정리

QueryDSL의 장점은 DSL 자체가 아닙니다. 조회 조건이 제품 요구사항과 함께 복잡해질 때, 문자열이 아닌 코드 단위로 조건을 관리하게 해준다는 점이 더 중요합니다.

런치챗의 TripPlan 조회에서는 이 점이 중요했습니다. 상태 하나가 아니라 상태와 타입 조합이 들어오기 시작하자, QueryDSL은 선택이 아니라 유지보수 비용을 낮추는 정리 도구가 됐습니다.