Develop/spring-data

[Querydsl-JPA] 자주 사용하는 기능 정리 (Kotlin)

에디개발자 2021. 5. 10. 07:00
반응형

이번 글에서는 다양한 프로젝트를 진행하면서 자주 사용되었던 Querydsl-JPA를 정리해보겠습니다. Kotlin Querydsl-JPA 설정방법이 궁금하신 분은 지난번 작성한 이글을 참조해주시기 바랍니다.

 

모든 소스는 Github에 올렸습니다. 참조해주세요 :)  

나를 닮았다고 한다...

간단한 목차입니다. 찾으시는 기능이 있으시다면 아래의 목차를 복사하여 Ctrl + F 로 검색하시면 빠르게 찾으실 수 있습니다. 

  • 간단한 RUD
    • Read
      • 전체조회
      • 원하는 필드만 조회
        • QueryProjection Annotation
        • Projections.fields
    • Update
    • Delete
  • 게시판 관련 쿼리 케이스
    • Paging 기능 추가 케이스
    • Page && Sort 기능과 검색조건 포함된 케이스
    • Many Dynamic Query
  • 대용량 데이터 처리 케이스
    • exists
    • 커버링 인덱스 케이스
글 작성 방식

1. Querydsl을 이용한 쿼리 작성
2. Test Code 작성

 

테스트 코드 BeforeEach...

 

@BeforeEach
fun beforeEach() {
    storeRepository.deleteAll()
    staffRepository.deleteAll()

    val staffs = listOf(
        Staff(name = "yong1", age = 20, lastName = "lim1"),
        Staff(name = "yong11", age = 20, lastName = "lim11"),
        Staff(name = "yong21", age = 20, lastName = "lim21"),
        Staff(name = "yong31", age = 20, lastName = "lim31"),
        Staff(name = "yong41", age = 20, lastName = "lim41"),
        Staff(name = "yong51", age = 20, lastName = "lim51"),
        Staff(name = "yong61", age = 20, lastName = "lim61"),
        Staff(name = "yong71", age = 20, lastName = "lim71"),
        Staff(name = "yong81", age = 20, lastName = "lim721"),
        Staff(name = "yong91", age = 20, lastName = "lim731"),
        Staff(name = "yong101", age = 20, lastName = "lim741"),
        Staff(name = "yong171", age = 20, lastName = "lim761"),
        Staff(name = "yong1271", age = 20, lastName = "lim7261"),
        Staff(name = "yong282", age = 21, lastName = "lim862"),
        Staff(name = "yong3", age = 22, lastName = "lim3"),
        Staff(name = "yong4", age = 23, lastName = "lim4"),
        Staff(name = "yong5", age = 24, lastName = "lim5"),
        Staff(name = "yong6", age = 25, lastName = "lim6"),
        Staff(name = "yong7", age = 26, lastName = "lim7"),
        Staff(name = "yong8", age = 27, lastName = "lim8"),
        Staff(name = "yong9", age = 28, lastName = "lim9"),
        Staff(name = "yong10", age = 29, lastName = "lim10")
    )

    val store = Store(name = "fastview", address = "강남역 미왕빌딩")
    store.addStaffs(staffs = staffs)
    storeRepository.save(store)
}

 

간단한 RUD

전체조회

가장 심플한 전체 조회 쿼리입니다.

/**
 * 전체 조회
 */
override fun searchAll(): List<Staff> =
    queryFactory
        .selectFrom(staff)
        .fetch()

 

Test Code

@Test
@DisplayName("전체 조회 테스트")
fun searchAllTest() {
    // Given


    // When
    val searchAll = staffRepository.searchAll()

    // Then
    assertThat(searchAll).hasSize(22)
}

 

원하는 필드만 조회

대용량 데이터를 조회할 경우 모든 컬럼을 조회하는 것보다 필요한 필드만 조회하는 것이 성능상 좋은 결과를 가져옵니다.

 

QueryProjection Annotation

원하는 필드를 받는 Vo객체를 QClass 로 생성하는 방법입니다. Vo 객체는 @QueryProjection을 작성한 생성자를 생성하여 사용합니다. 

QClass 경로 설정방법은 이 글 제일 상단에 설정방법 링크를 참조해주시기 바랍니다.  
class StaffVo @QueryProjection constructor(
    val id: Long,
    val name: String
) {
    override fun toString(): String {
        return "StaffVo(id=$id, name='$name')"
    }
}

 

생성된 Vo QClass를 사용하여 아래와 같이 원하는 필드만 받을 수 있습니다. 

/**
 * VO 객체에 QueryProjection Annotation을 사용하여 return 객체를 설정한 케이스
 */
override fun search(name: String): StaffVo? =
    queryFactory
        .select(
            QStaffVo(
                staff.id,
                staff.name
            )
        )
        .from(staff)
        .where(staff.name.eq(name))
        .fetchOne()

 

 

Projections.fields

QClass 를 생성하지 않고 원하는 컬럼을 Vo객체의 필드와 매칭시켜 조회하는 방법입니다.

/**
 * Projections.fields를 사용하여 return 객체를 설정한 케이스
 */
fun search2(name: String): StaffVo? =
    queryFactory
        .select(
            Projections.fields(
                StaffVo::class.java,
                staff.id,
                staff.name
            )
        )
        .from(staff)
        .where(staff.name.eq(name))
        .fetchOne()

 

위 두 개의 테스트코드는 동일합니다.

@Test
@DisplayName("조건 조회 테스트")
fun searchTest() {
    // Given
    val name = "yong1"

    // When
    val staff = staffRepository.search(name)

    // Then
    assertThat(staff).isNotNull
    assertThat(staff?.name).isEqualTo(name)
}

 

Update

Update 할 경우 Transactional 어노테이션이 필수로 선언 되있어야 합니다.

/**
 * update
 *   - Transactional Annotation require!!
 */
@Transactional
override fun updateQuery(oldName: String, newName: String): Long? =
    queryFactory.update(staff)
        .set(staff.name, newName)
        .where(staff.name.eq(oldName))
        .execute()

 

Test Code

@Test
@DisplayName("수정 테스트")
fun updateQueryTest() {
    // Given
    val id = 1L
    val oldName = "yong1"
    val newName = "임용용"

    // When
    staffRepository.updateQuery(oldName, newName)

    // Then
    val staff = staffRepository.search(newName)
    assertThat(staff).isNotNull
    assertThat(staff?.name).isEqualTo(newName)
}

 

Delete

Delete 할 경우 Transactional 어노테이션이 필수로 선언 되있어야 합니다.

/**
 * delete
 *   - Transactional Annotation require!!
 */
@Transactional
override fun deleteQuery(name: String): Long? =
    queryFactory.delete(staff)
        .where(staff.name.eq(name))
        .execute()

 

Test Code

@Test
@DisplayName("삭제 테스트")
fun deleteQueryTest() {
    // Given
    val name = "yong1"

    // When
    val staffId = staffRepository.deleteQuery(name)

    // Then
    assertThat(staffId).isNotNull
}

 

게시판 관련 쿼리 케이스

화면에 게시판 데이터를 조회할 경우 사용되는 케이스입니다.

 

Paging 기능 추가 케이스

QuerydslRepositorySupport를 참조하여 QuerydslPageAndSortRepository 클래스를 생성하여 페이징 처리를 쉽게 할 수 있도록 작성하였습니다 

open class QuerydslPageAndSortRepository(
    private val entityManager: EntityManager,
    private val clazz: Class<*>
) {

    private fun getQuerydsl(): Querydsl {  
        val builder = PathBuilderFactory().create(clazz)  // 1)
        return Querydsl(entityManager, builder)
    }

    fun <T> getPageImpl(pageable: Pageable, query: JPQLQuery<T>): PageImpl<T> {
        val totalCount = query.fetchCount()  // 2)

        val results = getQuerydsl()
            .applyPagination(pageable, query)
            .fetch()  // 3)

        return PageImpl(results, pageable, totalCount)
    }
}

1) 여기에 설정된 class ( QClass를 말한다 ) 가 Main이 됩니다. 

  - class가 QStaff일 경우 :  Sort값으로 "id,asc" 가 넘어온다면 order by staff.id asc 가 적용됩니다.

  - class가 QStore일 경우 : Sort값으로 "id,asc" 가 넘어온다면 order by store.id asc 가 적용됩니다. 

 

2) 조회 쿼리의 전체 카운트 값을 조회합니다.

3) Page, Size값이 적용된 쿼리의 조회 결과를 가져옵니다.

 

QuerydslPageAndSortRepository의 getPageImpl 메서드를 사용하여 쿼리를 작성합니다.

open class StaffCustomRepositoryImpl(
    private val queryFactory: JPAQueryFactory,
    private val entityManager: EntityManager
) : StaffCustomRepository, QuerydslPageAndSortRepository(entityManager, Staff::class.java) {

	/**
     * Paging 기능 추가 케이스
     *   - QuerydslRepositorySupport를 상속받아 사용하는 경우엔 getQuerydsl로 사용할 수 있지만 현재 구성으로는 getQuerydsl을 사용할 수 없음.
     *   - 방안 : 상속받은 QuerydslPageAndSortRepository 클래스에서 QuerydslRepositorySupport 내부 getQuerydsl 메서드를 구현하여 활용
     *
     *   - 게시판 Paging, Order에 사용
     */
    override fun findStaffsByPageImpl(pageable: Pageable): PageImpl<StaffVo> {
        val query: JPQLQuery<StaffVo> = queryFactory
            .select(
                QStaffVo(staff.id, staff.name)
            )
            .from(staff)

        return super.getPageImpl(pageable, query)
    }
    
}

 

Test Code

@Test
@DisplayName("staffs 조회 시 page && sort 적용 테스트")
fun findStaffsByPageImplTest() {
    // Given
    val page = 0
    val pageSize = 10
    val order = Sort.Order.desc("id")
    val pageable = generatePageable(page = page, pageSize = pageSize, order)

    // When
    val staffsPageImpl = staffRepository.findStaffsByPageImpl(pageable)

    // Then
    assertThat(staffsPageImpl.number).isEqualTo(page)
    assertThat(staffsPageImpl.totalPages).isEqualTo(3)
    assertThat(staffsPageImpl.content).hasSize(10)
}

private fun generatePageable(page: Int, pageSize: Int, vararg order: Sort.Order): PageRequest =
        PageRequest.of(page, pageSize, Sort.by(order.asList()))

 

Page && Sort 기능과 검색조건 포함된 케이스

페이징 처리는 위 방법과 동일하게 getPageImpl 메서드를 이용합니다. 여기서 다른 점은 게시판에서는 조회조건이 존재합니다. 그 조회 조건은 빈값이면 무시하고 값이 있으면 조회 조건에 추가되는 경우입니다. 아래와 같이 작성합니다.

/**
 * Page && Sort 기능과 검색조건 포함된 케이스
 */
override fun findDifficultByPageImpl(age: Int, name: String?, lastName: String?, pageable: Pageable): PageImpl<StaffVo> {
    val query: JPQLQuery<StaffVo> = queryFactory
        .select(
            QStaffVo(staff.id, staff.name)
        )
        .from(staff)
        .where(
            staff.age.eq(age),
            eqLastName(lastName),  // dynamic 조건 
            eqName(name)           // dynamic 조건
        )


    return super.getPageImpl(pageable, query)
}

 

Dynamic Query에 사용될 private method

where절에서 null이면 조회 조건은 무시됩니다.

/**
 * Dynamic Query에 사용될 private mothod
 * return 값이 null이면 조회 조건은 무시된다.
 */
private fun eqLastName(lastName: String?): BooleanExpression? =
    if (lastName != null)
        staff.lastName.eq(lastName)
    else
        null


/**
 * Dynamic Query에 사용될 private mothod
 * return 값이 null이면 조회 조건은 무시된다.
 */
private fun eqName(name: String?): BooleanExpression? =
    if (name != null)
        staff.name.eq(name)
    else
        null

 

Test Code

@Test
@DisplayName("staffs 조회 시 검색 조건 및 page && sort 적용 테스트")
fun findDifficultByPageImplTest() {
    // Given
    val page = 1
    val pageSize = 10
    val order1 = Sort.Order.desc("id")
    val order2 = Sort.Order.desc("name")
    val order3 = Sort.Order.asc("lastName")

    val pageable = generatePageable(page = page, pageSize = pageSize, order1, order2, order3)

    // When
    val staffsPageImpl = staffRepository.findDifficultByPageImpl(20, null, null, pageable)

    // Then
    for (staffVo in staffsPageImpl) {
        println("response data: ${staffVo.toString()}")
    }

    assertThat(staffsPageImpl.number).isEqualTo(page)
    assertThat(staffsPageImpl.totalPages).isEqualTo(2)
    assertThat(staffsPageImpl.content).hasSize(3)
}

private fun generatePageable(page: Int, pageSize: Int, vararg order: Sort.Order): PageRequest =
    PageRequest.of(page, pageSize, Sort.by(order.asList()))

 

 

Many Dynamic Query

만약 조회조건이 많이진다면 메서드 또한 많아질 것입니다. 다른 블로그 글을 보면 모두 메서드로 빼면 가독성이 올라간다고 작성되어 위와 같은 방법을 추천하지만 전 조회 조건이 30개가 넘는 경우가 있어서(...ㅠㅠ) 하나의 메서드에 BooleanBuilder로 작성하는 방법 또한 작성해보겠습니다.

/**
 * Many Dynamic Query 케이스
 *   - 단 건의 dynamic query 작성 시 메서드로 빼는 것이 가독성이 좋으나 많아진다면 한번에 처리하는 메서드로 처리한다.
 * @param name String?
 * @param address String?
 * @return Staff?
 */
override fun manyDynamicQuery(name: String?, address: String?): Staff? =
    queryFactory
        .selectFrom(staff)
        .where(searchCondition(name = name, address = address))
        .fetchOne()


private fun searchCondition(name: String?, address: String?): BooleanBuilder {
    val builder = BooleanBuilder()

    name?.let { builder.and(staff.name.eq(it)) }
    address?.let { builder.and(staff.name.eq(it)) }

    return builder
}
코틀린의 ?.let 메서드를 이용하면 왼쪽의 값이 null이 아닐경우에만 오른쪽 블록을 실행합니다.
위 로직의 경우 null이 아닐 경우에만 BooleanBuilder에 조건을 추가합니다.

 

대용량 데이터 처리 케이스

exists

Querydsl에서 지원하는 exists 기능은 mysql에서 사용하는 것과 다르게 전체 데이터를 카운팅한 후 0 보다 클 경우 조건으로 판별합니다. 즉 데이터가 많아질 수록 성능은 저하됩니다.

 

추천하지 않는 방법

/**
 * exists 사용
 *   - 추천하지 않음 데이터 양이 늘어날수록 성능이 낮아짐
 */
override fun findExist(name: String): Boolean? {
    val exists = queryFactory
        .selectOne()
        .from(staff)
        .where(staff.name.eq(name))
        .fetchAll()
        .exists()

    return queryFactory
        .select(exists)
        .from(staff)
        .fetchOne()
}

 

추천 방법

/**
 * exists 성능 이슈 대안
 *   - 대용량 처리 시 성능 향상
 */
override fun findLimitOneInsteadOfExist(name: String): Boolean {
    val fetchFirst = queryFactory
        .selectOne()
        .from(staff)
        .where(staff.name.eq(name))
        .fetchFirst() // limit(1).fetchOne()

    return fetchFirst != null // 값이 없으면 0이 아니라 null 반환
}

 

 

커버링 인덱스 케이스

커버링 인덱스란? 
select, from, where 절에 사용되는 컬럼이 모두 인덱스 컬럼일 경우이다.
/**
 * 커버링 인덱스 케이스
 *   - 대용량 처리 시 성능 향상
 */
override fun findByCoveringIndex(name: String): List<Staff> {
    val houseIds: List<Long> = queryFactory
        .select(house.id)
        .from(house)
        .where(house.name.eq(name))
        .fetch()

    return if (houseIds.isEmpty()) {
        arrayListOf()
    } else queryFactory
        .selectFrom(staff)
        .join(staff.house, house)
        .where(house.id.`in`(houseIds))
        .fetch()
}

 

 

여기까지 Querydsl을 사용하면서 자주 사용되는 예제를 정리해보았습니다. 추가적으로 유용한 기능이나 편리한 기능이 있으면 더 작성할 예정입니다. 

반응형