아니 대리님 N+1 문제가 발생하길래 Fetch Join하고 EntityGraph 어노테이션을 사용했는데 서버가 죽었어요
📌 개요
N+1 문제를 효과적으로 해결하는 방법은 크게 세 가지가 있는데, Fetch Join , @EntityGraph , Batch Size 이다. 일반적인 경우에서 이들 중 뭐가 더 좋냐고 하면 Fetch Join 과 @EntityGraph 가 더 좋다고 할 수 있다. 발생하는 쿼리의 개수 관점에서 보면 그렇다는 것이다. Fetch Join 과 @EntityGraph 는 1개의 쿼리가 발생하는 반면 Batch Size 는 , 최소 2개의 쿼리가 발생한다. 설정된 size에 따라 그 이상이 될 수 있다.
단, 대량의 데이터에서 페이징과 Fetch Join 또는 @EntityGraph 를 함께 사용하는 경우 OutOfMemoryError 가 발생할 수 있다.
📌 원인
일대다 관계에서 Fetch Join 과 Pageable 을 같이 사용하면 Hibernate 는 DB 레벨에서 LIMIT 또는 OFFSET 을 적용할 수 없다. 왜냐하면 SQL의 JOIN 결과와 JPA가 만들어야 하는 객체 그래프의 구조가 다르기 때문이다.
| team_id | team_name | member_id | member_name | 
|---|---|---|---|
| 1 | TeamA | 101 | Member1 | 
| 1 | TeamA | 102 | Member2 | 
| … | … | … | … | 
| 1 | TeamA | 200 | Member100 | 
| 2 | TeamB | 201 | Member101 | 
| … | … | … | … | 
Team 과 Member 엔티티가 일대다 관계라고 하자. SQL JOIN 을 하게 되면 카테시안 곱이 발생하여 각 Team 의 데이터가 가진 Member 의 수만큼 중복되어 나타나게 된다. 반면 JPA 객체 그래프는 Page<Team> 을 기대한다. 중복되지 않는 Team 객체와 속한 members 컬렉션을 온전히 가져야 한다.
이 상황에서 LIMIT 10 을 적용하개 되면, TeamA 와 속한 10명의 Member 만 조회되게 된다. 이를 JPA 객체 그래프로 변환하게 되면 단 하나의 Team 객체만 생성되며, TeamA 에 속한 members 도 온전히 가지지 못하게 된다.
이러한 데이터 정합성 문제를 피하기 위해 Hibernate는 메모리에 모든 데이터를 올린 후 페이징을 하는 방법을 선택한다. 전체 데이터를 메모리에 올리려고 하다 보니 OOM 이 발생하는 것이다.
📌 해결 방법
페이징과 Batch Size 를 같이 사용하면 된다.
1
2
3
4
5
6
spring:
  jpa:
    properties:
      hibernate:
        query:
          fail_on_pagination_over_collection_fetch: true
아니면 근본적인 해결 방법은 아니지만, fail_on_pagination_over_collection_fetch 를 true 로 설정하면 데이터를 메모리에 페이징으로 가져오려고 할 때 에러를 발생시키도록 한다.
📌 참고
https://jojoldu.tistory.com/737
