김영한 님의 실전! Querydsl 강의를 보고 작성한 내용입니다.
@Test
public void jpql() {
String jpql = "select m from Member m where m.username = :username";
Member findMember = em.createQuery(jpql, Member.class)
.setParameter("username", "member1")
.getSingleResult();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
@Test
void queryDSL() {
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = new QMember("m");
Member findMember = queryFactory
.select(m)
.from(m)
.where(m.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
JpaQueryFactory
의 생성자에 EntityManager
를 넘겨줍니다. 그러면 QueryFactory 가 Entitymanager 를 가지고 데이터를 찾거나 하는 등의 작업을 수행합니다.
쿼리는 일반적인 sql 처럼 select, from, where 를 사용하여 작성하면 되고, QueryDSL 은 자동으로 preparedStatement 와 파라미터 바인딩 방식을 사용해서 기존에 setParameter 로 수행했던 과정을 자동으로 수행해줍니다.
쉽게 생각하면 Querydsl은 JPQL 빌더이며, 가장 큰 차이점으로는 JPQL 은 문자로 작성해야 하지만 QueryDSL 은 코드로 작성하기 때문에 컴파일 시점에 오류를 잡아낼 수 있습니다.
public class QuerydslBasicTest {
@PersistenceContext
EntityManager em;
JPAQueryFactory queryFactory;
@BeforeEach
public void before() {
queryFactory = new JPAQueryFactory(em);
...
}
}
JPAQueryFactory 를 필드로 빼고, 이를 생성할 때 entityManager 를 넘겨주는 방식으로 사용해도 됩니다.
스프링 프레임워크는 여러 쓰레드에서 동시에 같은 EntityManager 에 접근해도, 트랜잭션 마다 별도의 영속성 컨텍스트를 제공하기 때문에 JPAQueryFacytory 는 멀티 스레드 환경에서 동시성 문제 없이 동작합니다.
Q 클래스 인스턴스를 사용하는 방법에는 2가지가 존재합니다.
QMember m = new QMember("m"); // 별칭 직접 사용
QMember qMember = QMember.member; // Q 클래스 내부에 자동으로 생성된 인스턴스를 사용
import static study.querydsl.entity.QMember.*;
@Test
void queryDSL() {
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
위의 코드는 인스턴스를 사용 + static import 를 사용하는 방식입니다. select 내부에 QMember.member
를 작성한 후 QMember 를 static import 를 하면 위처럼 사용할 수 있습니다.
QueryDSL 은 JPQL 의 빌더 역할을 하며, QueryDSL 로 작성된 것은 JPQL 로 변환되어 실행됩니다. 실행되는 JPQL 을 확인하고 싶다면 아래 설정을 추가하면 됩니다.
spring:
jpa:
properties:
hibernate:
use_sql_comments: true
설정을 추가하고 실행되는 JPQL 을 살펴보면 아래와 같습니다.
select member1
from Member member1
where member1.username = ?1
현재 별칭이 member1 이라고 지정된 것을 볼 수 있습니다. 이는 QMember 를 들어가보면 왜 member1 이라고 지정되는지 알 수 있습니다.
QMember 에서 인스턴스 (member )를 만들 때 member1 이라는 이름으로 생성하기 때문에 JPQL 에서 member1 이라고 표시됩니다.
@Test
void queryDSL() {
QMember m = new QMember("m");
Member findMember = queryFactory
.select(m)
.from(m)
.where(m.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
select m
from Member m
where m.username = ?1
만약 별칭을 사용하도록 테스트 코드를 수정하고 실행하면 member1 으로 나오던 것이 별칭으로 지정한 m 으로 변경됩니다.
하지만 같은 테이블을 조인해야 하는 경우가 아니면 기본 인스턴스를 사용하는 것을 권장하신다고 합니다.
QueryDSL 은 JPQL 이 지원하는 모든 검색 조건을 제공합니다. 아래 예시 몇 가지가 있습니다.
member.username.isNotNull() // 이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.username.like("member%") // like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") // like ‘member%’ 검색
이때 여러 조건을 한 번에 사용하기 위해 and() 와 파라미터 처리를 제공합니다.
@Test
void search() {
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1")
.and(member.age.eq(10)))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
assertThat(findMember.getAge()).isEqualTo(10);
}
where 안에 여러 개의 조건을 and 로 묶을 수 있습니다. 참고로 and 말고 or 도 가능하며, select 와 from 을 selectFrom()
으로 줄여서 사용할 수 있습니다.
@Test
void search() {
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"), (member.age.eq(10)))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
assertThat(findMember.getAge()).isEqualTo(10);
}
and 로 묶지 않고 쉼표를 통해 끊어서 사용할 수 있습니다. 이 경우에는 AND 조건으로 묶이게 되며 null 값은 무시됩니다. 이 기능과 메서드 추출을 활용해서 동적 쿼리를 깔끔하게 만들 수 있습니다.
fetch : 리스트를 조회, 없으면 빈 리스트가 반환
fetchOne : 단건 조회, 결과가 없으면 null, 둘 이상이면 NonUniqueResultException
fetchFirst :
limit(1).fetchOne()
을 실행fetchResults : 페이징 정보 포함한 결과 반환, count 쿼리가 추가로 실행됨
fetchCount : count 를 조회
//페이징에서 사용
QueryResults<Member> results = queryFactory
.selectFrom(member)
.fetchResults();
long limit = results.getLimit();
long offset = results.getOffset();
long total = results.getTotal();
List<Member> content = results.getResults();
fetchResult 의 결과에서 getTotal()
로 count 쿼리의 결과를 얻을 수 있고, getResult()
로 데이터를 가져올 수 있습니다. 이때 쿼리는 count 쿼리와 데이터를 가져오는 쿼리 총 2번이 실행됩니다.
fetchCount 와 fetchResult 는 개발자가 작성한 select 쿼리를 기반으로 count 용 쿼리를 내부에서 만들어서 실행합니다.
그런데 이 기능은 단순히 select 구문을 count 처리하는 용도로 바꾸는 정도입니다. 따라서 단순한 쿼리에서는 잘 동작하지만, 복잡한 쿼리에서는 제대로 동작하지 않는다고 합니다.
또 fetchResults 와 fetchCount 는 Querydsl 5.0 부터 Deprecated 되었습니다. 그 대안으로 fetch 를 사용하고, count 쿼리는 필요한 경우 직접 작성하며, fetchOne 을 실행하면 됩니다.
@Test
void sort() {
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(),
member.username.asc().nullsLast())
.fetch();
}
위의 예시는 나이 내림차순, 이름 오름차순 + null 은 가장 마지막에 위치를 기준으로 정렬한 예시입니다.
정렬은 orderBy()
에 지정할 수 있으며, 쉼표를 통해 여러 개를 지정할 수 있습니다. 또 null 데이터의 순서를 지정할 수 있는데 nullsLast()
, nullsFirst()
가 있습니다.
@Test
void paging1() {
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(0) // 0부터 시작
.limit(2)
.fetch();
}
offset 과 limit 를 사용해서 페이징 쿼리를 작성할 수 있으며, offset 은 0 부터 시작합니다.
페이징 쿼리를 작성할 때, 데이터를 조회하는 쿼리는 여러 테이블을 조인해야 하지만, count 쿼리는 조인이 필요 없는 경우도 있습니다. 만약 fetchResult()
를 사용한다면 자동화된 count 쿼리가 원본 쿼리처럼 모두 조인을 해버리기 때문에 성능이 좋지 않을 수도 있습니다. 따라서 count 쿼리에 조인이 필요없는 성능 최적화가 필요하다면, count 전용 쿼리를 별도로 작성해야 합니다.
@Test
void group() {
List<Tuple> result = queryFactory
.select(team.name,
member.age.avg(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min()
)
.from(member)
.join(member.team, team)
.groupBy(team.name)
.fetch();
Tuple teamA = result.get(0);
assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
}
기본적인 집계 함수들을 다 사용할 수 있으며, Tuple 이 반환됩니다. tuple 안에 담긴 데이터를 가져올 때는 get()
내부에 조회한 것을 넣어주면 됩니다. 추가로 having()
을 통해 그룹된 결과를 제한할 수 있습니다.
join(조인대상, 별칭으로 사용할 Q타입)
@Test
void join() {
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
}
JPQL 에서 join m.team t
로 조인을 사용했는데 QueryDSL 도 조인대상을 지정하고 별칭처럼 Q 타입을 지정합니다. join 외에도 innerJoin, leftJoin 과 같은 다른 조인도 사용할 수 있습니다.
select
member1
from
Member member1
inner join
member1.team as team
where
team.name = ?1
테스트 코드 실행 결과 위와 같은 JPQL 이 생성되고 실행됩니다.
@Test
void theta_join() {
List<Member> result = queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
}
연관관계가 없어도 세타 조인을 통해 조인을 실행할 수 있습니다. 이때는 from 절에 여러 개의 엔티티를 사용하면 됩니다. 하지만 세타 조인은 외부 조인 불가능한데 뒤에 나오는 조인 on 을 사용하면 외부 조인이 가능합니다.
ON 절을 활용하면 아래 두 가지를 수행할 수 있습니다
- 조인 대상 필터링
- 연관관계가 없는 엔티티 외부 조인
@Test
void join_on_filtering() {
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team)
.on(team.name.eq("teamA"))
.fetch();
}
tuple = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA)]
tuple = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA)]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]
테스트 코드를 수행한 결과는 위와 같습니다. ON 절을 통해 team.name 에 조건을 걸었기 때문에 teamA 만 출력되었고, left join 이기 때문에 teamB 에 해당하는 Member3, Member4 는 team 이 null 로 출력되었습니다.
on 절을 활용해 조인 대상을 필터링 할 때, 외부조인이 아니라 내부조인을 사용하면 where 절에서 필터링 하는 것과 기능이 동일합니다. 따라서 내부조인이면 where 절로 해결하고, 정말 외부조인이 필요한 경우에만 이 기능을 사용하는 것을 권장한다고 하십니다.
@Test
void join_on_no_relation() {
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(team)
.on(member.username.eq(team.name))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
하이버네이트 5.1부터 on 을 사용해서 서로 관계가 없는 필드로 내부 조인, 외부조인을 하는 기능이 추가되었습니다.
select
m1_0.member_id, m1_0.age, m1_0.team_id, m1_0.username, t1_0.team_id, t1_0.name
from
member m1_0
left join
team t1_0
on m1_0.username=t1_0.name
기존에는 leftJoin(member.team, team)
을 사용했는데 이렇게 하면 조인의 on 절에 id 값이 들어가게 됩니다.
하지만 이번에는 leftJoin(team)
하나 밖에 없는 것을 볼 수 있습니다. 이렇게 하면 id 매칭이 없어지기 때문에 on 절에 적힌 것처럼 username 와 name 만으로 매칭됩니다.
@Test
void fetchJoin() {
Member findMember = queryFactory
.selectFrom(member)
.join(member.team, team)
.fetchJoin()
.where(member.username.eq("member1"))
.fetchOne();
}
기존과 동일하게 join 을 사용하고, 뒤에 fetchJoin()
을 사용하면 연관된 엔티티까지 한 번에 조회할 수 있습니다.
@Test
void subQuery_where() {
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(memberSub.age.max())
.from(memberSub)
))
.fetch();
}
QueryDSL 에서 서브쿼리를 사용하려면 JPAExpressions 를 사용합니다. 이때 서브쿼리와 메인쿼리의 alias 가 겹치면 안되기 때문에 Q 객체를 새로 생성해서 사용해야 합니다. 일반적인 SQL 에서 별칭을 다르게 하는 것과 동일한 원리입니다.
eq
외에도 앞에서 보았던 in
, goe
와 같은 것들도 사용할 수 있습니다.
@Test
void subQuery_select() {
QMember memberSub = new QMember("memberSub");
List<Tuple> result = queryFactory
.select(member.username,
JPAExpressions
.select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
}
하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원합니다. 따라서 Querydsl도 하이버네이트 구현체를 사용하면 select 절의 서브쿼리를 지원합니다.
JPA JPQL 서브쿼리의 한계점으로 from 절의 서브쿼리는 지원하지 않는데 Hibernate 6 부터 from 절에서의 서브쿼리를 지원합니다. 하지만 QueryDSL 에서는 아직 지원하지 않는 것 같다고 합니다.
case 문은 select, where, order by 에서 사용할 수 있습니다.
@Test
void queryDSLCase() {
// 단순 조건
List<String> result1 = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
.from(member)
.fetch();
// 복잡한 조건
List<String> result1 = queryFactory
.select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0 ~ 20살")
.when(member.age.between(21, 30)).then("21 ~ 30살")
.otherwise("기타"))
.from(member)
.fetch();
}
복잡한 조건을 사용할 때는 CaseBuilder
를 사용해서 case 조건을 줄 수 있습니다. then 에서 문자가 아닌 숫자를 반환한다면 CaseBuilder 의 반환형을 따로 뽑아 정렬 기준으로 사용할 수 있습니다.
Tuple result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetchFirst();
상수가 필요한 경우 Expressions.constant()
를 사용합니다.
String result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
문자를 더할 때는 concat()
을 사용합니다. 또 문자가 아닌 다른 타입을 문자로 변환할 때는 stringValue()
를 사용하는데 특히 ENUM 을 사용할 때 주로 사용됩니다.