오늘은 자바에서 사용할 수 있는 다양한 동적 쿼리 방법을 알아보고, 왜 Jpa Criteria를 선택했는지 말씀드리려고 합니다
이번에 프로젝트에서 어드민 페이지를 만들던 과정에서 여러 가지 조건으로 필터링되어야 하는 데이터를 쿼리 할 필요가 생겼습니다.
상태가 성공인 데이터를 여러 조건에 맞게 조회해야 하는데요
원본 데이터는 총 3000건 정도이고, 상태가 성공인 건은 1500건 정도입니다.
처음에 생각했던 방법은 총 5가지 정도가 있었습니다.
동적 쿼리의 종류
1. findAll을 사용한 이후에 Application에서 처리한다
@Component
class ApplicationQuery(
private val userRepository: UserRepository
) {
fun findUserByName(name: String): List<User> {
val users = userRepository.findAll()
if (name == null) {
return users
}
return users.filter { it.name == name }
}
}
위에 방식을 선호하시는 분들도 꽤나 계셨습니다.
어드민이기 때문에 TPS를 고려할 필요가 없기 때문이기도 하고
전체 개수가 얼마 되지 않기 때문에, 이 정도까지는 애플리케이션에서 충분히 필터링할 수 있다는 의견이셨습니다.
어떻게 해서든 효율적인 쿼리로 좋은 결과를 내야 한다는 것과는 다른 의미로 오히려 요즘 추세는 DB에 로직을 두지 않는 것을 선호하기도 한다고 하더라고요
DB 가 가장 귀중한 자원이기에, 부담을 최대한 애플리케이션 레벨로 옮겨두는 것이 좋다고 알려주셨습니다
저도 처음에는 그렇게 하려고 했었지만, 여기서 나온 결과를 바탕으로 송금 원장에 ids in (*) 구문을 통해 질의를 해야 한다는 것을 깨닫고, 바로 포기하였습니다
2. String을 + 를 통해 연산한다
가장 간단한 방법이긴 합니다.
학습 테스트를 통해 확인할 수 있는 것처럼, 아래와 같은 결과를 의도하고 있고요
class StringQueryBuilderTest : StringSpec({
"기본 쿼리는 1 = 1 이 끝에 붙는다" {
val query = StringQueryBuilder.create("SELECT * FROM USER")
.build()
query shouldBe "SELECT * FROM USER WHERE 1 = 1"
}
"isEquals 는 필드와 값을 비교하는 쿼리를 생성한다" {
val query = StringQueryBuilder.create("SELECT * FROM USER")
.isEquals("name", "bestudent")
.build()
query shouldBe "SELECT * FROM USER WHERE 1 = 1 AND name = bestudent"
}
})
class StringQueryBuilder internal constructor(
private val query: String
) {
fun isEquals(field: String, value: String): StringQueryBuilder {
return StringQueryBuilder("$query AND $field = $value")
}
fun build(): String {
return query
}
companion object {
fun create(query: String): StringQueryBuilder {
return StringQueryBuilder("$query WHERE 1 = 1")
}
}
}
코드는 다음과 같습니다
직관적이긴 하지만, "name" 같은 필드를 실수로 잘못 넣게 된다면, 런타임에 문제가 발생하게 됩니다.
이 부분을 피하고자 다른 방법을 알아보게 되었습니다
3. Jpa Repository에 조건마다 쿼리를 추가한다
interface UserRepository : JpaRepository<User, Long> {
fun findAllByName(name: String): List<User>
}
repository에 추가된 형태는 위와 같고
@Component
class AllQuery(
private val userRepository: UserRepository
) {
fun findUserByName(name: String?): List<User> {
if (name == null) {
return userRepository.findAll()
}
return userRepository.findAllByName(name)
}
}
위와 같이 코드를 짜게 되면, 조건이 1개 늘어날 때마다, 모든 쿼리가 2배씩 늘어나기 때문에, 간단한 조건 1~2개라면 괜찮지만, 아니라면 복잡도를 매우 높이게 됩니다.
4. 1개의 Big Query를 만들어서 질의한다
쿼리 하나에, Null이라면 어떻게 처리하고, 아니라면 조건을 추가하는 형태로 전부 작성한다
@Component
class OneBigQuery(
private val userRepository: UserRepository
) {
fun findUserByName(name: String?): List<User> {
return userRepository.findAllByNullableName(name)
}
}
이 방식으로 service 레이어는 간단해지게 됩니다.
@Query(
value = """
SELECT t
FROM Transfer t
WHERE 1=1
AND (
(:senderUserNo IS NULL OR t.senderUserNo = :senderUserNo)
)
)
쿼리는 다음과 같은 형태로 짜면 됩니다.
이렇게 되면, 단점으로는 쿼리 테스트를 하기가 힘듭니다.
쿼리 쪽에 복잡한 로직이 들어가다 보니, 데이터가 잘 올 것이라는 보장을 하기가 쉽지 않을 수 있습니다
5. QueryDSL을 사용한다
QueryDSL을 사용해서 동적 쿼리를 만들 수도 있습니다
@Test
void queryDsl_FetchJoinComments_Success() {
EntityManager entityManager = testEntityManager.getEntityManager();
JPAQuery<Post> query = new JPAQuery<>(entityManager);
QPost qPost = new QPost("p");
QComment qComment = new QComment("c");
List<Post> posts = query.distinct()
.from(qPost)
.leftJoin(qPost.comments, qComment).fetchJoin()
.fetch();
assertThat(posts).hasSize(3);
}
위와 같은 형태로 qClass를 사용하는 방식이 있습니다.
하지만, 여기서 QueryDSL 이 가지고 있는 치명적인 단점으로는 프로젝트가 무거워질 수 있다는 것입니다.
qClass를 사용해야 하기에, 추가적으로 컴파일을 진행해야 하고, 버전문제도 생길 수 있다는 점에서 프로젝트에 추가하기 어려울 것이라는 결론을 내렸습니다
그래서 어떤 것을 사용해야 좋을까?
이 질문을 서버 개발자 전체가 있는 슬랙 채널에 올렸습니다
거기서 나온 좋은 방식으로 JpaSpecification을 사용하면 된다고 알려주셨습니다
저희 팀은 Kotlin을 사용하고 있기 때문에, Kotlin의 DSL을 사용한다면 querydsl과 비슷하게 동작하는 것을 만들 수 있다고 하시면서, 버전문제도 없이 갈 수 있다는 장점을 알고서 이를 통해 사용하기로 하였습니다.
fun <T> initSpec(): Specification<T> = Specificaion<T> { _, _, _ -> null }
fun <T, VALUE> Specification<T>.equalNotNull(fieldName: String, value: VALUE?): Specification<T>=
if(value == null) this else
this.and { root, _, builder ->
builder.equal(root.get<VALUE>(field), value)
} as Specification<T>
위와 같이 Specification을 사용하고, 실제 코드는 다음과 같습니다
val userSpec = initSpec<User>()
.equalNotNull(
fieldName = User::name.name,
value = "be-student"
)
val user:List<User> = userRepository.findAll(userSpec)
위와 같이 사용하게 되면, 동적 쿼리를 작동시킬 수 있습니다
이때 userRepository 쪽에 interface 하나를 추가적으로 추가해야 합니다.
@Repository
interface UserRepository: JpaRepository<User,Long>,
JpaSpecificationExecutor<User>{
}
이렇게만 만들면 기존 코드에 큰 변화 없이 동적 쿼리를 쓸 수 있다는 부분이 좋은 것 같아서 Specification 쪽을 사용하기로 결정했습니다
Criteria를 사용하다 보면, HQLQueryPlan 객체가 Heap 메모리를 과도하게 사용하는 문제가 있습니다.
spring:
jpa:
properties:
hibernate:
criteria:
literal_handling_mode: bind
위와 같은 형태의 property를 추가해주셔야 합니다.
학습 비용의 문제로 사용하지는 않았지만, 다른 좋은 방법으로는 line의 kotlin-jdsl이나, Exposed를 사용할 수도 있습니다
긴 글을 읽어주셔서 감사합니다
'Spring' 카테고리의 다른 글
Java Dto 를 Kotlin Dto로 변경하면 생기는 문제 (0) | 2024.01.07 |
---|---|
MDC 를 활용해 부가적인 정보를 남겨보자 (6) | 2023.12.03 |
테이블을 병합할 때, Auto Increment를 주의하자 (4) | 2023.10.08 |
공유 자원을 관리하는 bulk head에 대해서 알아보자 (8) | 2023.10.02 |
스프링에서 발생한 에러 로그를 슬랙으로 모니터링하는 방법 (1) | 2023.07.08 |