티스토리 뷰

개요

  • Java 진영에서 RDBMS에 접근하는 방법은 기존의 MyBatis가 지배하던 시대에서 JPA로 빠르게 이동하고 있다. 특히, Spring Data JPA의 등장으로 불필요한 코드가 상당히 줄어들면서 새로 시작하는 프로젝트는 모두 Spring Data JPA로 구현하는 추세이다. 하지만, 여전히 RAW SQL의 수요가 존재하기 때문에 컴파일 타임에서의 타입 세이프를 보장하는 Querydsl, jOOQ와 같은 라이브러리가 JPA를 보조하는 형태로 인기를 끌고 있다.(특히, 보고서 목적의 복잡한 SELECT 쿼리가 필요한 경우가 많다.) 이번 글에서는 Kotlin, Spring Boot, Spring Data JPA 환경에서 Querydsl을 이용하여 타입 세이프를 보장하는 쿼리를 작성하는 방법을 소개하고자 한다.

사전조건

  • QueryDSL 구성에 앞서 Spring Data JPA 기반의 환경 구성이 필요하다. 본 블로그의 이 글을 참고한다.

build.gradle

  • 가장 먼저, 프로젝트 루트의 /build.gradle 파일에 아래 내용을 추가한다.
buildscript {
    ext {
        querydslVersion = '4.2.1'
    }
}

apply plugin: 'kotlin-kapt'

dependencies {
    compile group: 'com.querydsl', name: 'querydsl-jpa', version: "${querydslVersion}"
    kapt "com.querydsl:querydsl-apt:${querydslVersion}:jpa"
    kapt "org.hibernate.javax.persistence:hibernate-jpa-2.1-api:1.0.2.Final"
}

sourceSets {
    main.java.srcDirs += [file("$buildDir/generated/source/kapt/main")]
}
  • Querydsl이 타입 세이프를 보장하는 방법은 모든 @Entity 클래스에 대해 Q로 시작하는 전용 도메인 클래스를 생성하고 이를 통해서 쿼리를 코드 레벨에서 작성하는 것이다. 다행히도 위와 같이 기존에 제공되는 플러그인을 설정하여 도메인 클래스를 컴파일 타임에 자동 생성시킬 수 있다.
  • 전용 도메인 클래스는 /build/generated/source/kapt/main 디렉토리 이하에 패키지 단위로 자동 생성된다. sourceSets 옵션을 수정하여 생성 위치를 변경할 수 있다.

@Configuration

  • JPA 설정 단계에서 생성한 EntityManagerFactory 빈을 받아 JPAQueryFactory 빈을 생성할 차례이다. 데이터소스 단위로 생성해야 하며, 아래 설명할 QuerydslRepositorySupport를 상속할 리파지터리 빈을 생성할 때 필요하다.
package com.jsonobject.example

import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import javax.persistence.EntityManager
import javax.persistence.PersistenceContext

@Configuration
class QuerydslConfig(

        @PersistenceContext(unitName = "foo")
        @Qualifier(value = "entityManagerFactory")
        val fooEntityManager: EntityManager,

        @PersistenceContext(unitName = "bar")
        @Qualifier(value = "barEntityManagerFactory")
        val barEntityManager: EntityManager
) {
    @Bean
    fun fooJpaQueryFactory(): JPAQueryFactory {

        return JPAQueryFactory(fooEntityManager)
    }

    @Bean
    fun barJpaQueryFactory(): JPAQueryFactory {

        return JPAQueryFactory(barEntityManager)
    }
}

QuerydslPredicateExecutor

  • Spring Data JPA 기반으로 작성된 리파지터리 인터페이스에 아래와 같이 QueryDSL에 제공하는 QuerydslPredicateExecutor 인터페이스를 추가로 상속하면 여러 편리한 기능을 이용할 수 있다.
interface UserRepository : JpaRepository<User, Long>, QuerydslPredicateExecutor<User>
  • QuerydslPredicateExecutor 인터페이스가 제공하는 추가된 메써드 중에는 Predicate 파라메터가 가장 주목할 만한데, 리파지터리에 추가 메써드를 작성할 필요 없이 다양한 필드에 대한 Type-Safe한 조건식을 우아하게 전달할 수 있어 매우 편리하다. 사용 예는 아래와 같다.
val predicate = QUser.user.email.eq("foobar@gmail.com")
userRepository.findOne(predicate)
userRepository.findAll(predicate)
userRepository.findAll(predicate, Sort.by("id").descending())
userRepository.findAll(predicate, QUser.user.id.desc(), QUser.user.email.desc())
userRepository.findAll(predicate, PageRequest.of(0, 10, Sort.by("id").descending()))
userRepository.count(predicate)
userRepository.exists(predicate)
  • 내 경우, 현업 부서의 다양한 요구사항에 빠르게 대응하기 위해, 특수한 상황이 아니라면 거의 전적으로 위 메써드 만으로 비지니스 로직을 제작한다. JVM 생태계에 있어 현 시점에서, 데이터베이스 조회에 있어 최고의 생산성을 제공한다고 생각된다. (Spring Data JPA로 제작하다 보면, 리파지터리에 무수히 많은 단발성 메써드가 범람하게 되어 소스 코드의 가독성이 극단적으로 저하되는 사례를 현업에서 몇년간 경험해왔다.)
  • Predicate를 이용하여 IN 조건을 목록으로 전달할 경우 런타임에서 java.lang.StackOverflowError 예외가 발생하는 경우가 있다. 이 경우, JVM 옵션에서 -Xss100m와 같이 적절한 스택 사이즈를 할당하면 문제가 해결된다.

QuerydslRepositorySupport

  • 앞서 QuerydslPredicateExecutor 인터페이스의 추가 만으로도 엔티티 조회 기능 작성이 상당히 편리해졌지만, 비지니스 로직에서의 필요에 따라 수없이 복잡한 쿼리에 대한 수요는 항상 발생한다. QuerydslRepositorySupport는 이러한 상황에 맞게 유연하고 직관적인 쿼리 작성을 가능하게 해준다. 작성 예는 아래와 같다.
package com.jsonobject.example

import com.querydsl.core.Tuple
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
import org.springframework.stereotype.Repository
import javax.annotation.Resource
import com.jsonobject.example.QFoo.foo as FOO
import com.jsonobject.example.QBar.bar as BAR

@Repository
class FooBarRepositorySupport(

    @Autowired
    @Resource(name = "jpaQueryFactory")
    val query: JPAQueryFactory

) : QuerydslRepositorySupport(FOO::class.java) {

    @PersistenceContext(unitName = "somedb")
    override fun setEntityManager(entityManager: EntityManager) {
        super.setEntityManager(entityManager)
    }

    fun findAll(): List<Tuple> {

        return query.select(
                FOO.id,
                FOO.someField,
                BAR.id,
                BAR.someField
            )
            .from(FOO)
            .innerJoin(BAR)
            .on(BAR.fooId.eq(FOO.id))
            .fetch()
    }
}
  • 복수개의 물리 데이터베이스에 접근하여 한다면(즉, 복수개의 EntityManagerFactory 빈이 존재한다면) setEntityManager()를 오버라이드하여 명확하게 사용할 빈을 명시해야 오류가 발생하지 않는다. 프로젝트에서 단 1개의 물리 데이터베이스에만 접근한다면 생략해도 된다.

SELECT 목록 조회, 다이나믹 쿼리 적용

  • SELECT 쿼리문에 다이나믹 쿼리를 적용한 작성 예는 다음과 같다.
fun fetchByNameAndEmail(name: String, email: String): List<User> {

    val where = BooleanBuilder()
    if (name.isNotEmpty()) {
        where.and(user.name.eq((name)))
    }
    if (email.isNotEmpty()) {
        where.and(user.email.eq((email)))
    }

    return query
            .selectFrom(user)
            .where(where)
            .fetch()
    }
  • 위는 다이나믹 쿼리를 작성하는 가장 일반적인 예로, 생산성을 위해서는 위 방법보다는 앞서 설명한 Predicate를 그대로 파라메터로 전달 받을 것을 추천한다. 즉, 서비스 레이어에서 이미 완성된 Predicate 객체를 전달 받는 것이다. 그 시작점이 컨트롤러 레이어라면, DTO 클래스로 조건 필드를 받아 Predicate 객체로 변환하는 기능을 작성해야 할 것이다. (이 방법이 Spring Data JPA가 제공하는 Specification 구현체 작성보다 훨씬 간결하고 막강하다고 생각한다.)

SELECT 목록 조회, 페이지네이션 적용

  • SELECT 쿼리문에 페이지네이션을 적용한 작성 예는 다음과 같다.
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable

/**
 * @return 페이지네이션 정보를 포함한 Page 객체
 */
fun fetchAll(pageable: Pageable): Page<User> {

    val query = query.selectFrom(user)
    val users = querydsl!!.applyPagination(pageable, query).fetch()

    return PageImpl<User>(users, pageable, query.fetchCount())
}
  • Pageable 객체를 파라메터로 전달하면, 컨트롤러 레벨에서의 요청과 응답 과정에서 일체의 개발자의 개입 없이 페이지네이션을 자동으로 제어해주기 때문에 상당히 편리하다.

SELECT 목록 조회, @OneToMany, @ManyToMany

  • 기본적으로 @OneToMany, @ManyToMany 관계의 사용을 추천하지 않는다. N+1 이슈 뿐만 아니라 페이지네이션 적용시 원하는 데이터만 조회하는 것이 아닌 테이블을 풀스캔하는 문제를 가지고 있다. 서비스 레이어에서 최대한 아래 설명할 @OneToOne, @ManyToOne 관계를 사용할 것을 추천한다.
  • 아래는 Foo라는 엔티티와 Bar라는 엔티티가 1:N의 관계를 가질 때 Foo 엔티티에 Bar 엔티티 목록을 선언한 예이다.
@OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL])
@Where(clause = "active = true AND deleted = false")
@JoinColumn(name = "foo_id", nullable = true, insertable = false, updatable = false)
var bars: MutableSet<Bar>? = null
  • @WhereHibernate가 제공하는 어노테이션으로 1:N 조인시 조인 조건을 지정할 수 있다. 조건에 명시할 컬럼명은 실제 데이터베이스의 컬럼명을 기입해야 한다.
  • @JoinColumnname에는 N개 조인 대상 엔티티의 FK 컬럼명을 기입해야 한다. 또한 1:N 조인시 List가 아닌 MutableSet 타입을 사용해야 한다.

SELECT 목록 조회, @ManyToOne 관계의 N+1 문제 해결

  • QueryDSL은 관계에서 발생하는 N+1 문제를 해결할 수 있다. 1개의 User 엔티티에 N개의 UserRole 엔티티가 맵핑된다고 가정하면, UserRole 엔티티는 아래와 같이 설계할 수 있다.
@Entity
@Table(name = "user_role")
data class UserRole(
        ...
        @Column(name = "user_id")
        var userId: Long = 0,

        @ManyToOne
        @NotFound(action = NotFoundAction.IGNORE) // javax.persistence.EntityNotFoundException 예방 목적
        @JoinColumn(name = "user_id", nullable = true, insertable = false, updatable = false)
        var user: User? = null,
        ...
)
  • 이제 QueryDSL 기반의 리파티지터리 클래스를 아래와 같이 작성해보자.
fun fetchAll(predicate: Predicate): List<UserRole> {

    val userRole = QUserRole.userRole
    return query
            .selectFrom(userRole)
            .leftJoin(userRole.user).fetchJoin()
            .where(predicate)
            .fetch()
}
  • Spring Data JPA와 구분하기 위해 findAll() 대신 fetchAll()로 작명하였다. leftJoin()fetchJoin()을 사용하여 LEFT OUTER JOIN으로 User 엔티티를 한번에 불러오도록 명시했다. 이 메써드를 호출시 실행되는 쿼리는 아래와 같다. SELECT을 N개 만큼 실행하는 findAll()과 다르게 단일 쿼리를 실행한 것을 확인할 수 있다. 앞서 설명한 페이지네이션 또한 문제 없이 적용할 수 있다.
select
    ...
from
    user_role userrole0_
left outer join
    user user1_
        on userrole0_.user_id = user1_.id
where
    userrole0_.id is not null

UPDATE

  • UPDATE 쿼리문의 작성 예는 다음과 같다.
/**
 * @return 갱신된 엔티티 수
 */
fun updateNameById(name: String, id: Long): Long {

    return query
            .update(user)
            .set(user.name, name)
            .where(user.id.eq(id))
            .execute()
}

참고 글

댓글
댓글쓰기 폼