Skip to content

베스트 프랙티스

실무 리포지토리에서 querydsl-ktx와 잘 어울리는 패턴을 정리했습니다. 모든 예제는 복사-붙여넣기로 바로 사용할 수 있으며, 실제 도메인 엔티티를 사용합니다.


Condition 객체 + Private Where 확장 함수

가장 효과적인 패턴: 검색 DTO를 JPAQuery의 private where 확장 함수로 매핑합니다.

kotlin
fun findAll(
    condition: MemberSearchCondition,
    pageable: Pageable,
): Page<Member> {
    val builder = BooleanBuilder()
    if (condition.name != null) {
        builder.and(member.name.contains(condition.name))
    }
    if (condition.status != null) {
        builder.and(member.status.eq(condition.status))
    }
    if (condition.minAge != null && condition.maxAge != null) {
        builder.and(member.age.between(condition.minAge, condition.maxAge))
    } else if (condition.minAge != null) {
        builder.and(member.age.goe(condition.minAge))
    } else if (condition.maxAge != null) {
        builder.and(member.age.loe(condition.maxAge))
    }
    if (condition.from != null && condition.to != null) {
        builder.and(member.createdAt.between(condition.from, condition.to))
    } else if (condition.from != null) {
        builder.and(member.createdAt.goe(condition.from))
    } else if (condition.to != null) {
        builder.and(member.createdAt.loe(condition.to))
    }

    val content = queryFactory.selectFrom(member)
        .where(builder)
        .offset(pageable.offset)
        .limit(pageable.pageSize.toLong())
        .fetch()
    val total = queryFactory.select(member.count())
        .from(member)
        .where(builder)
        .fetchOne() ?: 0L
    return PageImpl(content, pageable, total)
}
kotlin
// 1. Condition DTO
data class MemberSearchCondition(
    val name: String? = null,
    val status: MemberStatus? = null,
    val minAge: Int? = null,
    val maxAge: Int? = null,
    val from: LocalDateTime? = null,
    val to: LocalDateTime? = null,
)

// 2. Private where 확장 함수: 조건 필드를 null-safe 프레디킷으로 매핑
private fun <T> JPAQuery<T>.where(
    condition: MemberSearchCondition,
): JPAQuery<T> = this.where(
    member.name contains condition.name,
    member.status eq condition.status,
    member.age between (condition.minAge to condition.maxAge),
    member.createdAt between (condition.from to condition.to),
)

// 3. 리포지토리 메서드는 깔끔하게 유지
fun findAll(condition: MemberSearchCondition, pageable: Pageable): Page<Member> =
    selectFrom(member)
        .where(condition)
        .page(pageable, memberSort)

이 패턴이 효과적인 이유

  • 조건 필드 하나가 코드 한 줄에 대응
  • null 필드는 자동으로 건너뜀. if 분기 불필요
  • 부분 범위(from만, minAge만)도 추가 분기 없이 자연스럽게 처리
  • 동일한 where(condition) 확장 함수를 여러 쿼리 메서드에서 재사용 가능

여러 메서드에서 재사용

private where 확장 함수를 한 번 정의하면, 같은 조건 객체를 사용하는 모든 쿼리 메서드가 한 줄로 줄어듭니다:

kotlin
fun findAll(condition: MemberSearchCondition, pageable: Pageable): Page<Member> =
    selectFrom(member)
        .where(condition)
        .page(pageable, memberSort)

fun findSlice(condition: MemberSearchCondition, pageable: Pageable): Slice<Member> =
    selectFrom(member)
        .where(condition)
        .slice(pageable, memberSort)

fun count(condition: MemberSearchCondition): Long =
    select(member.count())
        .from(member)
        .where(condition)  // 같은 확장 함수, 다른 select
        .fetchOne() ?: 0L

SortSpec을 리포지토리 프로퍼티로

SortSpec은 상태가 없으므로 프로퍼티로 한 번 정의하고 모든 페이지네이션 메서드에서 재사용합니다.

kotlin
@Repository
class MemberRepository : QuerydslRepository<Member>() {
    private val member = QMember.member

    // 한 번 정의, 어디서든 재사용
    private val memberSort = sortSpec {
        "name" by member.name
        "age" by member.age
        "createdAt" by member.createdAt
    }

    fun findAll(pageable: Pageable): Page<Member> =
        selectFrom(member).page(pageable, memberSort)

    fun findByStatus(status: MemberStatus, pageable: Pageable): Slice<Member> =
        selectFrom(member)
            .where(member.status.eq(status))
            .slice(pageable, memberSort)
}

보안 이점

클라이언트가 보내는 정렬 프로퍼티(예: ?sort=name,asc)는 화이트리스트에 대해 검증됩니다. ?sort=password,asc 같은 알 수 없는 프로퍼티는 무시됩니다. 임의 컬럼 정렬이 불가능합니다.

SortSpec은 val, 함수가 아님

SortSpec은 변경 가능한 상태를 갖지 않으므로 private val 프로퍼티로 정의하세요. 호출할 때마다 다시 만들 이유가 없습니다.


Fetch Join이 있는 Page + 별도 카운트 쿼리

fetch join을 사용하는 쿼리에서 자동 생성된 카운트 쿼리는 잘못된 결과를 만듭니다. 람다 오버로드로 별도의 카운트 쿼리를 제공하세요.

kotlin
fun findAllWithDepartment(
    condition: MemberSearchCondition,
    pageable: Pageable,
): Page<Member> =
    selectFrom(member)
        .leftJoin(member.department, department).fetchJoin()
        .where(condition)
        .page(pageable, memberSort) {
            // fetch join 없는 별도 카운트 쿼리
            select(member.count())
                .from(member)
                .where(condition)
                .fetchOne() ?: 0L
        }

왜 중요한가

자동 생성된 카운트 쿼리는 메인 쿼리를 복제하고 select를 COUNT(*)로 교체합니다. fetch join이 있으면 카운트가 뻥튀기됩니다. 엔티티 수가 아닌 조인된 행 수를 셉니다. 이것은 QueryDSL의 한계이며, querydsl-ktx의 버그가 아닙니다.

Condition 객체 재사용

동일한 where(condition) private 확장 함수가 데이터 쿼리와 카운트 쿼리 양쪽에서 작동합니다. 카운트가 항상 데이터와 일치하는 것이 보장되며, 한쪽을 수정할 때 다른 쪽을 빠뜨릴 위험이 없습니다.


modifying { }: Bulk DML

modifying { }은 블록 실행 전에 entityManager.flush(), 후에 entityManager.clear()를 호출합니다. 따라서 활성 트랜잭션이 필요하며, 트랜잭션은 서비스 계층에서 선언합니다.

kotlin
// Repository: bulk DML 메서드만 제공
fun deactivateMembers(status: MemberStatus): Long =
    modifying {
        update(member)
            .set(member.status, MemberStatus.INACTIVE)
            .where(member.status.eq(status))
            .execute()
    }
kotlin
// Service: 트랜잭션은 여기서 선언
@Service
@Transactional
class MemberService(
    private val memberRepository: MemberRepository,
) {
    fun deactivateNormalMembers(): Long =
        memberRepository.deactivateMembers(MemberStatus.NORMAL)
}

@Transactional은 서비스 계층에서 선언 (리포지토리가 아님)

@Transactional 없이 사용하면 flush() 호출이 실패합니다. 트랜잭션은 서비스 계층에서 선언하고, 리포지토리 메서드는 그 트랜잭션에 참여합니다.

"We generally recommend declaring transaction boundaries when starting a unit of work to ensure proper consistency and desired transaction participation."

Spring Data JPA Reference: Transactions

참고: QuerydslRepository는 Spring Data의 SimpleJpaRepository를 상속하지 않으므로, 메서드에 기본 @Transactional이 적용되지 않습니다.

여러 문이 하나의 flush/clear 사이클을 공유

하나의 modifying { } 블록에 여러 DML 문을 넣으면, flush는 첫 번째 문 전에 한 번, clear는 마지막 문 후에 한 번 발생합니다. 각각 따로 감싸는 것보다 효율적입니다.

kotlin
// Repository
fun archiveAndNotify(cutoffDate: LocalDate): Pair<Long, Long> =
    modifying {
        val archived = update(member)
            .set(member.status, MemberStatus.ARCHIVED)
            .where(member.lastLogin lt cutoffDate)
            .execute()

        val notified = update(notification)
            .set(notification.sent, true)
            .where(notification.memberId `in`
                select(member.id).from(member)
                    .where(member.status eq MemberStatus.ARCHIVED)
            )
            .execute()

        archived to notified
    }

역방향 Between: 날짜 범위 유효성 검사

일반 between은 컬럼 값이 범위 안에 있는지 확인합니다. 역방향 between은 값이 컬럼으로 정의된 범위 안에 있는지 확인합니다.

양쪽 경계가 항상 있는 경우

.. (rangeTo) 연산자를 사용합니다:

kotlin
fun findActiveSales(now: LocalDateTime): List<Product> =
    selectFrom(product)
        .where(now between (product.saleStartAt..product.saleEndAt))
        .fetch()
sql
-- SQL: sale_start_at <= '2026-04-10T12:00' AND sale_end_at >= '2026-04-10T12:00'

경계가 nullable인 경우

to (Pair) 문법으로 null-safe 디그레이드:

kotlin
fun findActiveSales(now: LocalDateTime? = null): List<Product> =
    selectFrom(product)
        .where(now between (product.saleStartAt to product.saleEndAt))
        .fetch()
nowsaleStartAtsaleEndAt결과
non-nullnon-nullnon-nullstart <= now AND end >= now
non-nullnon-nullnullstart <= now
non-nullnullnon-nullend >= now
nullanyanynull (건너뜀)

자주 쓰는 활용 사례

  • 쿠폰 유효기간: now between (coupon.validFrom to coupon.validUntil)
  • 할인 기간: now between (discount.startAt to discount.endAt)
  • 이벤트 일정: now between (event.openAt to event.closeAt)
  • 가격 구간 매칭: orderAmount between (tier.minAmount to tier.maxAmount)

Computed Column 정렬을 위한 Kotlin 연산자

파생 숫자 값으로 정렬할 때, 기존에는 NumberExpression.add(...)나 Querydsl template이 필요했습니다. NumberExpression의 Kotlin operator 오버로드를 쓰면 표현식 빌딩이 일반 산술처럼 읽힙니다. 중간 변수도, template placeholder도 필요 없습니다.

kotlin
// 총 가격(price + tax) 내림차순 정렬
selectFrom(product)
    .orderBy((product.price + product.tax).desc())
    .fetch()

// 마진 비율 상위 정렬
selectFrom(product)
    .orderBy(((product.price - product.cost) / product.cost).desc())
    .fetch()

산술 연산자는 non-null 계약입니다. 양쪽 피연산자 모두 non-null이어야 합니다. orderBy, select projection 등 표현식 자체가 항상 존재해야 하는 곳에 사용하세요.

어느 한쪽이 null일 수 있는 동적 WHERE에서는 infix 형태(add, subtract, multiply, divide, mod)를 사용합니다. 양쪽 중 하나가 null이면 null을 반환합니다.

kotlin
where(product.price add discount gt 0)  // discount가 null이면 건너뜀

TIP

SortSpec과 함께 사용하면 동적 정렬에서 computed column을 노출할 수 있습니다.

kotlin
private val productSort = sortSpec {
    "grossPrice" by (product.price + product.tax)
    "margin" by ((product.price - product.cost) / product.cost)
}

이제 컨트롤러 레이어 변경 없이 ?sort=grossPrice,desc가 동작합니다.


전체 조합 예제

위 패턴을 모두 결합한 완성된 리포지토리:

kotlin
@Repository
class MemberRepository : QuerydslRepository<Member>() {

    private val member = QMember.member
    private val department = QDepartment.department

    // 패턴 2: SortSpec을 프로퍼티로
    private val memberSort = sortSpec {
        "name" by member.name
        "age" by member.age
        "createdAt" by member.createdAt
        "department" by department.name
    }

    // 패턴 1: Condition 객체 + private where 확장 함수
    private fun <T> JPAQuery<T>.where(
        condition: MemberSearchCondition,
    ): JPAQuery<T> = this.where(
        member.name contains condition.name,
        member.status eq condition.status,
        member.age between (condition.minAge to condition.maxAge),
        member.createdAt between (condition.from to condition.to),
    )

    // 패턴 3: fetch join + 별도 카운트 쿼리
    fun findAll(
        condition: MemberSearchCondition,
        pageable: Pageable,
    ): Page<Member> =
        selectFrom(member)
            .leftJoin(member.department, department).fetchJoin()
            .where(condition)
            .page(pageable, memberSort) {
                select(member.count())
                    .from(member)
                    .where(condition)
                    .fetchOne() ?: 0L
            }

    // 패턴 4: modifying (서비스 계층에서 @Transactional 선언)
    fun deactivateInactive(cutoffDate: LocalDate): Long =
        modifying {
            update(member)
                .set(member.status, MemberStatus.INACTIVE)
                .where(member.lastLogin lt cutoffDate)
                .execute()
        }
}