전주 팀 프로젝트 진행했을 당시 Post 를 전체 조회 시 Post 별 Like 의 수도 함께 조회할 수 있도록 설정하는 부분에서 JPQL 을 시도해 봤었는데, 결국에는 실패하고, 코드 상으로 해당 기능을 구현했던 것이 생각난다.
해당 기능을 구현하지 못한 데에는 여러 가지 이유가 있었는데 우선 그 때 작성한 Query 를 아래에 공유드립니다.
@Query(
nativeQuery = true,
value = "SELECT p.id, p.title, p.content, p.user, p.createdAt, COUNT(l.id) AS likeCount FROM Post p LEFT JOIN PostLike l ON p.id = l.post.id GROUP BY p.id, p.title, p.content, p.user, p.createdAt ORDER BY p.createdAt DESC"
)
SupaBase 로 직접 Table 을 만들어 Test 를 해봤을 때도 문제없이 의도한대로 잘 조회가 되었는데, 그래서 사실 아직까지 정확한 원인을 모르겠다.
그래서 예측하건대, 해당 코드에는 없지만 응답 타입을 결정할 때 Entity 가 아닌, ResponseDto 를 넣어 추가적으로 매핑하는 과정이 필요했고, 그러려면 추가적인 코드 작성이 필요했던 것으로 보인다.
그러나 그 당시에는 오류를 알 수 없던 것이 우선 String 으로 작성하기 때문에 컴파일 시 오류 발생없이 잘 시행된다는 점, 그리고 Test 시 명확하게 오류 발생 원인을 알 수 없다는 문제가 있었습니다.
그리고 금일 QueryDSL 관련 공부를 진행하면서 왜 실무에서 JPQL 보다 QueryDSL 을 더욱 선호하는 몸소 느낄 수 있었습니다.
여러 글들을 찾아봤을 때 많은 사람들이 강조하는 부분이
'문자열 형태로 작성해야 하기 때문에, 동적인 쿼리 작성이 힘들다' 인데, 해당 부분도 함께 다뤄보려 합니다.
QueryDSL 이 뭔데?
SQL, JPQL 등을 코드로 작성할 수 있도록 해주는 Framework 로, Query 를 type-safe(컴파일시 에러 체크 가능)하게 Java 코드로 작성할 수 있습니다. QueryDSL은 동적 쿼리를 아주 편리하게 작성할 수 있어, 복잡한 동적 쿼리를 사용해야 할 때 QueryDSL을 사용하고 단순한 경우에는 Spring Data JPA를 사용합니다.
QueryDSL 에서는 JPA에서 관리되는 Entity 를 활용하기 보다 static한 QClass를 사용합니다. QClass 는 Entity를 기반으로 자동으로 생성되는 Class입니다.
(QueryDSL 은 JPA가 제공하는 JPQL 을 코드로 작성할 수 있도록 도와주는 빌더 역할을 하기 때문에 JPQL의 문법에 대한 이해가 동반되어야 합니다.)
SQL, JPQL 의 문제점
QueryDSL 을 이해하기 전 SQL 과 JPQL 의 문제점을 확인해보려 합니다.
# SQL
String sql = "select id, title, content, writer from post";
# JPQL
String jpql = "select p from Post p";
SQL, JPQL 은 문자열로 Type-check 가 불가능합니다. 그래서 만약 Query 에 오류가 있을 시 컴파일 시점에 오류를 잡을 수 없어 애플리케이션을 실행한 후에야 오류를 발견할 수 있습니다.
Qclass
QueryDSL은 컴파일 단계에서 Entitiy를 기반으로 QClass를 생성하는데,
JPAAnnotationProcessor 가 컴파일 시점에 작동해서 @Entity 등등의 어노테이션을 찾아 해당 파일들을 분석해서 QClass를 만들어 줍니다.
그리고 이 Entity Class 와 매핑되는QClass 를 기반으로 쿼리를 실행합니다.
아래와 같이 사용할 수 있습니다.
val post = QPost.post // 기본 instance
val post = QPost("p") // 별칭 지정
queryFactory.select(post).from(post).fetch()
queryFactory.selectFrom(post).fetch()
// Title 만 조회 - List<String> 형태로 조회됨
queryFactory.select(post.title).from(post).fetch()
// Title, numLike 만 조회 - List<Tuple> 형태로 조회됨
queryFactory.select(post.title, post.numLike).from(post).fetch()
QueryDSL 장점
문자가 아닌 코드로 작성하여 컴파일 시점에 오류를 잡을 수 있습니다.
또한 select, from, where 등 쿼리 작성에 필요한 키워드들을 메서드 형식으로 제공하여 단순하고 쉽게 작성할 수 있습니다.
QueryDSL은 주로 JPA와 함께 많이 사용하며 가장 장점이라 할 수 있는 부분은 동적 쿼리를 아주 편리하게 작성할 수 있습니다.
아래는 동적 쿼리를 작성한 예시입니다.
@Service
class UserService {
@PersistenceContext
private lateinit var em: EntityManager
private val queryFactory by lazy { JPAQueryFactory(em) }
fun findUsers(username: String?, email: String?): List<User> {
val user = QUser.user
val predicate = BooleanBuilder()
if (username != null) {
predicate.and(user.username.eq(username))
}
if (email != null) {
predicate.and(user.email.eq(email))
}
return queryFactory.selectFrom(user)
.where(predicate)
.fetch()
}
}
UserService Class 에서 JPAQueryFactory 를 사용하여 동적 쿼리를 생성합니다.
BooleanBuilder 를 사용하여 조건을 동적으로 추가하고, username 과 email 이 null 이 아닌 경우에만 조건을 추가하여 동적 쿼리를 다음과 같이 쉽게 작성할 수 있습니다.
코드 풀이를 통해 더 자세히 이해해보자면,
findUsers Method 는 사용자 이름과 이메일을 기준으로 User 엔티티를 검색하는 기능을 제공합니다.
QUser.user 는 QueryDSL 이 생성한 Qclass 인스턴스입니다. 이는 이전 설명한 것과 같이 User 엔티티의 속성에 접근할 수 있도록 합니다.
*BooleanBuilder 는 여러 조건을 동적으로 결합하는데 사용되며, username 과 email 이 null 이 아닌 경우에만 해당 조건을 추가합니다.
user.username.eq(username) 는 username 필드가 주어진 값과 일치하는지 확인하는 조건을 생성합니다.
predicate.and 메서드를 사용하여 각 조건을 BooleanBuilder에 추가합니다.
BooleanBuilder : QueryDSL 에서 여러 조건을 결합하여 하나의 BooleanExpression(불리언 표현식)을 만드는 데 사용되는 클래스입니다.
동적 쿼리를 작성할 때 매우 유용하며, BooleanBuilder 를 사용하면 조건을 단계적으로 추가하거나 제거할 수 있어, 복잡한 조건문을 유연하게 처리할 수 있다는 장점이 있습니다!
벌크 수정, 벌크 삭제
JPA 에서는 Dirty Checking 을 통해 Entity의 변경을 감지하고, update , delete 쿼리를 수행합니다.
이 부분은 매우 직관적이고 편리합니다. 하지만 대량의 데이터를 update 혹은 delete 하는 경우 모든 Entity에 대해서 변경사항을 감지해야 하기 때문에 이 과정 자체는 시간 소요가 꽤 있습니다. 이때, QueryDSL 을 활용하면 성능을 월등하게 개선할 수 있습니다.
하지만 주의점이 있습니다.
꼭 @Transactional 내부에서 실행되어야 합니다.
영속성 컨텍스트에 있는 Entity 와 무관하게 실행되기 때문에, 쿼리 실행 후 JPA의 영속성 컨텍스트와 DB의 상태가 서로 다르게 됩니다. 쿼리 실행 후 em.clear() 를 통해 영속성 컨텍스트를 초기화해주어 안전하게 사용할 수 있습니다,