JPA

페치 조인(Fetch join)

dev-rootable 2023. 8. 21. 16:23

📌 페치 조인이란

 

JPQL에서 성능 최적화를 위해 제공하는 기능으로 SQL 조인의 종류는 아니다.

 

연관된 엔티티나 컬렉션을 SQL 한 번에 함께 조회하는 기능으로 즉시 로딩(EAGER)과 같은 효과를 보여준다.

 

기본 형태

 

select m from Member m join fetch m.team

 

📌 예제 <EAGER vs Fetch Join>

 

회원과 팀은 다음과 같은 관계에 있다.

 

Member와 Team의 관계

 

둘을 조인하면 아래와 같은 결과가 나온다.

 

Member JOIN Team 결과

 

위 관계에서 현재 지연 로딩이라고 할 때, 아래와 같은 코드를 실행하면 쿼리가 몇 번 나가게 될까

 

회원 조회

 

1번째 쿼리 : 회원 조회

 

2번째 쿼리 : 팀A를 찾는 쿼리, 3번째 쿼리 : 팀B를 찾는 쿼리

 

영속성 컨텍스트가 빈 상태에서 회원 1이 속한 팀 A를 질의한다. 회원 2는 이미 팀 A를 질의한 상태이므로 영속성 컨텍스트에서 값을 찾을 수 있다. 마지막으로 회원 3은 영속성 컨텍스트에 없으므로 다시 DB에 팀 B를 질의한다.

 

이렇게 3번의 쿼리를 통해 채워진 영속성 컨텍스트는 다음과 같다.

 

1차 캐시에 총 5개의 엔티티 보관

 

🔎 문제점

 

지연 로딩을 했음에도 불구하고, 연관 관계 엔티티까지 총 3번의 쿼리가 나갔다. 그 이유는 지연 로딩에서 사용하는 프록시는 getter를 사용하는 시점에 초기화를 수행하는데, 이때 DB에서 실제 Entity를 찾는 쿼리가 나가기 때문이다. 앞서 코드의 루프에서 "getTeam(). getName()"을 반복하고 있어 최악의 경우, 모든 회원의 팀이 다르다면 그 숫자만큼 쿼리가 나가는 것이다. 이를 N + 1 문제라고 한다.

 

💡 N + 1 문제

한 번의 쿼리로 N번의 쿼리가 발생하는 문제로 한 번의 쿼리로 관련된 다수의 쿼리가 발생하는 것을 말한다.

 

🔎 해결 방법

 

페치 조인으로 문제를 해결할 수 있다. 페치 조인은 필요한 곳에서 즉시 로딩을 수행하는 것으로 위 경우에도 회원을 조회할 때 팀을 한 번에 조회하면 된다.

 

페치 조인 - 회원 조회

 

프록시가 아닌 실제 엔티티를 함께 조회

 

위 결과처럼 프록시를 사용하지 않는다. 하지만 즉시 로딩과 똑같지는 않다.

 

즉시 로딩은 사용 여부에 관계없이 무조건 연관된 엔티티를 모두 조회한다. 반면, 페치 조인은 JPQL을 통해 설정한 엔티티에만 즉시 로딩 효과를 내는 것으로 부분적인 즉시 로딩을 수행할 수 있다는 차이점이 있다. 또한, N + 1 문제가 발생하지 않는다는 차이도 있다.

 

우선권 : Fetch join > LAZY

 

즉시 로딩은 개발자가 예측하기 힘든 추가 쿼리가 발생하여 실무에서 지양하는 것이 좋다. 또한, 지연 로딩은 반복적인 초기화를 수행할 때, N + 1 문제로부터 자유롭지 못하다. 따라서, 실무에서 발생하는 N + 1 문제는 대부분 페치 조인을 통해 해결한다.

 

실무에서 대부분 N + 1 문제는 페치 조인으로 해결

 

📌 컬렉션 페치 조인

 

일대다 관계일 때도 페치 조인을 사용할 수 있다.

 

Team에서 Member를 페치 조인하는 경우로, 아래의 SQL을 수행하는 것이다.

 

SELECT M.*, T.*
FROM TEAM T
	INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

 

위 SQL은 아래 JPQL로 수행할 수 있다.

 

select t from Team t join fetch t.members where t.name = '팀A'

 

🔎 컬렉션 페치 조인의 문제점

 

아래 쿼리는 각 팀에 소속된 회원을 질의하고, 팀 이름과 그 팀에 소속된 회원 숫자를 출력한다.

 

소속된 회원 조회

 

그런데 아래와 같은 중복된 결과가 나타난다.

 

팀A 중복

 

좀 더 자세하게 보기 위해 아래와 같이 출력했다.

 

팀별 회원 정보 조회

 

동일한 결과를 2번 출력하고 있다

 

이처럼 동일 엔티티에 대해 조회된 레코드를 모두 가져오기 때문에 중복된 결과를 필터링하지 못한다는 문제점이 있었다.

 

팀A로 조회된 데이터의 중복

 

✔ 해결 방법

 

이것은 DISTINCT로 해결할 수 있다. JPQL의 DISTINCT는 다음 2가지 기능을 제공한다.

 

  • SQL에 DISTINCT 추가
  • 애플리케이션에서 엔티티 중복 제거

 

SQL에 DISTINCT를 추가한다고 해도 각 컬럼 데이터가 완전히 일치한 것이 아니므로, 중복 데이터로 보지 않는다.

 

각 컬럼 데이터에서 부분 불일치

 

그래서 JPQL의 DISTINCT는 엔티티의 식별자를 보고 애플리케이션에서 엔티티 중복을 제거해 준다.

 

String query = "select distinct t from Team t join fetch t.members";

 

DISTINCT 추가 후 중복 제거

 

참고로 일대다 조인은 교집합을 찾는 과정에서 결과 데이터의 크기가 매우 커질 수 있다.

 

📌 페치 조인과 일반 조인의 차이

 

일반 조인은 연관된 엔티티를 함께 조회하지 않는다.

 

String query = "select t from Team t join t.members m";

 

Team만 조회

 

그리고 Member의 데이터가 없으므로 Member를 추가로 조회한다.

 

Member 추가 쿼리

 

반면, 페치 조인은 Team과 연관된 Member까지 한 번에 조회한다.

 

Team과 Member를 한 번에 조회

 

🔎 정리

 

JPQL은 결과를 반환할 때, 연관 관계를 고려하지 않는다. 단지, SELECT 절에 지정된 엔티티만 조회할 뿐이다. 그래서 일반 조인은 연관 관계를 고려하지 않고 Team만 조회했다. 하지만 JPQL은 페치 조인을 사용할 때만 연관된 엔티티도 함께 조회한다. 이것은 부분적으로 즉시 로딩 효과를 낼 수 있고, 객체 그래프를 SQL 한 번에 조회하는 개념이다.

 

📌 한계

 

🔎 페치 조인 대상에는 별칭을 줄 수 없다.

 

하이버네이트는 가능하지만 가급적 사용하지 않도록 한다.

 

일대다 관계에서 아래와 같은 쿼리를 수행하면 Collection 형태로 조회되는 데이터의 일부만 나오기 때문에 정확하지 않은 결과가 나올 수 있다.

 

//t.members에는 Collection 데이터 전체가 있어야 함
//아래 쿼리는 필터링된 데이터가 t.members가 되어 전체 데이터가 아닐 수 있음
select t from Team t join fetch t.members m where m.age > 10;

 

별칭을 사용할 수 있는 경우도 있다.

 

페치 조인 대상에 영향을 주지 않는 쿼리는 별칭을 사용할 수 있다. 즉, members 라는 Collection에 아무런 영향을 주지 않는 쿼리로 DB 일관성에 영향을 주지 않는다.

 

select t from Team t join fetch t.members m where t.name = :teamName //페치 조인 대상에 영향x

 

이처럼 조인과 무관한 필터링은 where을 사용하는 것이 올바르다. 아래처럼 ON을 사용하면 에러가 발생한다.

 

select t from Team t join fetch t.members m on t.name = :teamName //error

 

페치 조인 대상에 영향을 주는 다음과 같은 Outer Join도 사용할 수 없다. 

 

select t from Team t left join fetch t.members

 

팀이 없는 회원도 추가로 조회되므로, members 컬렉션 결과가 필터링된다.

 

일대다 관계에서 컬렉션 페치 조인을 하면 페치 대상은 컬렉션이다. 페치 조인은 해당 컬렉션에 연관된 모든 엔티티가 들어 있다고 가정하고 작업을 수행한다. 그래서 해당 컬렉션에 영향을 줄 수 있는 모든 필터링 및 조인의 사용을 금하는 것이다.

 

컬렉션 페치 조인의 대상 컬렉션에 영향을 주는 모든 작업 금지

 

🔎 둘 이상의 컬렉션은 페치 조인을 할 수 없다.

 

예를 들어, Member에 일대다 필드 A, B가 있을 때, A와 B를 함께 페치 조인으로 가져올 수 없다는 것이다.

 

🔎 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.

 

일대일, 다대일로 설정된 단일 값 연관 필드는 페치 조인해도 페이징이 가능하다.

 

아래와 같이 페이징을 수행하면 쿼리가 어떻게 나갈까

 

현재 상황

 

            ...

            String query = "select t from Team t join fetch t.members";

            List<Team> result = em.createQuery(query, Team.class)
                    .setFirstResult(0)
                    .setMaxResults(1)
                    .getResultList();

            System.out.println("size = " + result.size());

            for (Team team : result) {
                System.out.println("team = " + team.getName() + ", members = " + team.getMembers().size());
                for (Member member : team.getMembers()) {
                    System.out.println(">>> member = " + member);
                }
            }

 

쿼리 결과

 

위 결과를 보면 결과 1개만 잘 가져와서 출력했다. 그런데, 페이징을 수행했는데도 불구하고 select 쿼리에 limit 문이 없다. 그리고 경고문을 보면 메모리에서 컬렉션 페치를 처리한다고 적혀 있다. 즉, 테이블을 FULL SCAN해서 애플리케이션 메모리에 올리고, 애플리케이션에서 요청한 페이지에 맞춰서 잘라내고 있는 것이다.

 

✔ 원인

 

페치 조인을 한다해도 DB 입장에서는 조인문이 나가게 된다. 일대다 관계의 조인 결과는 연결된 테이블의 row 수만큼 늘어난다. 그렇다면 Hibernate 입장에서는 중복으로 생긴 row, 즉 같은 일의 집합에 소속된 row를 고려해서 limit을 날려야 할지 고민하게 되는데, 이를 SQL문으로 해결하지 않고 메모리에 전부 올린 후 페이징하는 식으로 해결한 것이다.

 

Reference:

https://velog.io/@antcode97/Fetch-Join%EC%9D%98-%ED%95%9C%EA%B3%84

 

Fetch Join의 한계

JPA로 엔티티를 설계하고, 쿼리를 짜다 보면연관관계가 굉장히 복잡해지는 경우를 볼 수 있다.보통 그럴 때, fetch join이 만능 해결책처럼 사용되곤 하는데fetch join도 한계가 있다.위 객체 처럼, 1대

velog.io

 

✔ 해결 방법

 

@BatchSize 애노테이션 또는 batch_petch_size 옵션을 통해 해당 사이즈만큼 연관된 엔티티를 한 번에 쿼리 한다.

 

BatchSize는 설정한 사이즈만큼 데이터를 가져와서 컬렉션이나 프록시 객체를 한꺼번에 IN 쿼리를 통해 조회한다. 단순 지연 로딩을 이용하면 N + 1 문제가 발생할 수 있지만, BatchSize 옵션은 설정한 크기만큼 미리 갖고 오기 때문에 쿼리를 날리는 횟수를 크게 줄일 수 있다.

 

기본 사용법은 아래와 같다. size는 1000개 이하 값(1000개 넘으면 몇몇 DB에서 에러 발생) 중에서 가능한 큰 값으로 설정하면 되고, 만약 BatchSize보다 결과 값이 크면 해당 사이즈 단위로 나눠서 처리한다. 실무에서는 대부분 기본 설정으로 넣고 시작한다.

 

한 번에 100개의 데이터를 갖고 옴(xxxToMany)

 

위와 동일(xxxToOne)

 

위와 동일한 설정(persistence.xml)

 

# application.yaml(위와 동일한 설정)
spring:
  jpa:
    properties:
      hibernate:
      	default_batch_fetch_size: 100

 

연관된 팀A, 팀B에 대해 한 번에 쿼리

 

🔎 정리

 

페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 즉, DB 설계 그대로 조회할 때를 말한다. 반면에 여러 테이블을 조인해서 엔티티 원본이 아닌 전혀 다른 결과를 내야 한다면 페치 조인보다 일반 조인을 사용하고 필요한 데이터들만 조회해서 DTO로 반환하는 것이 효과적이다. 이를 위해 애플리케이션 결과를 DTO로 변환하고 JPQL을 짤 때부터 DTO로 생성되도록 작성해야 한다.

 

Reference:

https://velog.io/@antcode97/Fetch-Join%EC%9D%98-%ED%95%9C%EA%B3%84

 

Fetch Join의 한계

JPA로 엔티티를 설계하고, 쿼리를 짜다 보면연관관계가 굉장히 복잡해지는 경우를 볼 수 있다.보통 그럴 때, fetch join이 만능 해결책처럼 사용되곤 하는데fetch join도 한계가 있다.위 객체 처럼, 1대

velog.io

 

https://www.inflearn.com/course/ORM-JPA-Basic/dashboard

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

초급자를 위해 준비한 [웹 개발, 백엔드] 강의입니다. JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자

www.inflearn.com