코프링 실전 시리즈 (3/5)

이전 편: 2 예외 처리 & Validation 전략

다음 편: 4 설정 관리와 프로파일 전략

Spring Data JPA의 메서드 이름 기반 쿼리는 간단한 조회에는 편리하지만, 조건이 복잡해지면 한계가 온다. 동적 검색 조건, 다중 테이블 조인, 페이징과 정렬이 결합된 쿼리를 메서드 이름만으로 표현하기 어렵다. @Query 어노테이션으로 JPQL을 쓸 수 있지만, 문자열이므로 컴파일 타임 검증이 안 되고 리팩토링에 취약하다.

QueryDSL은 이 문제를 해결한다. 타입 안전한 쿼리를 Java/Kotlin 코드로 작성할 수 있다. 필드 이름을 바꾸면 Q클래스의 필드도 바뀌고, 존재하지 않는 필드를 참조하면 컴파일 에러가 난다.

이 문서에서는 Kotlin 프로젝트에서 QueryDSL을 설정하고, 실전에서 자주 쓰는 패턴을 다룬다.

설정

Kotlin에서 QueryDSL을 설정하려면 kapt(Kotlin Annotation Processing Tool)가 필요하다. QueryDSL은 어노테이션 프로세서로 Q클래스를 생성하는데, Kotlin 컴파일러는 Java의 어노테이션 프로세서를 직접 실행할 수 없기 때문이다.

// build.gradle.kts
plugins {
    kotlin("kapt") version "2.1.0"
}

dependencies {
    implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
    kapt("com.querydsl:querydsl-apt:5.1.0:jakarta")
}
  • querydsl-jpa — QueryDSL의 JPA 모듈. jakarta classifier는 Jakarta EE 네임스페이스를 사용하는 Spring Boot 3.x용이다.
  • querydsl-apt — 어노테이션 프로세서. @Entity가 붙은 클래스에서 Q클래스를 생성한다.
  • kapt — Kotlin에서 Java 어노테이션 프로세서를 실행하는 플러그인.
KSP 지원

Kotlin 2.x부터 kapt는 유지보수 모드로 전환되었고, KSP(Kotlin Symbol Processing)가 권장된다. QueryDSL 5.1.0부터 KSP를 실험적으로 지원하므로, 프로젝트에 따라 KSP로 마이그레이션을 검토할 수 있다. 다만 아직 안정화 단계이므로 kapt를 쓰는 것이 안전하다.

빌드하면 build/generated/source/kapt 디렉터리에 Q클래스가 생성된다. Post 엔티티가 있으면 QPost가 만들어진다.


JPAQueryFactory 설정

QueryDSL을 사용하려면 JPAQueryFactory 빈을 등록해야 한다.

@Configuration
class QueryDslConfig(
    private val entityManager: EntityManager
) {
    @Bean
    fun jpaQueryFactory(): JPAQueryFactory = JPAQueryFactory(entityManager)
}

이 빈을 Repository에 주입해서 쿼리를 작성한다. 설정은 이것뿐이다.


Custom Repository 패턴

Spring Data JPA의 JpaRepository와 QueryDSL을 함께 쓸 때는 Custom Repository 패턴을 사용한다. 하나의 인터페이스로 기본 CRUD와 복잡한 쿼리를 모두 사용할 수 있다.

graph TD A["PostRepository"] -->|상속| B["JpaRepository"] A -->|상속| C["PostRepositoryCustom"] D["PostRepositoryImpl"] -->|구현| C style A fill:#e1f5fe style D fill:#e8f5e9

구조를 코드로 보면 이렇다. 먼저 QueryDSL로 구현할 메서드를 선언하는 Custom 인터페이스가 필요하다.

interface PostRepositoryCustom {
    fun search(condition: PostSearchCondition): Page<Post>
    fun findWithComments(id: Long): Post?
}

이 인터페이스의 구현체에서 JPAQueryFactory를 주입받아 실제 쿼리를 작성한다.

class PostRepositoryImpl(
    private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom {

    override fun search(condition: PostSearchCondition): Page<Post> {
        // QueryDSL 쿼리 (아래에서 자세히)
    }

    override fun findWithComments(id: Long): Post? {
        // QueryDSL 쿼리
    }
}

마지막으로 메인 Repository가 JpaRepository와 Custom 인터페이스를 동시에 상속한다. 이렇게 하면 하나의 인터페이스로 기본 CRUD와 복잡한 쿼리를 모두 사용할 수 있다.

interface PostRepository : JpaRepository<Post, Long>, PostRepositoryCustom {
    fun existsByTitle(title: String): Boolean  // Spring Data JPA 쿼리 메서드
}
  • PostRepository를 주입받으면 save(), findById() 같은 기본 메서드와 search() 같은 커스텀 메서드를 모두 사용할 수 있다.
  • 구현체 이름은 반드시 {메인 Repository 이름}Impl이어야 한다. PostRepositoryImpl이 아니면 Spring이 자동으로 연결하지 못한다.

기본 쿼리 작성

QueryDSL의 기본 문법을 Kotlin에서 어떻게 쓰는지 보자.

단건 조회

private val post = QPost.post

fun findByIdWithAuthor(id: Long): Post? {
    return queryFactory
        .selectFrom(post)
        .join(post.author).fetchJoin()
        .where(post.id.eq(id))
        .fetchOne()
}
  • QPost.post — Q클래스의 기본 인스턴스. 쿼리에서 post.title, post.content 같이 필드를 참조한다.
  • selectFrom(post)SELECT p FROM Post p에 해당.
  • fetchJoin() — 지연 로딩된 연관관계를 즉시 로딩한다. N+1 문제를 방지하는 핵심 기법.
  • fetchOne() — 결과가 1개 또는 null. 2개 이상이면 NonUniqueResultException.

Kotlin에서는 반환 타입이 Post?다. 결과가 없으면 null을 반환하므로 Kotlin의 null 안전성과 자연스럽게 맞는다.


목록 조회

fun findPublishedPosts(): List<Post> {
    return queryFactory
        .selectFrom(post)
        .where(post.isPublished.isTrue)
        .orderBy(post.createdAt.desc())
        .fetch()
}
  • fetch() — 결과를 List로 반환. 결과가 없으면 빈 리스트.
  • isTrue — Boolean 타입 필드의 QueryDSL 표현.
  • orderBy() — 정렬. .desc(), .asc() 메서드를 체이닝한다.

동적 쿼리 — BooleanExpression 활용

실무에서 가장 많이 쓰는 패턴이다. 검색 조건이 여러 개 있고, 사용자가 입력한 조건만 쿼리에 포함해야 한다.

먼저 검색 조건을 담는 DTO를 정의한다.

data class PostSearchCondition(
    val keyword: String? = null,
    val isPublished: Boolean? = null,
    val authorName: String? = null,
    val startDate: LocalDateTime? = null,
    val endDate: LocalDateTime? = null
)

모든 필드가 nullable이다. 값이 있으면 조건에 포함, 없으면 무시한다.

QueryDSL에서 동적 조건을 처리하는 핵심은 wherenull을 넘기면 그 조건이 무시된다는 것이다. 이 특성을 활용한다.

class PostRepositoryImpl(
    private val queryFactory: JPAQueryFactory
) : PostRepositoryCustom {

    private val post = QPost.post

    override fun search(condition: PostSearchCondition): Page<Post> {
        val query = queryFactory
            .selectFrom(post)
            .where(
                keywordContains(condition.keyword),
                publishedEq(condition.isPublished),
                authorNameEq(condition.authorName),
                createdBetween(condition.startDate, condition.endDate)
            )
            .orderBy(post.createdAt.desc())

        // 페이징은 아래에서 설명
        return /* ... */
    }

    private fun keywordContains(keyword: String?): BooleanExpression? {
        return keyword?.let { post.title.containsIgnoreCase(it) }
    }

    private fun publishedEq(isPublished: Boolean?): BooleanExpression? {
        return isPublished?.let { post.isPublished.eq(it) }
    }

    private fun authorNameEq(authorName: String?): BooleanExpression? {
        return authorName?.let { post.author.name.eq(it) }
    }

    private fun createdBetween(start: LocalDateTime?, end: LocalDateTime?): BooleanExpression? {
        if (start == null || end == null) return null
        return post.createdAt.between(start, end)
    }
}

각 조건 메서드의 구조가 동일하다.

  • 파라미터가 null이면 null을 반환 → QueryDSL이 이 조건을 무시.
  • 파라미터가 있으면 BooleanExpression을 반환 → 쿼리에 AND 조건으로 포함.

이 패턴의 장점은 조건 메서드를 재사용할 수 있다는 것이다. 다른 쿼리에서도 keywordContains()를 그대로 쓸 수 있다.

Kotlin의 ?.let { }이 이 패턴과 아주 잘 맞는다. Java에서는 if (keyword != null) return ...; return null; 같은 패턴이 되는데, Kotlin에서는 한 줄로 표현된다.


페이징

Spring Data의 Pageable과 QueryDSL을 조합하는 패턴이다.

override fun search(condition: PostSearchCondition, pageable: Pageable): Page<Post> {
    val content = queryFactory
        .selectFrom(post)
        .where(
            keywordContains(condition.keyword),
            publishedEq(condition.isPublished)
        )
        .orderBy(*getOrderSpecifiers(pageable))
        .offset(pageable.offset)
        .limit(pageable.pageSize.toLong())
        .fetch()

    val total = queryFactory
        .select(post.count())
        .from(post)
        .where(
            keywordContains(condition.keyword),
            publishedEq(condition.isPublished)
        )
        .fetchOne() ?: 0L

    return PageImpl(content, pageable, total)
}
  • offset(pageable.offset) — 페이지 번호에 따른 시작 위치.
  • limit(pageable.pageSize.toLong()) — 한 페이지의 크기.
  • 카운트 쿼리를 별도로 실행한다. fetchJoin이 포함된 복잡한 쿼리에서는 카운트 쿼리를 분리하는 것이 성능상 유리하다.
  • PageImpl(content, pageable, total) — Spring Data의 Page 구현체를 직접 생성.

정렬 처리는 PageableSort 정보를 QueryDSL의 OrderSpecifier로 변환해야 한다.

private fun getOrderSpecifiers(pageable: Pageable): Array<OrderSpecifier<*>> {
    return pageable.sort.map { order ->
        val direction = if (order.isAscending) Order.ASC else Order.DESC
        val path = when (order.property) {
            "createdAt" -> post.createdAt
            "title" -> post.title
            "viewCount" -> post.viewCount
            else -> post.createdAt
        }
        OrderSpecifier(direction, path)
    }.toTypedArray()
}

when으로 허용할 정렬 필드를 명시한다. 클라이언트가 보낸 임의의 필드명을 그대로 쿼리에 넣으면 보안 문제가 될 수 있으므로, 허용 목록으로 제한하는 것이 안전하다.


Projection — DTO 직접 조회

엔티티 전체가 아니라 필요한 필드만 조회하고 싶을 때가 있다. 목록 화면에서 엔티티의 모든 필드를 가져올 필요가 없고, 성능 최적화에도 유리하다.

data class PostSummary(
    val id: Long,
    val title: String,
    val authorName: String,
    val commentCount: Long,
    val createdAt: LocalDateTime
)

QueryDSL의 Projections.constructor를 사용하면 DTO로 직접 조회할 수 있다.

fun findSummaries(): List<PostSummary> {
    return queryFactory
        .select(
            Projections.constructor(
                PostSummary::class.java,
                post.id,
                post.title,
                post.author.name,
                post.comments.size().toLong(),
                post.createdAt
            )
        )
        .from(post)
        .join(post.author)
        .fetch()
}
  • Projections.constructor — DTO의 생성자를 호출해서 결과를 매핑한다.
  • 선택한 필드의 순서가 DTO 생성자의 파라미터 순서와 일치해야 한다.
  • 엔티티가 아니라 DTO를 반환하므로 영속성 컨텍스트에 올라가지 않는다. 조회 전용 쿼리에 적합하다.

심화 분석

서브쿼리

QueryDSL에서 서브쿼리는 JPAExpressions를 사용한다.

fun findAboveAverageViewCount(): List<Post> {
    return queryFactory
        .selectFrom(post)
        .where(
            post.viewCount.gt(
                JPAExpressions
                    .select(post.viewCount.avg())
                    .from(post)
            )
        )
        .fetch()
}

JPAExpressions.select()로 서브쿼리를 만들고, where 조건에 넣는다. 다만 JPQL의 제약으로 FROM 절 서브쿼리(인라인 뷰)는 지원하지 않는다. 이 경우에는 네이티브 쿼리를 사용해야 한다.

exists 최적화

"특정 조건의 데이터가 존재하는지" 확인할 때 count보다 exists가 효율적이다.

fun existsByTitleAndPublished(title: String): Boolean {
    return queryFactory
        .selectOne()
        .from(post)
        .where(
            post.title.eq(title),
            post.isPublished.isTrue
        )
        .fetchFirst() != null
}

selectOne().fetchFirst()는 조건에 맞는 첫 번째 행만 찾고 멈춘다. count()는 전체 행을 세므로 데이터가 많을수록 성능 차이가 난다.

벌크 연산

여러 행을 한 번에 업데이트하거나 삭제할 때는 벌크 연산을 쓴다.

@Transactional
fun bulkUnpublish(authorId: Long): Long {
    return queryFactory
        .update(post)
        .set(post.isPublished, false)
        .where(post.author.id.eq(authorId))
        .execute()
}

벌크 연산은 영속성 컨텍스트를 거치지 않고 DB에 직접 실행된다. 따라서 벌크 연산 후에는 entityManager.flush()entityManager.clear()를 호출해서 영속성 컨텍스트를 초기화하는 것이 안전하다. 그렇지 않으면 영속성 컨텍스트에 캐시된 데이터와 DB의 실제 데이터가 불일치할 수 있다.


자주 하는 실수

fetchJoin과 페이징 함께 사용

// 위험 — 메모리에서 페이징 처리
queryFactory
    .selectFrom(post)
    .join(post.comments).fetchJoin()  // 컬렉션 fetchJoin
    .offset(pageable.offset)
    .limit(pageable.pageSize.toLong())
    .fetch()

컬렉션(@OneToMany)에 fetchJoin을 걸면서 페이징을 하면, Hibernate는 모든 데이터를 메모리에 올린 후 애플리케이션 레벨에서 페이징한다. 데이터가 많으면 OutOfMemoryError가 발생할 수 있다. 로그에 HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory 경고가 나온다.

해결법은 @BatchSize를 활용하거나, 컬렉션 조인 없이 페이징하고 @EntityGraph로 별도 조회하는 것이다.

조건 메서드에서 빈 문자열 처리 누락

// 나쁜 예 — 빈 문자열이 들어오면 "제목이 빈 문자열과 같은" 게시글을 찾음
private fun keywordContains(keyword: String?): BooleanExpression? {
    return keyword?.let { post.title.containsIgnoreCase(it) }
}

// 좋은 예 — 빈 문자열도 null처럼 처리
private fun keywordContains(keyword: String?): BooleanExpression? {
    return keyword?.takeIf { it.isNotBlank() }?.let { post.title.containsIgnoreCase(it) }
}

프론트엔드에서 검색어 없이 요청하면 빈 문자열("")이 올 수 있다. null만 체크하면 빈 문자열이 조건에 포함되어 의도하지 않은 결과가 나온다. takeIf { it.isNotBlank() }로 빈 문자열도 걸러야 한다.

where 조건에 and/or 혼용

// 의도: (키워드 포함 AND 발행됨) OR (작성자 일치)
// 실제: 키워드 포함 AND 발행됨 AND 작성자 일치 (모두 AND)
.where(
    keywordContains(keyword),
    publishedEq(true),
    authorNameEq(authorName)
)

where에 여러 조건을 넣으면 모두 AND로 연결된다. OR 조건이 필요하면 BooleanExpression.or()를 명시적으로 사용해야 한다.