하단에 첨부한, 이전에 작성한 글을 확인해 보면 제가 겪었던 문제를 확인해 볼 수 있습니다.
2024.06.07 - [분류 전체보기] - 2024-06-07 [JPQL 보다 QueryDSL?]
2024-06-07 [JPQL 보다 QueryDSL?]
전주 팀 프로젝트 진행했을 당시 Post 를 전체 조회 시 Post 별 Like 의 수도 함께 조회할 수 있도록 설정하는 부분에서 JPQL 을 시도해 봤었는데, 결국에는 실패하고, 코드 상으로 해당 기능을 구현
hongjinkwon.tistory.com
겪었던 문제를 요약하여 설명드리면, Repository 내 JPQL Query를 작성했을 때, 반환 값을 Dto로 설정하면서 해당 Method가 사용된 API 에 지속적으로 오류가 발생했습니다.
첫 번째로 JPQL Query 문은 문자열로 작성이 되다 보니 컴파일 시점에 문제를 파악할 수 없었고, 두 번째로 정확하게 작성한 Query의 어떤 문제가 있어 오류가 발생한지 몰라 해결했던 방법은 "QueryDSL 활용 + 반환값을 Entity로 변경" 하는 것이었습니다.
이전 작성 글을 작성했던 당시 QueryDSL이나, JPQL을 활용 시 Dto로 반환값을 설정하기 위한 방법은 알지 못했고, 드디어 정확한 해결 방법을 찾게 되어 해당 부분을 포스팅하게 되었습니다.
Get API에서 Dto를 반환 값으로 설정하는 것이 이점을 가져다주는 경우가 많아, Projection 관련 해당 포스팅에서 함께 설명드리려 합니다.
** 이번 글은 JPA, JPQL, QueryDSL 중 QueryDSL에서 Projection을 사용하는 방법을 포스팅한 부분 참고 부탁드립니다.
Projection이란?
- Projection은 데이터베이스에서 테이블을 출력할 때, Column 을 제한적으로 출력하는 것을 의미합니다.
SELECT * FROM member // Non-Projection
SELECT id, email, nickname FROM member // Projection
간단한 예시를 살펴보면, Projection이 가지는 의미를 쉽게 이해할 수 있습니다.
Projection이 왜 필요할까?
- Entity로 조회할 수 있는데, Projection이 갖는 이점은 뭘까요?
불필요한 컬럼이 제외된 꼭 필요한 데이터만을 조회할 수 있고,
조회된 모든 Entity는 강제로 Persistence Context(영속성 컨텍스트)의 관리를 받게 됩니다.
예시로 규모가 큰 하나의 서비스 내 Entity의 수와 안의 데이터 규모가 매우 크다고 가정해 보면, 불필요한 데이터를 가져오는 것을 넘어서 메모리에 악영향을 줄 수 있습니다.
예를 들어, 한 번에 10만 개의 Entity를 조회해 해당 Entity들 모두 영속성 컨텍스트의 관리를 받는다면 해당 성능 부하는 엄청나게 커질 수 있습니다.
그래서 우리는 Projection을 활용하여 다음과 같은 문제점들을 해결할 수 있습니다.
쓰기(Command) 작업을 수행할 땐 Entity로 조회하여 영속성 컨텍스트의 이점(1차 캐시, 쓰기 지연, 변경 감지 등 JPA의 지원)을 충분히 활용할 수 있고,
읽기(Query) 작업을 수행할 땐 Projection을 통해 필요한 정보만을 조회하거나, 대량의 Row를 한 번에 조회할 때 활용하여 성능을 개선할 수 있습니다.
1. Projections.bean
// Dto Class
data class SimpleMember(
var id: Long? = null,
var email: String? = null,
var nickname: String? = null
)
//QueryDsl
override fun findByEmail(email: String): SimpleMember? {
return jpaQueryFactory
.select(
Projections.bean(
SimpleMember::class.java,
member.id,
member.email,
member.nickname
)
).from(member)
.where(member.email.eq(email))
.fetchOne()
}
사용방법은 위의 코드와 같습니다.
Projections.bean 을 이용한 방법은 Setter를 기반으로 동작하기 때문에 해당 방식에는 몇 가지 문제점이 존재하는데,
반드시 Dto에 존재하는 필드를 var로 선언해 Setter를 열어야하며, SimpleMember에 반드시 기본 생성자가 존재해야 합니다.
Dto가 불변을 지키지 못하는 것도 문제 (Dto는 저장된 데이터를 이동하는 읽기 전용 목적으로 쓰이는 객체이기 때문에 val로 변수를 선언하여 불변을 지켜야 합니다)고, Nullable한 타입이 강제되는 것도 문제가 되어 일반적으로 잘 사용되지 않는, 권장되지 않는 Projection 방법입니다.
2. Projections.constructor
data class SimpleMember(
val id: Long,
val email: String,
val nickname: String
)
override fun findByEmail(email: String): SimpleMember? {
return jpaQueryFactory
.select(
Projections.constructor(
SimpleMember::class.java,
member.id,
member.email,
member.nickname
)
).from(member)
.where(member.email.eq(email))
.fetchOne()
}
QueryDSL 코드 자체는 크게 달라지지 않고, bean → constructor로만 달라지는 부분을 확인할 수 있습니다.
하지만 Dto에는 큰 변화가 있었는데, 불변을 지켰을 뿐더러 Nullable 타입도 사라집니다.
3. @QueryProjection
data class SimpleMember @QueryProjection constructor(
val id: Long,
val email: String,
val nickname: String
)
override fun findByEmail(email: String): SimpleMember? {
return jpaQueryFactory
.select(
QSimpleMember(
member.id,
member.email,
member.nickname
)
).from(member)
.where(member.email.eq(email))
.fetchOne()
}
생성된 QSimpleMember를 이용해 생성자 방식으로 Projection이 가능합니다.
"Projections.constructor"와 동일하게 생성자 방식을 사용하기 때문에 Dto의 불변을 지키면서 Nullable 타입도 사용하지 않을 수 있게 되며, 동시에 Projections.constructor에서 존재했던 순서에 대한 문제도 사라지게 됩니다.
그러나 Dto의 정의를 다시 한번 생각해 봐야 하는데, Dto는 Repository를 벗어나 다른 계층도 오가는 객체인데 Dto 자체가 QueryDSL을 의존하는 것은 사실 옳지 않습니다. Repository의 구체적인 것에 대한 변경이 다른 계층으로 전파될 가능성을 품기 때문입니다.
결론적으로, 반드시 생성자 방식을 사용하되 Projections.constructor, @QueryProjection 중 장, 단점을 비교해 Trade-off하여 적용해보는 것을 추천합니다.