[Spring Boot/JPA] QueryDSL 문법(2) : Join 표현식
이전 포스팅에서 기본적인 QueryDSL의 검색 쿼리 문법에 대해서 살펴보았었다. 이번 포스팅에서는 연관된 다른 릴레이션과의 연산을 수행하는 Join과 Subquery 방법에 대해서 알아보도록 하겠다.
아래 포스팅은 QueryDSL의 기본 검색에 대한 문법을 정리해 둔 것이니 참고하면 좋을 것 같다.
https://sjh9708.tistory.com/175
사용할 데이터
1. Author : Book = 1 : N
Author(저자)는 여러 개의 Book(책)을 가진다.
2. Author : Organization = N : 1
Author(저자)는 한곳의 Organization(조직)에 속한다.
3. Book : Review = 1 : N
Book(책)은 여러 개의 Review(리뷰)를 가진다.
@Entity
@Table(name = "Organization")
@Getter
@Setter
public class Organization {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orgName;
@OneToMany(mappedBy = "organization", cascade = CascadeType.ALL)
private List<Author> authors = new ArrayList<>();
}
@Entity
@Table(name = "Author")
@Getter
@Setter
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL)
private List<Book> book = new ArrayList<>();
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name = "organization_id")
private Organization organization;
}
@Entity
@Table(name = "Book")
@Getter
@Setter
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
@OneToMany(mappedBy = "book", cascade = CascadeType.ALL)
private List<Review> reviews = new ArrayList<>();
}
@Entity
@Table(name = "Review")
@Getter
@Setter
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String comment;
@ManyToOne
@JoinColumn(name = "book_id")
private Book book;
}
Join
기본적인 Join 방법으로는 join(조인 대상, QType) 의 형태로 두 번째 파라미터로는 별칭(Alias)로 사용할 Q타입을 지정하면 된다.
해당 방식은 연관관계 외래키를 통해서 조인한다는 것을 의미한다.
연관관계 외래키를 통해 조회하지 않고 다른 조건으로 조인을 하려면 join(QType) + on(조건)을 통해 수행할 수 있다.(뒤에서 설명)
별칭 Q타입이 무슨 뜻인지 궁금할 수 있다. 쿼리가 복잡해질 경우 기본적으로 Static Import되는 Q타입 이외에 중복되지 다른 별칭을 지정한 동일한 엔터티의 Q타입을 사용해야 하는 경우가 있다.
예를 들어 자기 자신과의 연관 관계일 때의 케이스로 leftJoin(member.member, member)로는 사용할 수 없기 때문에 별칭 Q타입이 필요한 경우가 생긴다.
Join 종류는 아래와 같이 사용할 수 있다.
- join() : Inner Join
- innerJoin() : Inner Join
- leftJoin() : Left Outer Join
- rightJoin() : Rigth Outer Join
@Test
public void join() throws Exception {
//given
//when
List<Book> books = queryFactory
.selectFrom(book)
.leftJoin(book.author, author)
.where(author.name.eq("John Doe"))
.fetch();
//then
for(Book ele : books){
System.out.println(ele);
System.out.println(ele.getAuthor());
}
assertThat(books)
.extracting("id")
.containsExactly(1L, 2L);
}
Join을 수행할 시, JPQL과 마찬가지로 Lazy Loading으로 연관관계가 설정되어 있는 경우 get 메서드 등 실제로 사용하는 순간에 데이터를 로딩하게 되고, 그 이전에는 연관된 데이터를 불러오지 않는다.
2024-01-29T02:06:00.477+09:00 DEBUG 37201 --- [ Test worker] org.hibernate.SQL :
select
b1_0.id,
b1_0.author_id,
b1_0.title
from
book b1_0
left join
author a1_0
on a1_0.id=b1_0.author_id
where
a1_0.name=?
Book(id=1, title=Introduction to SQL)
2024-01-29T02:06:00.485+09:00 DEBUG 37201 --- [ Test worker] org.hibernate.SQL :
select
a1_0.id,
a1_0.age,
a1_0.gender,
a1_0.name,
a1_0.organization_id
from
author a1_0
where
a1_0.id=?
Author(id=1, name=John Doe, age=25, gender=M)
Book(id=2, title=Java Programming Basics)
Author(id=1, name=John Doe, age=25, gender=M)
Test : join
Tuple : Book(id=1, title=Introduction to SQL)
Tuple : Book(id=2, title=Java Programming Basics)
다음 테스트 코드 로그를 보면, Book 내부의 Author를 get 메서드를 사용하여 불러오는 순간 이와 관련된 쿼리가 실행되는 것을 확인할 수 있다.
그리고 동시에 드는 생각은 N+1 문제가 발생할 수 있겠구나가 떠오른다.
N+1 문제
첫 번째 쿼리 수행 : 한 번의 쿼리로 엔터티를 로드
추가 쿼리 수행 : 엔터티에 연관된 엔터티의 개수 만큼의 추가 쿼리를 수행
이를 후술할 Fetch Join을 통해 최적화 해보도록 하자.
ON
On문을 통해서 SQL과 마찬가지로 조인 대상에 대한 필터링을 수행할 수 있으며, 연관관계가 없는 컬럼을 통해서 엔티티를 조인할 수도 있다.
조인 대상에 대한 필터링
책과 저자를 조인하면서, 저자의 이름이 John Doe인 저자와 책만 조인
@Test
public void innerJoinOnFiltering() throws Exception {
//given
//when
List<Book> results = queryFactory
.select(book)
.from(book)
.innerJoin(book.author, author)
.on(author.name.eq("John Doe")) // == where(author.name.eq("John Doe"))
.fetch();
//then
//...
}
Test : leftJoinOnFiltering
Tuple : [Book(id=1, title=Introduction to SQL), Author(id=1, name=John Doe, age=25, gender=M)]
Tuple : [Book(id=2, title=Java Programming Basics), Author(id=1, name=John Doe, age=25, gender=M)]
해당 케이스의 경우 Inner Join이므로 교집합에서 결국 John Doe인 저자의 책들만 조회하면 된다.
따라서 ON문을 통해서 필터링을 수행해도 되지만, Where을 통해서 결과 집합에서 필터링을 수행해도 된다.
@Test
public void leftJoinOnFiltering() throws Exception {
//given
//when
List<Book> results = queryFactory
.select(book)
.from(book)
.leftJoin(book.author, author)
.on(author.name.eq("John Doe"))
.fetch();
//then
//...
}
Test : leftJoinOnFiltering
Tuple : [Book(id=1, title=Introduction to SQL), Author(id=1, name=John Doe, age=25, gender=M)]
Tuple : [Book(id=2, title=Java Programming Basics), Author(id=1, name=John Doe, age=25, gender=M)]
Tuple : [Book(id=3, title=Web Development with Spring), null]
Tuple : [Book(id=4, title=My Book), null]
하지만 다음과 같은 Left Join 케이스는, 왼쪽 테이블의 레코드들은 모두 반환해야 하지만, 오른쪽 테이블에 대한 레코드가 John Doe인 경우만 Join하도록 필터링해야 하므로 ON문을 통해서 필터링해야 한다.
연관관계가 없는 컬럼을 통한 조인
책의 리뷰들 중, 책의 이름이 포함된 리뷰를 Join한다.
@Test
public void innerJoinOnFiltering2() throws Exception {
//given
//when
List<Book> results = queryFactory
.select(book)
.from(book)
// .innerJoin(book.review, review) //외래키를 통한 Join을 하지 않는다.
.innerJoin(review)
.on(review.comment.contains(book.title))
.fetch();
//then
//...
}
Test : innerJoinOnFiltering2
Tuple : [Book(id=1, title=Introduction to SQL), Review(id=5, comment=Introduction to SQL)]
Tuple : [Book(id=1, title=Introduction to SQL), Review(id=6, comment=Introduction to SQL)]
해당 방식은 외래키를 통한 조인이 아닌 Book 테이블의 Title 컬럼과, Review 테이블의 Comment 컬럼 간의 비교를 통한 조인을 수행하였다.
따라서 외래키를 사용하지 않을 경우 innerJoin(QType) 과 같이 조인 대상 연관관계를 지정하지 않고 Q타입만 지정한 후, ON절에서 조인 조건을 설정하는 방식으로 사용할 수 있다.
세타 조인 (Theta Join, Cartesian Product)
# Natural join
select b.bookname, b.publisher, b.price from customer c
natural join orders o natural join book b where c.name = '박지성' or c.name = '장미란';
# Cartesian product
select b.bookname, b.publisher, b.price from customer c, orders o, book b
where c.custid = o.custid and b.bookid = o.bookid
and (c.name = '박지성' or c.name = '장미란');
위의 SQL문의 예시와 같이, Join문은 From문에서 모든 테이블을 불러와 카테시안 곱 연산을 한 이후 Where문에서 처리하는 방식으로도 사용할 수 있다. (모든 조합의 경우의 수 -> Where문을 통한 필터링)
하지만 많이 사용될 일은 없을 것 같다.
@Test
public void thetaJoin() throws Exception {
//given
//when
List<Book> books = queryFactory
.select(book)
.from(book, author)
.where(book.author.id.eq(author.id))
.fetch();
//then
for(Book ele : books){
System.out.println(ele);
System.out.println(ele.getAuthor());
}
}
From절에서 Book과 Author을 함께 불러오며, Where로 필터링 이후 조인되어 결과가 나온다.
이 때 반환되는 데이터에 Book 엔티티와 함께 Author가 Lazy Loading 되고 있다는 점도 확인해두자.
Fetch Join
앞에서 우려했던 N+1 문제에 대해서 Fetch Join을 통해 최적화를 수행할 수 있다는 사실은 JPQL을 사용할 때에도 알고 있던 사실이다.
Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
@PersistenceUnit
EntityManagerFactory emf;
@Test
public void fetchJoinNot(){
em.flush();
em.clear();
List<Book> books = queryFactory
.selectFrom(book)
.join(book.author, author)
.where(author.name.eq("John Doe"))
.fetch();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(books.get(0).getAuthor());
assertThat(loaded).as("로드되지 않음").isFalse();
for(Book ele : books){
System.out.println(ele);
System.out.println(ele.getAuthor());
}
}
@Test
public void fetchJoin(){
em.flush();
em.clear();
List<Book> books = queryFactory
.selectFrom(book)
.join(book.author, author).fetchJoin()
.where(author.name.eq("John Doe"))
.fetch();
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(books.get(0).getAuthor());
assertThat(loaded).as("로드됨").isTrue();
for(Book ele : books){
System.out.println(ele);
System.out.println(ele.getAuthor());
}
}
- join().fetchJoin() : 해당 조인을 FetchJoin하여 연관된 엔티티를 SQL로 한번에 조회하도록 한다.
ON은 일반적인 Join의 조인 조건에 사용되며, Fetch Join에서는 주로 where 절을 이용하여 필터링 조건을 추가한다.
해당 테스트 코드에서 EntityManagerFactory의 isLoaded 메서드는 해당 엔티티가 로딩되었는가의 여부를 검사한다.
이를 AssertThat 조건으로 삼는 Fetch Join을 적용한 테스트 코드와 적용하지 않은 테스트 코드를 작성하였다.
Fetch Join 미적용
2024-01-29T02:58:52.481+09:00 DEBUG 43630 --- [ Test worker] org.hibernate.SQL :
select
b1_0.id,
b1_0.author_id,
b1_0.title
from
book b1_0
join
author a1_0
on a1_0.id=b1_0.author_id
where
a1_0.name=?
Book(id=1, title=Introduction to SQL)
2024-01-29T02:58:52.483+09:00 DEBUG 43630 --- [ Test worker] org.hibernate.SQL :
select
a1_0.id,
a1_0.age,
a1_0.gender,
a1_0.name,
a1_0.organization_id
from
author a1_0
where
a1_0.id=?
Author(id=1, name=John Doe, age=25, gender=M)
Book(id=2, title=Java Programming Basics)
Author(id=1, name=John Doe, age=25, gender=M)
Test : Fetch Join 미적용
Tuple : Book(id=1, title=Introduction to SQL)
Tuple : Book(id=2, title=Java Programming Basics)
패치 조인을 적용하지 않았을 때에는 앞에서 살펴보았던 것과 같이 Lazy Loading을 통해서 사용되는 순간 추가적인 쿼리가 실행되게 된다.
해당 쿼리를 살펴보면 우선적으로 Book의 데이터를 가져온 이후, Book의 외래키를 통해서 또 다시 Author을 검색하는 추가적인 쿼리가 수행되는 것을 로그상으로 확인할 수 있다.
Fetch Join 적용
2024-01-29T02:58:52.186+09:00 DEBUG 43630 --- [ Test worker] org.hibernate.SQL :
insert
into
organization
(org_name)
values
(?)
2024-01-29T02:58:52.198+09:00 DEBUG 43630 --- [ Test worker] org.hibernate.SQL :
select
b1_0.id,
a1_0.id,
a1_0.age,
a1_0.gender,
a1_0.name,
a1_0.organization_id,
b1_0.title
from
book b1_0
join
author a1_0
on a1_0.id=b1_0.author_id
where
a1_0.name=?
Book(id=1, title=Introduction to SQL)
Author(id=1, name=John Doe, age=25, gender=M)
Book(id=2, title=Java Programming Basics)
Author(id=1, name=John Doe, age=25, gender=M)
Test : Fetch Join 적용
Tuple : Book(id=1, title=Introduction to SQL)
Tuple : Book(id=2, title=Java Programming Basics)
패치 조인을 적용한 경우를 살펴보면 연관된 엔티티를 한번에 가져오게 되어, 추가적인 쿼리가 실행되지 않는 것을 확인할 수 있다.
한 줄의 쿼리문에서 Book과 Author의 데이터들을 같이 가져오고 있다.
3개 이상의 엔티티의 Join
@Test
public void complexJoin(){
List<Review> reviews = queryFactory
.select(review)
.from(review)
.join(review.book, book).fetchJoin()
.join(book.author, author).fetchJoin()
.fetch();
for(Review ele : reviews){
System.out.println(ele.getBook().getAuthor());
System.out.println(ele);
}
}
@Test
public void complexJoin2(){
List<Review> reviews = queryFactory
.select(review)
.from(review)
.join(book)
.on(review.comment.contains(book.title))
.join(book.author, author)
.fetch();
for(Review ele : reviews){
System.out.println(ele.getBook());
System.out.println(ele.getBook().getAuthor());
System.out.println(ele);
}
}
다음과 같이 메서드 체이닝을 통해서 연관된 여러개의 엔티티에 대해서 Join을 수행할 수도 있다.
함께 보기
MySQL의 Join Expression
https://sjh9708.tistory.com/176
지연 로딩, N+1 문제, Fetch Join
https://sjh9708.tistory.com/160
함께 보기
1. QueryDSL 설정과 Repository에서의 사용
https://sjh9708.tistory.com/174
2. QueryDSL 기본 문법
https://sjh9708.tistory.com/175
3. QueryDSL Join 문법
https://sjh9708.tistory.com/178
4. QueryDSL 서브쿼리와 상수/문자열 조작
https://sjh9708.tistory.com/180
5. QueryDSL 프로젝션과 Entity > DTO 변환 방법들
https://sjh9708.tistory.com/181
6. QueryDSL 동적 쿼리 작성하기
https://sjh9708.tistory.com/182
'Backend > Spring' 카테고리의 다른 글
[Spring Boot/JPA] Entity > DTO 변환 방법들 및 QueryDSL 프로젝션 (1) | 2024.02.13 |
---|---|
[Spring Boot/JPA] QueryDSL 문법(3) : 서브쿼리, 상수/문자열 조작 (1) | 2024.02.01 |
[Spring Boot/JPA] QueryDSL 문법(1) : 기본 검색 (선택, 조건, 정렬, 집계, 그룹화) (1) | 2024.01.25 |
[Spring Boot/JPA] QueryDSL 설정과 Repository에서의 사용 (2) | 2024.01.23 |
[Spring Boot] Spring Security : JWT Auth (SpringBoot 3 버전) (9) | 2024.01.15 |