Study/Java&Spring

JPA Fetch Join 튜닝

kdhoooon 2022. 1. 15. 21:24

JPA를 사용하다 보면 n + 1 문제에 마주치고 바로 Fetch Join을 접하게 된다.

n + 1 을 Fetch Join으로 해결하면 된다는 해결법에 집중하였지만, 정확한 원리Sql 언어의 Join과 다른점은 무엇인지 파악하기 위해 알아보기 위해 공부를 통해 알게 된 내용을 정리하였다.

 

Fetch Join

  • JPQL 에서 제공하는 성능 최적화를 위해 제공하는 기능
  • 조회가 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
  • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨

일반 Join

  • Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
  • 조회가 주체가 되는 Entity만 SELECT해서 영속화 하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색 조건에는 필요한 경우에 주로 사용됨

Fetch Join 과 일반 Join 차이점

  • 가장 큰 차이는 위에서 말했듯이 일반 Join은 실행 시 연관된 Entity를 함께 조회하지 않고, SELECT절에 지정한 엔티티만 조회한다.
  • 하지만 그렇다고 해서 데이터가 프록시인 것은 아니지만 해당 데이터에 접근하려고 하면 로딩 시점에 쿼리가 날아간다

 

Entity Fetch Join

회원을 조회하면서 팀도 함께 조회하고 싶다고 가정하면, SQL에서는 회원 뿐만 아니라 팀도 같이 조회를 한다.

이를 JPQL의 Fetch Join을 이용해 표현하면,

select m from Member m join fetch m.team

얼핏 보면 SQL과 유사해보이는데 차이점이 있다.

 

가장 큰 차이점은 프로젝션에 m(Memeber)만 존재한다.

위 JPQL은 아래의 SQL로 번역된다.

SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID

즉, Fetch Join의 목적은 관계있는 Entity를 한방에 가져오는 것에 목적이 있다.

이는 지연로딩(LAZY)으로 설정하여도 즉시로딩(EAGER)으로 가져오게 된다.

 

위 방식으로 한번의 쿼리로 여러개의 멤버와 팀의 정보를 가져오면서 n + 1 문제를 해결할 수 있다.

 

N명의 멤버가 하나의 팀을 선택 할 수 있는 다대일 관계를 생각해보자.

 

N며의 멤버를 가져오기 위한 쿼리가 발생할 것이고, 이후 각 멤버는 팀을 프록시 객체로 받아온 뒤 프록시 객체의 값에 접근하려고 할 때 실제 쿼리를 날려서 팀의 정보를 가져오게 된다.

 

이때, 문제는 N명의 멤버가 모두 각기 다른 팀을 가진다고 가정하면 1명의 멤버마다 그 멤버가 소속된 팀의 쿼리가 나아갈 것이고 (1차 캐시가 안될거니까) 이것이 우리가 말하는 n + 1 문제가 발생하는 원인이다.

 

사실 이러한 n + 1 문제는 지연로딩이든 즉시로딩이든 발생하는 문제인데, 이를 해결하는 방법 중 하나가 바로 JPQL의 Fetch Join이다.

 

XToOne 관계에서는 이러한 조회에서 문제가 되지 않는다. OneToMany 상태일 때는 이제 문제가 발생한다.

 

컬렉션 Fetch Join에서의 문제는 이게 Join이다 보니, 1 : N 조인은 반드시 데이터가 뻥튀기 될 수 있다.

 

위 그림을 보면 팀 A 입장에서는 하나이지만, 멤버가 2명이어서 2 ROW가 된다.

이와 같은 문제를 해결하기위해 DISTINCT를 사용한다.

 

Fetch Join 과 DISTINCT

위와 같이 데이터 중복 ROW가 생기는것을 방지하기 위해서 SQL의 DISTINCT를 사용하여 중복 결과를 제거한다.

 

실제로 DISTINCT 를 추가하여 아래와 같이 JPQL을 작성하여 JPA에서 실행하게 되면,

select distinct t from Team t join fetch t.members

 

결과적으로 값은 옳게 나온다. 해당 중복 데이터 값이 합쳐져서 ROW가 뻥튀기 되는것을 막을 수 있다.

하지만 SQL의 DISTINCT와 JPQL 에서 제공하는 DISTINCT는 실제 결과값이 다르게 나온다.

 

위의 코드를 이용해 실제 쿼리를 찍어보게 되면,

실제로 SQL에 DISTINCT를 추가한다. 하지만 SQL의 DINSTINCT는 실제로 모든 값이 같아야 하나의 결과로 보여주기 때문member의 값까지 같은 것이 아니면 같은 결과라고 보지 않는다.

 

따라서 위의 코드로 나온 쿼리를 Database에서 돌리게 되면 row의 값은 여전히 뻥튀기 된 상태로 나오게 된다. 

이를 JPQL의 DISINCT가 제거하게 되는 방식인 것이다.

 

정리하자면 JPQL의 DISTINCT 역할은 두가지가 있다.

  1. SQL에 DISTINCT를 추가한다.
  2. 애플리케이션의 Entity 중복을 제거한다.

이런 Fetch Join 에도 한계가 있다.

한계

1. paging API를 사용할 수 없다는 점이다.

컬렉션(일대다)이 아닌 단일 값 연관 필드(일대일, 다대일)들은 패치 조인을 사용해도 페이징 API를 사용할 수 있다.

하이버 네이트에서 컬렉션을 페치조인하고 페이징 API를 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관 없겠지만 데이터가 많으면 성능 이슈와 메모리 초과(out of memory) 예외가 발생할 수 있어 위험하다.

 

그럼 위와 같은 페이징 + 컬렉션 엔티티 조회 문제는 어떤 방법으로 해결해야 할까에 대해 알아보자.

 

지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용

  • hibernate:default_batch_fetch_size : 글로벌 설정
  • @BatchSize : 개별 최적화
  • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN쿼리로 조회한다.
spring:
 jpa:
  properties:
   hibernate:
    default_batch_fetch_size: 1000

위와 같이 야믈에 선언해서 사용할 수 있다.

개별로 설정하려면 -> 컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 @BatchSize 어노테이션을 적어서 사용

 

위와 같이 글로벌로 설정을 해놓고 기존의 XToOne 으로 Fetch Join 로 튜닝한 부분은 냅두고 OneToMany로 설정 된 부분은 DTO 를 사용해서 직접 컬렉션 추가를 해줘야 한다.

public TeamDto(Team team){
	teamId = team.getId();
	teamName = team.getName();
	members = team.getMembers().stream().map(member -> new member(member)).collect(toList());
}

위 의 코드와 같이 TeamDto를 따로 설정하여서 다음과 같이 직접 member를 생성해주는 방식으로 컬렉션을 구성하는 방향으로 해야 paging API를 사용할 수 있다.

 

페이징 한계 돌파 튜닝의 장점

  • 쿼리 호출 수가 1 + N -> 1 + 1 로 최적화 된다.
  • 조인보다 DB 데이터 전송량이 최적화 된다. ( Team 과 Member를 조인하면 Team이 Member만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)
  • 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다

결론적으로 ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size로 최적화 하자.

 

참고

default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100 ~ 1000 사이를 선택하는 것을 권장한다. 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.

 

 

2. 별칭을 주 수 없다.(하이버네이트는 가능)

 

select t from Team t join fetch t.members # as m

페치 조인은 기본적으로 연관된 엔티티를 다 가져오는 것 이기 때문에, 만약 팀 하나에 멤버가 5명있는데 3명만 조회했다고 가정한다.

그 3명만 따로 조작한다는 것은 굉장히 위험할 수 있다.

select t from Team t join fetch t.members as m where m.age > 10;

위 쿼리는 팀에 있는 10살 이상의 멤버를 조회할 수 있는 쿼리다.

만약 10살이상의 멤버가 3명이라고 가정하면, 이렇게 3명의 멤버만 조회 하는 것은 JPA에서 의도한 Fetch Join의 의도와 어긋나게 된다.

 

이는 JPA의 설계 사상인 객체 그래프 탐색과도 어긋날 수 있는 부분이기 때문에 Fetch Join에서 별칭은 가급적 쓰지 않는 것이 좋다.

 

3. 둘이상의 컬렉션은 Fetch Join 할 수 없다.

 

위에서 1:N 의 경우에 데이터가 뻥튀기 되면서 distinct 튜닝을 통해서 이를 해결했던 적이 있다.

이와 같이 1:N도 데이터가 뻥튀기 될 수 있는데 1:N:N이 된다면, 데이터가 예상하지 못하게 팍팍 늘어나면서 문제가 발생할 수 있다.

 

 

 

정리

모든 것을 페치 조인으로 해결할 수는 없다.

 

만약 여러 테이블을 조인해서 엔티티가 가진 모양이 아닌 전혀 다른 결과(통계쪽)를 내야하는 경우라면 페치 조인 보다는 일반 조인을 사용하는 것이 낫다.

 

일반 조인을 사용하고 필요한 데이터만 조회해서 DTO로 반환하는 것이 효과적이다.