이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › JPA › 쿼리
JPQL과 페치 조인
join과 join fetch는 무엇이 다른가
JPQL은 테이블이 아니라 엔티티를 대상으로 쓰는 쿼리입니다. 게시글을 조회하면서 댓글을 그냥 join할 때와 join fetch할 때, DB에서 읽어 오는 컬럼과 그 뒤에 따라 나가는 조회가 달라집니다.
실행으로 대조 · Hibernate 7.4.5와 H2에서 열두 가지 조합을 실행해 결과 목록, 돌려받은 행 수, SQL의 행 수 제한 위치, 뒤따른 조회, 경고와 예외를 대조 (예전 버전의 동작은 문서 기준)
이 실습에 나오는 개념
- JPQL
- 테이블이 아니라 엔티티와 그 필드를 대상으로 작성하는 JPA의 쿼리 언어다. SQL과 문법이 비슷하며, 실행할 때 JPA 구현체가 사용 중인 데이터베이스에 맞는 SQL로 변환한다. 특정 데이터베이스의 문법에 의존하지 않는다는 장점이 있다.
- fetch join
- JPQL에서 엔티티를 조회하면서 연관된 엔티티를 SQL 조인으로 한 번에 함께 가져오게 하는 기능이다. 지연 로딩으로 설정된 연관 관계라도 이 쿼리에서는 즉시 함께 조회되어 N+1 문제를 해결한다. 컬렉션을 여러 개 함께 가져오면 결과 행이 곱으로 늘어나는 점에 주의한다.
- 컬렉션 페치 조인과 페이징
- 일대다 관계의 컬렉션을 페치 조인하면 SQL의 결과 행이 자식 수만큼 늘어나므로, 행 수를 세는 DB의 페이징(limit)을 그대로 걸면 부모를 기준으로 자를 수 없다. 그래서 예전 버전의 Hibernate는 이 경우 SQL에 행 수 제한을 넣지 않고 조건에 맞는 행을 전부 읽어 온 뒤 메모리에서 잘랐고, 그때 경고 HHH90003004를 로그에 남겼다. 데이터가 많으면 전부 메모리에 올라와 위험하다. Hibernate 7.4.5는 부모를 먼저 자르는 서브쿼리를 만들어 DB에서 처리하지만, 정렬 기준이 컬렉션 쪽에 있는 경우처럼 여전히 메모리에서 자르는 경우가 있다. 버전에 따라 동작이 다르므로 실제로 나가는 SQL을 확인해야 하며, 컬렉션은 지연 로딩과 배치 크기 설정으로 따로 가져오는 방법이 안전하다.
- MultipleBagFetchException
- List로 선언한 컬렉션 두 개 이상을 한 쿼리에서 함께 페치 조인하려 할 때 Hibernate가 던지는 예외다. Hibernate는 순서 컬럼이 없는 List를 bag이라고 부르는데, 두 컬렉션을 한 SQL로 조인하면 행이 두 컬렉션 크기의 곱으로 늘어나 어느 행이 중복인지 구분할 수 없기 때문에 실행 전에 거절한다. 한쪽을 Set으로 바꾸면 예외는 사라지지만 행이 곱으로 늘어나는 문제는 그대로 남는다. 컬렉션은 하나만 페치 조인하고 나머지는 배치 크기 설정으로 따로 가져오는 것이 보통의 해결 방법이다.
- 카테시안 곱
- 두 집합의 모든 조합을 만들어 내는 것이다. SQL에서 한 엔티티에 딸린 두 개의 컬렉션을 동시에 조인하면 결과 행 수가 두 컬렉션 크기의 곱으로 늘어난다. 불필요하게 많은 데이터가 전송되고 메모리를 차지하므로 여러 컬렉션을 한 번에 조인해 가져오는 것은 피한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 그냥 join. 조인을 했는데도 댓글 조회가 게시글마다 따로 나갑니다.
- join fetch — 댓글 컬럼까지 한 번에 읽어 와서 뒤따르는 조회가 없습니다.
- 조인 없이 조회 — 게시글만 읽고, 댓글은 쓸 때마다 조회합니다.
- 테이블 이름으로 쓰면 — JPQL의 from 뒤에는 엔티티 이름이 와야 합니다.
- 페치 조인에 페이징 — Hibernate 7.4.5는 서브쿼리에서 게시글을 먼저 자릅니다.
- 메모리에서 자르는 경우 — 댓글 기준으로 정렬하면 전부 읽어 온 뒤 메모리에서 자르고 경고를 냅니다.
- 컬렉션 두 개를 페치 조인 — 실행하기 전에 예외로 거절됩니다.
처음 설정으로 실행하면
- em.createQuery(…): JPQL은 테이블이 아니라 엔티티를 대상으로 씁니다. from Post p의 Post는 엔티티 이름(따로 정하지 않으면 클래스 이름)이고, p.comments는 Post의 필드 이름입니다. Hibernate가 이것을 posts와 comments 테이블을 조회하는 SQL로 바꿉니다.
- getResultList(): SQL이 나갑니다. comments 테이블을 조인하지만, select 절에는 게시글 컬럼만 있습니다. 댓글은 조인 조건에만 쓰였습니다. 게시글 1은 댓글이 3개라 3행, 게시글 2는 2행이 되어 DB가 모두 5행을 돌려줍니다.
- 5행을 Post 객체로 바꿉니다. 같은 게시글이 여러 행에 나오지만 Hibernate가 한 번만 담아서 결과 목록은 2개입니다. comments 목록은 댓글 컬럼을 읽어 오지 않았으므로 아직 비어 있는(읽지 않은) 상태입니다.
- 반복문, 게시글 1: p.getComments().size()를 부르는 순간 비어 있던 목록을 채우려고 댓글 조회 SQL이 따로 나갑니다.
- 반복문, 게시글 2: p.getComments().size()를 부르는 순간 비어 있던 목록을 채우려고 댓글 조회 SQL이 따로 나갑니다.
실행 결과
- 그냥 join은 SQL에 조인을 넣을 뿐, 연관 엔티티의 컬럼을 읽어 오지 않습니다. 댓글로 조건을 걸 때는 쓸모가 있지만, 댓글을 쓰려면 다시 조회해야 합니다. 함께 가져오려면 join fetch를 씁니다.
- DB는 5행을 돌려주었지만 결과 목록은 2개였습니다. Hibernate 7.4.5는 엔티티 하나를 조회하는 쿼리에서 같은 엔티티를 한 번만 담습니다. 엔티티가 아니라 값(p.title)을 조회하면 5개가 그대로 나오는 것도 같은 환경에서 확인했습니다.
코드
@Entity
@Table(name = "posts")
class Post {
@Id Long id;
String title;
@OneToMany(mappedBy = "post")
List<Comment> comments = new ArrayList<>();
@OneToMany(mappedBy = "post")
List<Attachment> files = new ArrayList<>(); // 첨부 파일
}
@Entity
@Table(name = "comments")
class Comment {
@Id Long id;
String text;
@ManyToOne(fetch = FetchType.LAZY)
Post post;
}
// DB: 게시글 1 "공지"에 댓글 3개, 게시글 2 "질문"에 댓글 2개
List<Post> posts = em.createQuery(
"select p from Post p join p.comments c order by p.id",
Post.class)
.getResultList();
for (Post p : posts) {
p.getComments().size(); // 게시글마다 댓글을 사용한다
}
이 모형의 전제
게시글 1에 댓글 3개, 게시글 2에 댓글 2개가 있는 상태에서 열두 가지 조합(JPQL 여섯, 페이징 여부)을 모두 실제 Hibernate 7.4.5와 H2 데이터베이스에서 실행해, 결과 목록의 크기와 순서, DB가 돌려준 행 수, SQL에 댓글 컬럼과 행 수 제한이 들어 있는지와 그 위치, 뒤따른 조회 수, 경고와 예외가 화면과 일치하는 것을 확인했습니다. 화면의 SQL은 읽기 쉽게 줄인 것입니다. 행 수 제한을 limit 1로 적었지만 H2에서 실제로 나간 문장은 offset ? rows fetch first ? rows only이고 DB 종류에 따라 다릅니다. 이 주제는 버전에 따라 결과가 달라집니다. 페치 조인에 페이징을 걸었을 때 서브쿼리로 DB에서 자르는 것, 그냥 join의 결과에서 같은 엔티티가 한 번만 담기는 것은 7.4.5에서 실행한 결과입니다. "예전 버전은 늘 메모리에서 잘랐다", "Hibernate 5까지는 distinct가 필요했다"는 설명은 문서를 따른 것이며 이 PC에는 예전 버전이 없어 실행으로 확인하지 못했습니다. 대조는 조합마다 애플리케이션을 새로 시작한 상태(SessionFactory를 새로 만듦)에서 했습니다. 같은 SessionFactory에서 같은 페치 조인 JPQL을 페이징 없이 먼저 실행한 뒤 페이징을 걸어 실행했더니, 행 수 제한이 붙지 않은 SQL이 다시 쓰여 전체가 돌아오는 것을 이 환경에서 보았기 때문입니다. 또 페치 조인한 댓글에 where 조건을 걸고 페이징하면 이 환경에서는 SQL 문법 오류가 났습니다. 컬렉션 페치 조인과 페이징을 함께 쓰는 코드는 쓰는 버전에서 실제 SQL을 확인해야 합니다. 그냥 join과 join fetch는 내부 조인이라 댓글이 없는 게시글은 결과에서 빠지는데, 이 실습의 자료에는 그런 게시글이 없습니다.
앞 실습: N+1 · 다음 실습: 벌크 연산