JPA와 N+1 문제

재훈·2024년 5월 30일
post-thumbnail

1. N+1 문제란

엔티티를 조회할 때 연관된 엔티티를 조회하기 위한 N번의 쿼리가 추가적으로 발생하는 문제 ⇒ DB 부담 증가

예시(1 : N 관계)

1:N 뿐만 아니라 1:1, N:1 관계에서 모두 발생할 수 있음!

Entity

 @Entity
 @Getter
 @Builder
 @NoArgsConstructor(access = AccessLevel.PROTECTED)
 @AllArgsConstructor
 public class Post {
 
     @Id
     @GeneratedValue(strategy = GenerationType.IDENTITY)
     private Long id;
     private String title;
     private String content;
     
     // ...
     
     @Builder.Default
     @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
     private List<Image> images = new ArrayList<>(); // image는 id와 url을 갖고 있음
     
     //... 
 }

Service

5개의 게시글을 불러온다고 가정

    @Transactional(readOnly = true)
    public PostResponse getPostList(Long postId) {
        List<Post> posts = postRepository.findAll();
    
        return posts.stream()
                    .map(PostResponse::toResponse)
                    .collect(Collectors.toList());
    }

Query

각각의 게시글의 Image에서 url을 불러올 때 쿼리문 추가 발생(N=5)

    Hibernate: select p1_0.id, p1_0.content, p1_0.title from post p1_0
    Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.id=?
    Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.id=?
    Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.id=?
    Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.id=?
    Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.id=?
  1. postRepository의 findAll() 메서드를 사용하여 1개의 Select 쿼리로 Post 목록 조회
  2. @OneToMany는 기본적으로 Fetch Type이 Lazy(지연) ⇒ 이미지 리스트 자리에 프록시 객체 생성
  3. Image에 있는 데이터를 조회할 때 엔티티 객체를 불러오기 위한 N개의 Select 쿼리가 추가적으로 발생

2. 발생하는 이유

객체와 RDB간 패러다임 차이

객체는 레퍼런스를 가지고 언제든지 연관된 객체에 접근할 수 있지만, RDB의 경우 SELECT 쿼리를 통해서만 조회할 수 있기 때문에 연관된 엔티티를 조회하려고 할 때 추가적으로 쿼리가 발생하게 된다.

fetch type

Q. 지연 로딩이 아닌 즉시 로딩을 사용하면 되는 것 아닌가요?

@OneToMany, @ManyToMany는 지연(Lazy) 로딩 이 기본

@ManyToOne, @OneToOne은 즉시(Eager) 로딩 이 기본

⇒ JPQL을 사용하는 시점에 N+1 문제 발생
+ 예상치 못한 쿼리 발생 우려가 있어서 실무에서 사용하지 않음,

3. 해결 방법

1) fetch join

특징

  • fetch join은 객체 그래프를 SQL 한번에 조회하는 개념
  • fetch join을 사용할 때만 연관된 엔티티도 함께 조회(즉시 로딩) ⇒ 글로벌 로딩 전략 무시

한계점

  • 둘 이상의 컬렉션은 fetch join 할 수 없다.
  • 컬렉션을 페치 조인하면 페이징 API(setFirstResult, setMaxResults)를 사용할 수 없다.
    ⇒ 일대다 데이터를 조회하고 중복을 제거하는 과정에서 데이터의 수가 변하기 때문
public interface PostRepository extends JpaRepository<Post, Long> {

    @Override
    @Query("select p from Post p join fetch p.images")
    List<Post> findAll();
}
Hibernate: 
    select
        p1_0.id,
        p1_0.content,
        i1_0.post_id,
        i1_0.id,
        i1_0.url,
        p1_0.title
    from
        post p1_0 
    join
        image i1_0 
            on p1_0.post_id=i1_0.post_post_id 

cf) 하이버네이트 6 이후 부터는 자동으로 중복 제거(distinct)

일반 join과 차이점

select p from Post p join p.images
  • JPQL은 결과를 반환할 때 연관관계 고려X ⇒ SELECT 절에 지정한 엔티티만 조회
  • 여기서는 Post 엔티티만 조회하고, Image 엔티티는 조회X

2) EntityGraph

fetch join을 편하게 사용하도록 도와주는 기능

public interface PostRepository extends JpaRepository<Post, Long> {

    @Override
    @EntityGraph(attributePaths = {"images"})
    List<Post> findAll();
}

기본적으로 inner join을 사용하는 fetch join과 다르게 EntityGraphs는 outer join을 사용하기 때문에 성능 이슈가 있을 수 있음

3) BatchSize

  • 연관된 엔티티 조회시 지정한 size 만큼 SQL의 in 절을 사용
  • 즉시 로딩을 사용하면 최초에 JPQL 쿼리를 사용할 때, 지연 로딩으로 실행하면 지연 로딩된 엔티티를 최초 접근하는 시점에 size에 설정된 값만큼 in 절을 사용해서 조회한다.
  • 10개를 조회하는데, @BatchSize(5)이라면 JPA는 쿼리를 2번 (10/5 = 2개) 날린다.
@BatchSize(size = 5)
@Entity
public class Image {
    ...
}

@Entity
public class Post {

    @BatchSize(size = 5)
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Image> images = new ArrayList<>();
}
Hibernate: select p1_0.id, p1_0.content, p1_0.title from post p1_0
Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.post_id in (?, ?, ?, ?, ?)
Hibernate: select i1_0.id, i1_0.post_id, i1_0.url from image i1_0 where i1_0.post_id in (?, ?, ?, ?, ?)

전역적으로 Batch Size 설정하는 방법

// application.yml

jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

레퍼런스

https://www.inflearn.com/course/ORM-JPA-Basic
https://inma.tistory.com/165
https://ttl-blog.tistory.com/1135
https://velog.io/@xogml951/JPA-N1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0-%EC%B4%9D%EC%A0%95%EB%A6%AC

0개의 댓글