JPQL, Querydsl 비교
테스트 코드
@SpringBootTest
@Transactional
public class QuerydslBasicTest{
@Autowired
EntityManager em;
@BeforeEach
public void before(){
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
em.persist(teamA);
em.persist(teamB);
Member member1 = new Member("member1",10,teamA);
Member member2 = new Member("member2",20,teamA);
Member member3 = new Member("member3",30,teamB);
Member member4 = new Member("member4",40,teamB);
em.persist(member1);
em.persist(member2);
em.persist(member3);
em.persist(member4);
}
@Test
public void startJPQL(){
String jpqlString = "select m from Member m where m.username = :username";
Member findMemberByJpQL = em.createQuery(jpqlString,Member.class)
.setParameter("username","member1")
.getSingleResult();
Assertions.assertThat(findMemberByJpQL.getUsername()).isEqualTo("member1");
}
@Test
public void startQuerydsl(){
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember qMember = new QMember("m");
Member findMemberByQ = queryFactory
.select(qMember)
.from(qMember)
.where(qMember.username.eq("member1")) //파라미터 바인딩 처리함
.fetchOne();
Assertions.assertThat(findMemberByQ.getUsername()).isEqualTo("member1");
}
}
- Querydsl을 사용하려면 JPAQueryFactory를 만드는데 EntityManager를 넘겨줘서 생성해야 한다.
- Querydsl을 생성할 때 “m”과 같은 문자열을 넣어서 생성했는데 이 값은 Q클래스를 생성할 때 어떤 QMember인지 구분하기 위한 값이다. 크게 중요한 값은 아니다.
- queryFactory.select().from() 이렇게 메소드 체이닝으로 생성할 수 있는데 SQL을 사용해 봤다면 이해할 수 있는 문법들이다. eq()는 equal이라는 의미로 같은지 확인하는 메소드이다. 즉 username이 member1인 것을 where로 조건을 거는 것이다.
- JQPL은 문자열이라 사소한 오타가 발생해도 실행시켜서 메소드를 호출했을 때 알 수 있다. 즉 런타임에 알 수 있는 오류이다. 하지만 Querydsl은 내가 실수로 오타를 넣으면 컴파일러가 도와주기 때문에 컴파일 시점에 오류를 잡을 수 있는 장점이 있다. 이렇게 컴파일 타임에 오류를 발견할 수 있고 파라미터 바인딩을 자동으로 해주는 것이 Qtype을 생성한 이유이다. 추가로 자동완성 기능 덕분에 굉장히 편리하다.
- Querydsl은 JPQL과 전혀 다른 것은 아니다. Querydsl은 JPQL의 builder역할이 되어서 결국 JPQL이 되는 것과 같다. 혹시 눈으로 JPQL을 보고 싶다면 application.yml파일에서 jpa.properties.hibernate.use_sql_comments 옵션을 true로 주면 된다.
@SpringBootTest
@Transactional
public class QuerydslBasicTest{
@Autowired
EntityManager em;
JPAQueryFactory queryFactory;
@BeforeEach
public void before(){
queryFactory = new JPAQueryFactory(em);
...
}
...
}
- queryFactory의 경우 위와 같이 필드에 선언해서사용할 수 있다. @BeforeEach에 넣어서 초기화했는데 이렇게 하면 EntityManager에 queryFactory를 사용하여 동시에 접근할 수 있으니 동시성 문제가 생기지 않을까?
- 이 부분에 대해 문제가 없이 설계되어 있고 Spring Framework이 주입해주는 EntityManager가 멀티스레드에 문제가 없게 설계되어 있다고 한다. 즉 이 트랜잭션이 어디에 걸려있는지에 따라 각 트랜잭션에 바운딩되도록 분배해준다고 한다. 그래서 위의 코드처럼 필드로 빼도 상관없다고 한다.
Q-Type 사용법
Qmember qMember = new QMember("member");
QMember qMember = Qmember.member;
위와 같이 2가지 방법으로 생성할 수 있는데 첫 번째는 별칭을 직접 지정하는 방법이고 두 번째는 기본적으로 만들어 놓은 내부 인스턴스를 사용하는 방법이다.
Qmember를 static으로 선언하여 사용하면 코드가 깔끔해진다. Qmember에 커서 올리고 맥기준으로 option+Enter 누르면 static으로 import 할 수 있다.
import static com.seungh1024.entity.QMember.*;
...
Member findMemberByQ = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
이렇게 사용하면 가독성이 더 좋다.
- 실행하면 이런 쿼리가 나가는데 별칭을 지정하지 않아도 member1이라고 별칭이 지정된다.
- 이유는 내가 생성한 Q-Type에서 기본으로 제공하는 member의 별칭이 member1이기 때문이다. 그래서 별칭을 직접 지정해서 사용하면 JPQL도 별칭이 바뀌어서 적용되는 모습을 볼 수 있다.
- 그리고 가끔 별칭을 직접 지정해서 사용할 때가 있는데 같은 테이블을 조인하는 경우 별칭이 같으면 안 되기 때문에 각각 별칭을 주어 사용한다.
검색 조건 쿼리
기본 검색 쿼리
@Test
public void search(){
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1")
.and(member.age.eq(10)))
.fetchOne();
Assertions.assertThat(findMember.getUsername()).isEqualTo("member1");
}
- select와 from을 합쳐서 한 번에 selectFrom이라고 사용할 수 있다.
- 검색 조건을 and(), or()을 사용하여 메소드 체인으로 연결할 수 있다.
검색 조건
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") //like 검색 member.username.contains("member")
- 대부분 보면 어떤 조건인지 쉽게 파악 가능하다. gt, lt 등이 뭐지? 싶은데 greater than, less than을 줄인 것으로 초과, 미만의 의미이다. goe나 loe는 or equal이 추가되어 크거나 같은, 작거나 같은, 즉 이상 이하의 의미가 되는 것이다.
- SQL에 있는건 대부분 되기 때문에 잘 모르겠으면 ‘ . ‘ 을 찍어서 뭐가 있는지 확인하면서 조립해서 사용하면 된다.
and
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1")
,member.age.eq(10))
.fetchOne();
- and의 경우 and()를 사용하지 않고 이렇게 파라미터를 ‘ , ‘로 구분하여 여러 개 나열하면 알아서 and로 바뀌어서 쿼리가 나간다. 이게 좋은 점은 파라미터 중에 null이 들어가면 그걸 무시한다. 이게 동적 쿼리 만들 때 장점이 있다고 한다.
결과 조회
@Test
public void resultFetch(){
List<Member> fetch = queryFactory
.selectFrom(member)
.fetch();
Member fetchOne = queryFactory
.selectFrom(member)
.fetchOne();
Member fetchOneLimit = queryFactory
.selectFrom(member)
.fetchFirst();
}
- fetch() : 리스트 조회, 데이터 없으면 빈 리스트 반환
- fetchOne() : 단 건 조회
- 결과가 없으면 : null
- 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException
- fetchFirst() : limit(1).fetchOne()
정렬
@BeforeEach
public void before(){
...
Member memberNull= new Member(null,100);
Member member5 = new Member("member5",100);
Member member6 = new Member("member6",100);
em.persist(memberNull);
em.persist(member5);
em.persist(member6);
}
@Test
public void sort(){
List<Member> members = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.fetch();
Member member5 = members.get(0);
Member member6 = members.get(1);
Member memberNull = members.get(2);
Assertions.assertThat(member5.getUsername()).isEqualTo("member5");
Assertions.assertThat(member6.getUsername()).isEqualTo("member6");
Assertions.assertThat(memberNull.getUsername()).isNull();
}
- null도 확인하기 위해 새로운 멤버를 추가했다.
- orderBy()를 사용하며 SQL과 똑같이 desc, asc를 사용하여 정렬한다.
- 결과를 확인해 보면 원하던 대로 정렬이 되어 쿼리가 나간 것을 볼 수 있다.
- desc()는 내림차순, asc()는 오름차순 nullsLast() , nullsFirst()는 null 데이터의 순서를 부여한다. 그래서 members.get(2)에서 null 멤버가 있는 것을 확인할 수 있다.
페이징
@Test
public void paging1(){
List<Member> members = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1)
.limit(2)
.fetch();
Assertions.assertThat(members.size()).isEqualTo(2);
}
- offset과 limit으로 페이징을 지원한다. offset은 row의 시작 지점(0부터 시작이다), limit은 페이지 당 조회할 개수를 의미한다. 위의 코드에선 1번 행부터 2개를 조회한다는 것이다. 실제로는 API로 요청이 들어온 페이지 번호와 해당 페이지에 몇 개씩 보여줄 것인지 정해서 넣어주면 된다.
집합 함수
@Test
public void aggregation(){
em.persist(new Member(null,0));
em.flush();
em.clear();
List<Tuple> members = queryFactory
.select(
member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min()
)
.from(member)
.fetch();
Tuple tuple = members.get(0);
Assertions.assertThat(tuple.get(member.count())).isEqualTo(8);
Assertions.assertThat(tuple.get(member.age.sum())).isEqualTo(400);
Assertions.assertThat(tuple.get(member.age.avg())).isEqualTo(50);
Assertions.assertThat(tuple.get(member.age.max())).isEqualTo(100);
Assertions.assertThat(tuple.get(member.age.min())).isEqualTo(00);
}
- 평균 때문에 기존 7명에서 1명 추가해서 조회하였다.
- 기존 SQL에서 사용하던 함수들을 모두 사용할 수 있다.
- 신기한 점은 List<Tuple>로 Tuple이라는 데이터로 받는데 이는 데이터 타입을 여러 타입으로 조회할 때 사용한다. 실제 실무에선 많이 사용하지 않는다고 한다. DTO로 직접 뽑는 방법을 많이 사용한다고 한다.
group by
@Test
public void groupBy() throws Exception{
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name)
.fetch();
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);
Assertions.assertThat(teamA.get(team.name)).isEqualTo("teamA");
Assertions.assertThat(teamA.get(member.age.avg())).isEqualTo(15);
Assertions.assertThat(teamB.get(team.name)).isEqualTo("teamB");
Assertions.assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}
- member와 team을 join 하여 groupBy()를 사용했다. 팀 명으로 묶었고 A팀은 (10+20)/2 =15로 테스트가 잘 되었고 B도 마찬가지이다.
- 그룹화된 결과를 제한하기 위해 having()을 사용할 수 있다. SQL과 동일하게 사용할 수 있다.
조인
기본 조인
조인은 첫 번째 파라미터에 조인 대상을 지정하고, 두 번째 파라미터에 별칭으로 사용할 Q-Type을 지정하면 된다.
@Test
public void join() throws Exception{
List<Member> members = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
Member member = members.get(0);
Assertions.assertThat(member.getTeam().getName()).isEqualTo("teamA");
Assertions.assertThat(members)
.extracting("username")
.containsExactly("member1","member2");
}
- 이때 조인 대상으로 그냥 team이 아닌 member.team을 해야 pk와 fk를 사용하여 조인이 걸린다. 그냥 team만 사용하는 경우는 아래의 on을 사용할 때 다룬다.
- innerjoin(), leftJoin(), rightJoin()도 사용할 수 있다.
세타 조인
연관 관계가 없는 필드로 조인을 걸어서 결과를 받아오는 것이다.
아래 코드는 회원의 이름과 팀 이름이 같은 회원을 조회하는 코드이다.(연관 관계가 없는 필드임)
@Test
public void theta_join(){
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
List<Member> members = queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
Assertions.assertThat(members)
.extracting("username")
.containsExactly("teamA","teamB");
}
모든 회원과 모든 팀을 전부 조인 후 where절에서 필터링하는 것과 같다. 이 과정에서 DB가 최적화를 하여 실제 모든 팀들이 조인이 되지는 않는다고 한다.
기존 문법과 다른 것은 from에서 member, team을 파라미터로 넘겨준 것이다.
예전에 이 세타 조인 방법은 외부 조인이 불가능했다. 하지만 최신 버전에서는 다음에 나올 on을 사용하면 외부 조인이 가능하게 되었다.
on → join on절
on절을 활용한 join으로 JPA 2.1부터 지원한다.
- 조인 대상을 필터링
- 연관 관계없는 엔티티를 외부 조인
보통 2번이 필요한 경우 많이 사용한다.
아래는 회원과 팀을 조회하면서 팀 이름이 teamA인 팀만 조인하고 회원은 모두 조회하는 것이다.(필터링)
@Test
public void join_on_filtering(){
List<Tuple> members = queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
for(Tuple tuple : members){
System.out.println("tuple = " + tuple);
}
}
left outer join을 사용했기 때문에 member는 전부 나오고 team은 A팀만 조회된 것을 볼 수 있다.
on을 사용할 때 주의할 점이 있는데 위처럼 outer join이 아닌 inner join을 사용하면 where절에서 필터링을 하는 것과 동일한 결과가 나온다. 따라서 inner join이면 where절로, outer join을 사용하면 on절을 사용할 것을 권고한다고 한다.
아래 코드는 연관 관계가 없는 엔티티를 외부 조인하는 경우이다.
@Test
public void join_on_relation(){
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
em.persist(new Member("teamC"));
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);
}
}
기존 join처럼 member.team을 하지 않는다. 기존 join은 join(member.team, team)과 같이 하는데 이렇게 하면 join절에 member의 id값이 들어가서 join이 발생하여 id로 매칭이 된다(fk가 사용되는 것이다). 하지만 위와 같은 outer join들은 그냥 team을 넣어서 id값이 아닌, team.name과 member.username이 같은지에 대한 조건으로만 join이 발생한다.
결과를 보니 전체 멤버 중 팀명과 자기 이름이 같은 member들만 팀 정보가 조회되었다.
fetch join
fetch join은 SQL에서는 없는 기능이다. SQL조인을 활용해서 연관된 Entity를 SQL 한 번에 조회하는 기능이다. 주로 최적화를 위해 사용한다.
@PersistenceUnit
EntityManagerFactory emf;
@Test
public void fetchJoin(){
em.flush();
em.clear();
Member member1 = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.where(member.username.eq("member1"))
.fetchOne();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(member1.getTeam()); // 초기화 됐는지 안됐는지 알려준다
Assertions.assertThat(loaded).as("페치 조인 미적용").isTrue();
}
기존과 크게 다른 점이 없다. 단순히 join절 뒤에 fetchJoin()을 추가로 붙여주면 된다.
EntityManagerFactory에서 제공하는 로딩이 됐는지 확인할 수 있는 기능이 있어서 사용했다. member1에서 team이 위의 쿼리로 로딩됐다면 true일 것이다. 그럼 해당 테스트가 통과된다.
결과를 보니 fetch가 사용된 것을 볼 수 있다. 해당 쿼리는 Entity의 연관 관계 설정에서 fetch옵션의 FetchType.EAGER처럼 한 번에 가져온다.
서브 쿼리
[com.querydsl.jpa.JPAExpressions]를 사용한다.
@Test
public void subQuery(){
QMember subMember = new QMember("subMember");
List<Member> members = queryFactory
.selectFrom(member)
.where(member.age.eq(
JPAExpressions
.select(subMember.age.max())
.from(subMember)
))
.fetch();
Member member = members.get(0);
Assertions.assertThat(member.getAge()).isEqualTo(100);
}
JPAExpressions를 사용하고 이후의 구문들은 queryFactory와 같다. 여기서 주의점은 같은 별칭의 Q-Type클래스를 사용하면 안 되니 새로운 별칭으로 생성해서 해당 객체를 사용해야 한다는 것이다. 아까 100살의 사용자 3명을 넣었으니 members에는 3명이 있을 것이다.
쿼리를 확인해 보면 서브쿼리에서 from Member subMember로 member1과 다르다. 만약 같은 Q-Type을 사용했다면 둘 다 member1으로 충돌이 발생했을 것이다.
@Test
public void subQueryGoe(){
QMember subMember = new QMember("subMember");
List<Member> members = queryFactory
.selectFrom(member)
.where(member.age.goe(
JPAExpressions
.select(subMember.age.avg())
.from(subMember)
))
.fetch();
Assertions.assertThat(members).extracting("age")
.containsExactly(100,100,100);
}
위처럼 검색 조건으로 goe 등도 사용할 수 있다.
@Test
public void subQueryIn(){
QMember subMember = new QMember("subMember");
List<Member> members = queryFactory
.selectFrom(member)
.where(member.age.in(
JPAExpressions
.select(subMember.age)
.from(subMember)
.where(subMember.age.gt(50))
))
.fetch();
Assertions.assertThat(members).extracting("age")
.containsExactly(100,100,100);
}
동일한 방식으로 in도 사용이 가능하다.
@Test
public void subQuerySelect(){
QMember subMember = new QMember("subMember");
List<Tuple> result = queryFactory
.select(member.username,
JPAExpressions
.select(subMember.age.avg())
.from(subMember)
)
.from(member)
.fetch();
for(Tuple tuple : result){
System.out.println("tuple = "+tuple);
}
}
select절 안에 서브 쿼리도 마찬가지로 사용할 수 있다. 정말 SQL에서 하는 것과 같이 가능하다.
from 절의 서브쿼리 한계
JPA JPQL 서브쿼리는 SQL과 다르게 from절에서는 서브쿼리(인라인 뷰)는 지원하지 않는다. 즉 Querydsl도 지원하지 않는다. select절의 경우 하이버네이트 구현체를 사용하면 지원하기 때문에 사용가능하다.
해결 방안으로는
- 서브쿼리를 Join으로 변경한다.(가능한 상황과 불가능한 상황이 있다)
- 애플리케이션에서 쿼리를 2번 분리해서 실행한다.
- nativeSQL을 사용한다.
찾아보니 hibernate 6.1부터 지원한다고 한다. https://in.relation.to/2022/06/14/orm-61-final/ 하지만 아직은 공식적으로 지원해주지는 않아서 실제 사용은 못한다고 한다..
실제 요구사항에 따라 데이터를 가져오려면(화면에 맞춰서 데이터를 뽑아오는 경우 등) 서브쿼리가 사용되고 또 그 안에 서브쿼리가 사용되며 복잡하게 쿼리가 생성될 수 있다. 이렇게 쿼리로 어떻게든 다 풀려고 하면 데이터 재사용도 할 수 없고 좋지 않다. DB는 데이터를 뽑아오는 용도로 사용하고 애플리케이션에서 조금 더 정제하는 것이 좋다. 물론 where과 group by를 사용하여 잘 자르고 그룹화 하는 것은 중요하다. 즉 데이터를 최소화해서 가져오는 것은 중요하지만 정말 세부적으로 나누는 작업은 DB가 아닌 어플리케이션 수준에서 하는 게 좋다.
case 문
select, where절에서 사용 가능하다. 기본적으로 JPQL에서 지원하는 건 다 된다.
when().then()을 사용하는 방법과 caseBuilder()를 사용하는 두 가지 방법이 있다.
@Test
public void basicCase(){
List<String> result = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
.from(member)
.fetch();
for(String s : result){
System.out.println("s = "+s);
}
}
member의 나이가 10살이면 “열 살”, 20살이면 “스무 살”, 나머지는 “기타”로 뽑아왔다.
@Test
public void complexCase(){
List<String> result = 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();
for(String s : result){
System.out.println("s = "+s);
}
}
위와 같이 조건이 복잡하면 CasseBuilder()를 사용하면 된다.
하지만 가급적이면 이런 세세한 필터링은 DB에서 하지 않는 것이 좋다고 한다. 쿼리를 작성하다 보면 이런 경우가 더 효율이 좋을 때 있는데 그런 경우가 아니라면 어플리케이션 수준에서 세세하게 필터링하는 것이 좋다고 한다.
상수, 문자 더하기
@Test
public void constant(){
List<Tuple> result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetch();
for(Tuple tuple : result){
System.out.println("tuple = "+tuple);
}
}
Expressions.constant()로 상수를 쿼리 결과에 무조건 넣어서 가져올 수 있다. 이때 특이한 점은 위의 결과 사진처럼 JPQL 쿼리문에 포함되는 것이 아닌 결괏값에만 포함된다는 것이다.
@Test
public void concat(){
List<String> result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.fetch();
for(String s : result){
System.out.println("s = "+s);
}
}
concat()을 이용하여 문자열을 더해줄 수 있다. 이때 member.age는 숫자이기 때문에 이때 지원하는 stringValue()를 이용하면 문자열로 변환되어 member.username_member.age의 형태가 나온다.
쿼리가 나간 것을 보면 stringValue()로 cast(m1_0.age as char)가 된 것을 볼 수 있다. stringValue()의 경우 enum타입에 사용하기 좋은데 enum타입은 문자로 안 나오기 때문에 stringValue()를 사용하여 볼 수 있다.
'Java > Querydsl' 카테고리의 다른 글
[Querydsl] Spring Data JPA + Querydsl , Pageable(페이징) (0) | 2023.07.09 |
---|---|
[Querydsl] 순수 JPA Repository + Querydsl (0) | 2023.07.09 |
[Querydsl] 프로젝션, 동적 쿼리, 배치 쿼리(벌크 연산) (0) | 2023.07.08 |
[Querydsl] Querdydsl 설정하기 (0) | 2023.07.03 |