광주소프트웨어마이스터고등학교의 MSG팀에서 진행한 GAuth라는 프로젝트에서 검색 api를 만들면서 querydsl를 적용한 사례를 오늘 작성해보려고 한다. 작성해보고 나서의 장점에 대해서도 얘기해보겠다.
요구사항은 이랬다. 학년, 반, 키워드를 통해 유저를 검색한다. 만약 학년, 반에 0이 들어온다면 전체 검색의 기준을 달고 키워드는 "" 빈 문자열이 들어오면 전체 검색 기준을 단다. 예를 들어 학년 반 키워드에 2, 0, "김" 이 들어오면 2학년 김씨 모두를 검색한다.
처음에는 findAll을 통해서 모든 유저리스트를 가져오고 filter를 통해서 유저를 걸러내었다.
class GetAcceptedUsersService(
private val userRepository: UserRepository,
) {
fun execute(grade: Int, classNum: Int, keyword: String, page: Int, size: Int): Page<SingleAcceptedUserResDto> =
userRepository.findAllByState(UserState.CREATED, PageRequest.of(page, size))
.let { filterUser(it, grade, classNum, keyword) }
.map { SingleAcceptedUserResDto(it) }
private fun filterUser(users: Page<User>, grade: Int, classNum: Int, keyword: String): Page<User> =
users.filter { grade == 0 || it.grade == grade }
.filter { classNum == 0 || it.classNum == classNum }
.filter { keyword.isEmpty() || it.name!!.contains(keyword) }
.toList()
.let { PageImpl(it, users.pageable, it.size.toLong()) }
}
이렇게 되면 문제가 생긴다. 내부 연산이 많이 일어나게 된다. 그리고 키워드를 거를때 contains를 사용하는데 가입된 유저의 수가 많을 수록 더 많은 연산을 수행하기때문에 프로그램의 성능이 저하된다.
두번째 구현때는 좀 멍청한 하드코딩을 했는데 if문으로 인자 여부를 확인하고 그에 맞는 JPA find를 날렸다.
package com.msg.gauth.domain.user.services
import com.msg.gauth.domain.user.User
import com.msg.gauth.domain.user.enums.UserState
import com.msg.gauth.domain.user.presentation.dto.response.SingleAcceptedUserResDto
import com.msg.gauth.domain.user.repository.UserRepository
import com.msg.gauth.global.annotation.service.ReadOnlyService
import org.springframework.data.domain.Pageable
@ReadOnlyService
class GetAcceptedUsersService(
private val userRepository: UserRepository,
) {
fun execute(grade: Int, classNum: Int, name: String, pageable: Pageable): List<SingleAcceptedUserResDto> {
val userList: List<User>
= when {
grade == 0 && classNum == 0 && name == "" -> userRepository.findAllByStateOrderByGrade(UserState.CREATED, pageable)
grade == 0 && classNum == 0 -> userRepository.findAllByStateAndNameContainingOrderByGrade(UserState.CREATED, name, pageable)
classNum == 0 && name == "" -> userRepository.findAllByStateAndGradeOrderByGrade(UserState.CREATED, classNum, pageable)
grade == 0 && name == ""-> userRepository.findAllByStateAndClassNumOrderByGrade(UserState.CREATED, grade, pageable)
grade == 0 -> userRepository.findAllByStateAndClassNumAndNameContainingOrderByGrade(UserState.CREATED, classNum, name, pageable)
classNum == 0 -> userRepository.findAllByStateAndGradeAndNameContainingOrderByGrade(UserState.CREATED, grade, name, pageable)
name == "" -> userRepository.findAllByStateAndGradeAndClassNumOrderByGrade(UserState.CREATED, grade, classNum, pageable)
else -> userRepository.findAllByStateAndGradeAndClassNumAndNameContainingOrderByGrade(UserState.CREATED, grade, classNum, name, pageable)
}
return userList.map {
SingleAcceptedUserResDto(it)
}
이건 다시봐도 좀 에바다 ㅋㅋㅋ
동적 쿼리를 작성하기 위해 JPQL, Criteria나 QueryDSL을 사용해야한다.
그래서 결국 나는 QueryDSL를 채택하기로 했다.
그렇게 쿼리 dsl로 작성한 코드다.
package com.msg.gauth.domain.user.repository
import com.fasterxml.jackson.databind.util.ArrayBuilders
import com.msg.gauth.domain.user.QUser.user
import com.msg.gauth.domain.user.User
import com.querydsl.core.BooleanBuilder
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.impl.JPAQueryFactory
import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport
import org.springframework.stereotype.Repository
import javax.persistence.EntityManager
import javax.persistence.PersistenceContext
@Repository
class CustomUserRepositoryImpl(
private val jpaQueryFactory: JPAQueryFactory
) : CustomUserRepository {
override fun searchUser(grade: Int, classNum: Int, keyword: String): List<User> {
val booleanBuilder = BooleanBuilder()
if (grade != 0) {
booleanBuilder.and(user.grade.eq(grade))
}
if (classNum != 0) {
booleanBuilder.and(user.classNum.eq(classNum))
}
if (keyword.isNotEmpty()) {
booleanBuilder.and(user.name.like("%$keyword%"))
}
return jpaQueryFactory.selectFrom(user)
.where(booleanBuilder)
.fetch()
}
}
import com.msg.gauth.domain.user.presentation.dto.response.SingleAcceptedUserResDto
import com.msg.gauth.domain.user.repository.UserRepository
import com.msg.gauth.global.annotation.service.ReadOnlyService
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
@ReadOnlyService
class GetAcceptedUsersService(
private val userRepository: UserRepository,
) {
fun execute(grade: Int, classNum: Int, keyword: String): List<SingleAcceptedUserResDto> =
userRepository.searchUser(grade, classNum, keyword)
.map { SingleAcceptedUserResDto(it) }
}
그렇게 성공적으로 리팩터링을 끝나치나 싶었던 찰나에 졸업생 선배분의 피드백이 있었다. 그것은 바로 BooleanBuilder가 아닌 BooleanExpression을 사용하란 것이였다.
그렇게 최종적으로 리팩터링한 코드다.
@Repository
class CustomUserRepositoryImpl(
private val jpaQueryFactory: JPAQueryFactory
) : CustomUserRepository {
override fun search(grade: Int, classNum: Int, keyword: String): List<User> {
val expression = (if (grade != 0) user.grade.eq(grade) else null)
?.and(if (classNum != 0) user.classNum.eq(classNum) else null)
?.and(if (keyword.isNotEmpty()) user.name.like("%$keyword%") else null)
return if(expression == null){
jpaQueryFactory.selectFrom(user)
.fetch()
} else {
jpaQueryFactory.selectFrom(user)
.where(expression)
.fetch()
}
}
}
이렇게 querydsl로 연산도 줄이고 더러운 하드 코딩도 줄일 수 있게 되었다.
앞으로 동적쿼리를 짤때 계속 공부하고 응용을 해보아야겠다.
이렇게 나의 첫 querydsl 적용기는 이렇게 끝을 내렸다.