Skip to content

동적 쿼리 ​

QueryDSL 동적 쿼리의 발전 과정 ​

Kotlin에서 QueryDSL을 써봤다면, 아마 이런 순서로 발전해 왔을 겁니다:

1단계: BooleanBuilder: 대부분 처음 배우는 패턴입니다.

kotlin
val builder = BooleanBuilder()
if (name != null) builder.and(member.name.contains(name))
if (status != null) builder.and(member.status.eq(status))

2단계: 필드별 헬퍼 함수: BooleanExpression?을 반환하는 메서드를 조건마다 작성합니다. 인프런 김영한 강의에서 소개하는 패턴이 이것입니다:

kotlin
fun statusEq(status: String?): BooleanExpression? =
    status?.let { member.status.eq(it) }

fun nameLike(name: String?): BooleanExpression? =
    name?.let { member.name.contains(it) }

// 사용
selectFrom(member)
    .where(statusEq(status), nameLike(name))
    .fetch()

3단계: querydsl-ktx: 동일한 null-safe 동작이지만, 필드마다 헬퍼 함수를 작성할 필요가 없습니다.

kotlin
selectFrom(member)
    .where(
        member.status eq status,
        member.name contains name,
    )
    .fetch()

세 가지 접근 방식 모두 같은 SQL을 생성합니다. 차이는 보일러플레이트의 양입니다.


핵심 개념: null = 건너뛰기 ​

querydsl-ktx의 모든 확장 함수는 하나의 규칙을 따릅니다:

인자가 null이면 조건이 적용되지 않습니다.

이것으로 BooleanBuilder + if-null-check 패턴을 완전히 제거할 수 있습니다.

kotlin
val builder = BooleanBuilder()
if (name != null) builder.and(entity.name.contains(name))
if (status != null) builder.and(entity.status.eq(status))
if (from != null && to != null) {
    builder.and(entity.createdAt.between(from, to))
} else if (from != null) {
    builder.and(entity.createdAt.goe(from))
} else if (to != null) {
    builder.and(entity.createdAt.loe(to))
}
kotlin
var where: BooleanExpression? = null
where = where and (entity.name contains name)
where = where and (entity.status eq status)
where = where and (entity.createdAt between (from to to))

QuerydslRepository 내부에서는 더 짧게 쓸 수 있습니다:

kotlin
selectFrom(entity)
    .where(
        entity.name contains name,
        entity.status eq status,
        entity.createdAt between (from to to),
    )
    .fetch()

.where()의 null 처리 방식

QueryDSL의 .where()는 vararg 오버로드에서 이미 null 프레디킷을 무시합니다. querydsl-ktx의 null 반환 확장 함수와 결합하면, null 파라미터가 모든 레벨에서 투명하게 필터링됩니다.


필드별 헬퍼 함수와 비교 ​

인프런 강의 스타일의 필드별 헬퍼 함수를 작성해 본 적이 있을 겁니다:

kotlin
private fun statusEq(status: String?): BooleanExpression? =
    status?.let { member.status.eq(it) }

private fun nameLike(name: String?): BooleanExpression? =
    name?.let { member.name.contains(it) }

private fun ageBetween(min: Int?, max: Int?): BooleanExpression? {
    if (min != null && max != null) return member.age.between(min, max)
    if (min != null) return member.age.goe(min)
    if (max != null) return member.age.loe(max)
    return null
}

fun search(status: String?, name: String?, minAge: Int?, maxAge: Int?) =
    selectFrom(member)
        .where(statusEq(status), nameLike(name), ageBetween(minAge, maxAge))
        .fetch()
kotlin
fun search(status: String?, name: String?, minAge: Int?, maxAge: Int?) =
    selectFrom(member)
        .where(
            member.status eq status,
            member.name contains name,
            member.age between (minAge to maxAge),
        )
        .fetch()

querydsl-ktx 버전은 정확히 같은 일을 합니다. member.status eq null은 null을 반환하고, .where()가 이를 무시합니다. Pair를 사용한 between은 네 가지 조합 (양쪽 값, min만, max만, 둘 다 없음)을 하나의 표현식으로 처리합니다.

그래도 헬퍼를 추출해야 할 때

querydsl-ktx가 있다고 메서드 추출이 필요 없다는 뜻은 아닙니다. 여러 필드를 조합하거나 비즈니스 로직이 포함된 복잡한 조건은 여전히 이름이 있는 메서드가 낫습니다:

kotlin
// 이건 여전히 메서드로 추출하는 게 깔끔합니다
private fun isEligibleForPromotion(): BooleanExpression? =
    (member.grade eq "VIP") or (
        (member.totalPurchase goe 100000) and (member.active eq true)
    )

기준: 조건이 필드 1:1 매핑이면 인라인 확장 함수를 쓰세요. 비즈니스 로직을 인코딩하면 메서드로 추출하세요.


AND / OR 체이닝 ​

기본 AND ​

and infix 함수를 사용하여 nullable 조건을 결합합니다:

kotlin
var predicate: BooleanExpression? = null
predicate = predicate and (entity.name eq name)
predicate = predicate and (entity.active eq true)
thisright결과
non-nullnon-nullthis AND right
nullnon-nullright
non-nullnullthis
nullnullnull

기본 OR ​

대칭적으로 동작합니다:

kotlin
var predicate: BooleanExpression? = null
predicate = predicate or (entity.role eq "ADMIN")
predicate = predicate or (entity.role eq "MANAGER")
thisright결과
non-nullnon-nullthis OR right
nullnon-nullright
non-nullnullthis
nullnullnull

AND와 OR 결합 ​

kotlin
val rolePredicate = (entity.role eq "ADMIN") or (entity.role eq "MANAGER")
val predicate = (entity.active eq true) and rolePredicate
kotlin
selectFrom(entity)
    .where(
        entity.active eq true,
        (entity.role eq "ADMIN") or (entity.role eq "MANAGER"),
    )
    .fetch()
sql
SELECT e.*
FROM entity e
WHERE e.active = true
  AND (e.role = 'ADMIN' OR e.role = 'MANAGER')

Null-Safety 계약 ​

모든 확장 함수는 일관된 null 동작을 따릅니다:

결합자 (and / or) ​

thisarg둘 다 null결과
arg 반환this 반환nullnon-null 쪽을 보존

Between (Pair 오버로드) ​

thisfromto결과
non-nullnon-nullnon-nullBETWEEN from AND to
non-nullnon-nullnull>= from
non-nullnullnon-null<= to
non-nullnullnullnull (건너뛰기)
nullanyanynull (건너뛰기)

기타 모든 함수 (eq, gt, contains, ...) ​

this nullarg null둘 다 null결과
nullnullnullnull (건너뛰기)

null 표현식 vs null 인자

this가 null인 경우는 QueryDSL 표현식 자체가 null인 것입니다 (실제로는 드묾). arg가 null인 경우는 필터 파라미터가 제공되지 않은 것입니다. 일반적인 경우입니다.


복합 조건 ​

andAnyOf: AND와 OR 그룹 ​

"기본 조건 AND (이 중 하나라도)":

kotlin
val predicate = (entity.active eq true) andAnyOf listOf(
    entity.role eq role,
    entity.department eq department,
)
sql
active = true AND (role = ? OR department = ?)

role과 department가 모두 null이면 OR 그룹이 null로 축소되고, 기본 조건만 남습니다.

orAllOf: OR과 AND 그룹 ​

"기본 조건 OR (이것 모두)":

kotlin
val predicate = (entity.vip eq true) orAllOf listOf(
    entity.age goe minAge,
    entity.active eq true,
)
sql
vip = true OR (age >= ? AND active = true)

vararg 오버로드 ​

andAnyOf와 orAllOf는 BooleanExpression?의 vararg도 받습니다. listOf(...) 없이 인라인으로 더 간결하게 사용할 수 있습니다.

kotlin
predicate.andAnyOf(
    entity.role eq role,
    entity.department eq department,
)

null-skip 시맨틱은 동일합니다. 각 null predicate는 OR/AND 그룹 빌드 전에 제거됩니다.


서브쿼리 비교 ​

SimpleExpression은 JPAExpressions로 만든 서브쿼리와의 null-safe 비교를 지원합니다. null을 전달하면 조건 자체가 건너뛰어집니다.

kotlin
import com.querydsl.jpa.JPAExpressions

fun search(matchMaxPrice: Boolean): List<Product> {
    val maxPrice = if (matchMaxPrice)
        JPAExpressions.select(product.price.max()).from(product)
    else null
    return selectFrom(product)
        .where(product.price eq maxPrice)  // null 서브쿼리 -> 건너뜀
        .fetch()
}
sql
-- maxPrice 서브쿼리가 제공된 경우
SELECT * FROM product WHERE price = (SELECT MAX(price) FROM product)

-- maxPrice 서브쿼리가 null인 경우
SELECT * FROM product

eq, in, notIn은 SubQueryExpression<T>?를 받습니다. 값/표현식 오버로드와 동일한 null-skip 규칙이 적용됩니다.

ALL / Any 비교 ​

여러 서브쿼리 행 또는 컬렉션 원소와 비교하려면 *All / *Any 변형을 사용합니다.

kotlin
// 카테고리의 모든 가격보다 큼: 해당 카테고리의 모든 상품보다 비싼 상품
val categoryPrices = JPAExpressions.select(product.price).from(product)
    .where(product.category.eq(targetCategory))
selectFrom(product).where(product.price gtAll categoryPrices).fetch()

// cheap 카테고리의 어떤 가격과도 일치: 어떤 cheap 상품의 가격과 같은 상품
val cheapPrices = JPAExpressions.select(product.price).from(product)
    .where(product.price.lt(threshold))
selectFrom(product).where(product.price eqAny cheapPrices).fetch()
sql
SELECT * FROM product WHERE price > ALL (SELECT price FROM product WHERE category = ?)
SELECT * FROM product WHERE price = ANY (SELECT price FROM product WHERE price < ?)

지원 변형: eqAll/eqAny, neAll/neAny (Collection만), gtAll/gtAny, goeAll/goeAny, ltAll/ltAny, loeAll/loeAny. NumberExpression의 정확한 커버리지는 All/Any 비대칭 표를 참고하세요.

ESCAPE를 사용한 LIKE ​

% 또는 _ 문자를 리터럴로 매칭해야 하는 패턴에서는 like / notLike / likeIgnoreCase 결과에 escape를 체이닝합니다.

kotlin
entity.name like "10\\%off" escape '\\'  // 리터럴 "10%off"와 매칭

이스케이프 문자는 notLike, likeIgnoreCase 체인에서도 그대로 유지됩니다. 유효하지 않은 receiver(LIKE가 아닌 표현식 등)에는 com.querydsl.core.types.ExpressionException이 발생합니다.

Computed Column에서의 산술 ​

NumberExpression은 null-safe infix 산술 (add/subtract/multiply/divide/mod)과 Kotlin 연산자 (+, -, *, /, %, 단항 -) 양쪽을 제공합니다. infix는 양쪽 중 하나가 null이면 건너뛰고, 연산자는 non-null 계약이라 projection이나 orderBy에 적합합니다.

kotlin
// 중간 변수 없이 computed column으로 정렬
selectFrom(entity)
    .orderBy((entity.price + entity.tax).desc())
    .fetch()

// 동적 WHERE에서 null-safe
where(entity.discount add bonus gt 0)  // bonus가 null이면 건너뜀

조건을 점진적으로 구성하기 ​

런타임 로직에 따라 조건을 구성해야 하는 경우:

kotlin
fun search(criteria: SearchCriteria): List<Entity> {
    var where: BooleanExpression? = null

    // 항상 적용되는 조건
    where = where and (entity.deleted eq false)

    // 조건부 그룹
    if (criteria.hasKeyword()) {
        where = where and (
            (entity.name contains criteria.keyword)
            or (entity.description contains criteria.keyword)
        )
    }

    // Null-safe 조건: if 검사 불필요
    where = where and (entity.status eq criteria.status)
    where = where and (entity.createdAt between (criteria.from to criteria.to))

    return selectFrom(entity).where(where).fetch()
}

if vs null-safety 사용 시점

  • 단순 null 검사: 확장 함수에 맡기세요. entity.status eq criteria.status로 충분합니다.
  • 복잡한 로직 (예: 여러 필드에 걸친 키워드 검색): 명시적 if 블록으로 서브 표현식을 구성한 후 and로 결합하세요.

실전 예제: 어드민 검색 페이지 ​

여러 선택적 필터가 있는 전형적인 어드민 검색:

kotlin
@Repository
class OrderRepository : QuerydslRepository<Order>() {

    private val order = QOrder.order
    private val member = QMember.member

    fun adminSearch(
        orderNumber: String?,
        memberName: String?,
        status: OrderStatus?,
        minAmount: Int?,
        maxAmount: Int?,
        from: LocalDateTime?,
        to: LocalDateTime?,
        pageable: Pageable,
    ): Page<Order> =
        selectFrom(order)
            .join(order.member, member)
            .where(
                order.orderNumber contains orderNumber,
                member.name contains memberName,
                order.status eq status,
                order.totalAmount between (minAmount to maxAmount),
                order.createdAt between (from to to),
            )
            .page(pageable)
}

모든 파라미터가 nullable입니다. 어드민이 "회원명"과 "상태"만 입력하면 SQL에는 그 두 조건만 나타납니다. BooleanBuilder도, 헬퍼 함수도, 분기 로직도 없습니다.


QuerydslRepository 없이 확장 함수 사용하기 ​

어떤 클래스에서든 확장 인터페이스를 직접 구현하여 사용할 수 있습니다:

kotlin
class MyService : BooleanExpressionExtensions, SimpleExpressionExtensions {

    fun buildPredicate(name: String?, status: String?): BooleanExpression? {
        var where: BooleanExpression? = null
        where = where and (entity.name eq name)
        where = where and (entity.status eq status)
        return where
    }
}

또는 QuerydslSupport를 확장하는 리포지토리에서 일부만 구현할 수도 있습니다:

kotlin
@Repository
class MinimalRepository : QuerydslSupport<MyEntity>(),
    SimpleExpressionExtensions,
    StringExpressionExtensions {

    override val domainClass = MyEntity::class.java

    // eq, ne, in, notIn, contains, startsWith 등만 스코프 내에서 사용 가능
}