티스토리 뷰

개요

  • 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.2'
    }
}

apply plugin: 'kotlin-kapt'

dependencies {
    compile group: 'com.querydsl', name: 'querydsl-jpa', version: "$"
    kapt "com.querydsl:querydsl-apt:$: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로 시작하는 전용 도메인 클래스를 생성하고 이를 통해서 쿼리를 코드 레벨에서 작성하는 것이다. 다행히도 위와 같이 기존에 제공되는 플러그인을 설정하여 도메인 클래스를 빌드 시점에 자동 생성시킬 수 있다. (따라서 당연하게도 특정 엔티티 필드에 변경점이 발생하면 빌드 태스크를 실행해주어야 변경점이 반영된 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())
}

SELECT 조회, DTO 프로젝션 적용

  • SELECT 쿼리문를 조회 대상이 되는 엔티티 단위나 단일 필드가 아닌 원하는 DTO로 자유롭게 프로젝션할 수 있다. 아래와 같이 Projections.fields()를 이용하면 완전히 Type-Safe하게 프로젝션이 가능하다.
query
    .select(
        Projections.fields(FooDTO::class.java,
                foo.id.`as`(FooDTO::id.name),
                foo.age.`as`(FooDTO::age.name)
                CaseBuilder().`when`(foo.age.lt(30)).then(true).otherwise(false).`as`(FooDTO::isJunior.name),
                CaseBuilder().`when`(foo.age.goe(30)).then(true).otherwise(false).`as`(FooDTO::isSenior.name)
        )
    )
    .from(foo)
    ...
  • 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

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

  • 위 예보다 복잡한 다이나믹 쿼리의 적용 예는 아래와 같다.
fun fetchTeamMatchScoreStats(pageable: Pageable): Page<TeamMatchScoreStatDTO > {

    val team = QTeam("team")
    val match = QMatch("match")
    val query = query
        .select(
            Projections.bean(TeamMatchScoreStatDTO::class.java,
                team.id.`as`("teamId"),
                team.name.`as`("teamName"),
                ExpressionUtils.`as`(
                    JPAExpressions
                        .select(match.score.min())
                        .from(match)
                        .where(match.teamId.eq(team.id)),
                    "minScore"
                ),
                ExpressionUtils.`as`(
                    JPAExpressions
                        .select(match.score.max())
                        .from(match)
                        .where(match.teamId.eq(team.id)),
                    "maxScore"
                )
            )
        )
        .from(team)
        .where(match.win.eq(true))
        .orderBy(
            team.totalWinPoint
                    .add(team.totalDrawPoint)
                    .add(team.totalDefeatPoint)
                    .desc(),
            team.totalWinPoint.desc(),
            team.totalDrawPoint.desc(),
            team.totalDefeatPoint.desc(),
        )

    return PageImpl(
        querydsl!!.applyPagination(pageable, query).fetch(), 
        pageable, 
        query.fetchCount()
    )
}
  • JPAExpressions.select()를 이용하여 SELECT 또는 WHERE 조건절에 서브 쿼리를 표현할 수 있다. 또한 ExpressionUtils.as()를 이용하여 앨리어스를 부여할 수 있다.
  • Projecions.bean()을 이용하여 쿼리 조회 결과를 엔티티가 아닌 특정 DTO 오브젝트에 맵핑할 수 있다.

SELECT 조회, 네이티브 함수 사용

  • Querydsl에서 데이터베이스 고유의 네이티브 함수를 사용하려면 해당 데이터베이스에 대한 Dialect 구현체를 생성하고 등록해야 한다. 먼저 구현체를 작성하는 방법은 아래와 같다.
import org.hibernate.dialect.MariaDB103Dialect
import org.hibernate.dialect.function.StandardSQLFunction
import org.hibernate.type.StandardBasicTypes

class QuerydslMariaDBDialect : MariaDB103Dialect() {

    init {
        registerFunction("md5", StandardSQLFunction("md5", StandardBasicTypes.STRING))
        registerFunction("group_concat", StandardSQLFunction("group_concat", StandardBasicTypes.STRING))
    }
}
  • 중요한 점은 프로젝트에서 사용하는 실제 물리 데이터베이스에 해당하는 Dialect 구현체를 상속하여 작성해야 한다. 위 예제에서는 MariaDB 10.3에 해당하는 MariaDB103Dialect 구현체를 상속하였다. 구현체 작성이 완료되었으면 아래와 같이 /src/main/resources/applicaion.yml 파일에 작성한 구현체를 등록해야 한다.
spring:
  jpa:
    database-platform: com.jsonobject.example.QuerydslMariaDBDialect
  • 이제 네이티브 함수를 사용할 모든 준비가 완료되었다. 아래와 같이 실사용이 가능하다.
// select 절의 필드에 앞서 등록한 네이티브 함수를 사용하는 것이 가능하다.
FooRepositorySupport.query
    .select(
        foo.id,
        Expressions.stringTemplate("md5()", foo.id)
        Expressions.stringTemplate("group_concat()", foo.barId)
    )
    .from(foo)
    .groupBy(foo.id)
    .orderBy(foo.barId.asc())
    .fetch()

// 함수를 사용한 필드를 변수화하면 where, having 절에도 사용할 수 있다.
// 해당 필드에 대한 alias라고 생각하면 된다.
val field1: StringExpression = Expressions.stringTemplate("md5()", foo.id)
val field2: StringExpression = Expressions.stringTemplate("group_concat()", foo.barId)

UPDATE

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

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

프로덕션 적용 후기

  • JPA의 꽃이라고 할 수 있는 @OneToXXX, @ManyToXXX 조인 조건을 모든 엔티티에서 제거했다. 프로토타입 수준에서는 문제가 안됬지만 프로젝트가 복잡해지고 운영 레벨에 다가갈수록 오히려 제약으로 다가왔다. 대신 다양한 비지니스 상황에 따라 XXXRepositorySupport의 메써드 레벨에서 엔티티 간의 조인 조건을 직접 명시하고, 필요한 필드만 특정 DTO로 획득하는 전략으로 최종 정착하여 평화와 행복을 얻었다.
  • 웹 서비스에서 특정 검색 조건과 정렬 조건, 페이지네이션이 동시에 다이나믹하게 반영되어야 하는 SELECT 목록 조회는 가장 반복적이고 지루한 작업이다. 이 경우, 요청 DTO 레벨에서 검색 조건은 Predicate, 정렬 조건은 Array<OrderSpecifier<*>로 변환하고 XXXRepositorySupport에서 fetchAll(predicate: Predicate, orderSpecifiers: Array<OrderSpecifier<*>>, pageable: Pageable)로 받아 처리하면 최소의 코드로 생산성 있는 개발이 가능하다. (이 방법이 Spring Data JPA의 지저분하고 장황한 Specification 구현체를 작성하는 것보다 훨씬 깔끔하고 강력하다.)
  • 시간이 흐를수록 INSERT를 제외한 SELECT, UPDATE, DELETE 문은 XXXRepositorySupport에서 직접 쿼리문을 작성하여 해결하는 형태로 변해갔다. 메써드 네이밍 조건을 엄격하게 통일하니 코드 가독성이 훌륭해졌다. (예를 들면 fetchOneById, fetchByXXXAndYYY, fetchAll, fetchCountByXXX와 같은 식으로 네이밍을 구체적으로 정의했다.)

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함