이번 글에서는 다양한 프로젝트를 진행하면서 자주 사용되었던 Querydsl-JPA를 정리해보겠습니다. Kotlin Querydsl-JPA 설정방법이 궁금하신 분은 지난번 작성한 이글을 참조해주시기 바랍니다.
모든 소스는 Github에 올렸습니다. 참조해주세요 :)
간단한 목차입니다. 찾으시는 기능이 있으시다면 아래의 목차를 복사하여 Ctrl + F 로 검색하시면 빠르게 찾으실 수 있습니다.
- 간단한 RUD
- Read
- 전체조회
- 원하는 필드만 조회
- QueryProjection Annotation
- Projections.fields
- Update
- Delete
- Read
- 게시판 관련 쿼리 케이스
- 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을 사용하면서 자주 사용되는 예제를 정리해보았습니다. 추가적으로 유용한 기능이나 편리한 기능이 있으면 더 작성할 예정입니다.
'Develop > spring-data' 카테고리의 다른 글
[Redis] 성능개선 - 캐싱 처리 3가지 방법 (2) | 2021.08.17 |
---|---|
[kotlin] Querydsl-JPA GroupBy 사용했을 경우 Paging처리 방법 (1) | 2021.08.06 |
Querydsl Join Table Sort 적용 ( 번외로 Pageable와 비슷한 것을 구현해보자! ) (0) | 2021.03.14 |
[Querydsl] 성능개선 - 3편 ( group by, 커버링 인덱스, update ) (2) | 2021.02.04 |
[Querydsl] 성능개선 - 2편 ( N + 1 ) (0) | 2021.02.01 |