UMC-GREENY / greeny-backend

UMC GREENY project API server
3 stars 1 forks source link

Collection fetch join with pagination #26

Open dtd1614 opened 1 year ago

dtd1614 commented 1 year ago

Post 목록 불러오기 기능은 페이징 처리와 함께 각 Post마다 PostFile의 존재 여부를 표시해야 합니다. 그리고 해당 Post를 작성한 Member의 이메일도 표시해야 합니다.

그런데 Post 목록을 모두 불러와서 하나 하나 PostFile 존재 여부와 Member의 이메일을 조회한다면, 하나의 Post마다 2개의 쿼리가 추가로 발생할 것입니다. Post가 10개가 있으면 PostFile을 조회하는 10개의 쿼리와 Member를 조회하는 10개의 쿼리가 발생하여 총 20개의 쿼리가 추가로 발생하는 것입니다.

그래서 fetch join을 적용하여 쿼리가 한번만 나가도록 하였습니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Entity
@Builder
public class Post extends AuditEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "writer_id", nullable = false)
    private Member writer;
    @OneToMany(mappedBy = "post", cascade = ALL, orphanRemoval = true)
    @Builder.Default
    private List<PostFile> postFiles = new ArrayList<>();

Post와 Member는 다대일 관계고, Post와 PostFile은 일대다 관계입니다. 둘 다 lazy 로딩으로 설정되어 있습니다.

public interface PostRepository extends JpaRepository<Post, Long> {

  @EntityGraph(attributePaths = {"writer", "postFiles"})
  @NotNull Page<Post> findAll(@NotNull Pageable pageable);

EntityGraph를 사용해 findAll할 때 Member와 PostFile을 fetch join하도록 설정하였습니다. 게시글 목록을 불러올 때 작성자의 이메일도 표시해야 하기 때문에 Member도 fetch join 하도록 하였습니다.

    @Test
    @Transactional
    void findTest() {
        Pageable pageable = PageRequest.of(0, 3); // page = 0 , size = 3

        // 페이징하여 Post 목록 불러오기
        System.out.println("=================================== find all ==========================================");
        Page<Post> posts = postRepository.findAll(pageable);

        // 각 Post마다 PostFile 존재 여부 확인
        System.out.println("=================================== find postfile ==========================================");
        for(Post post : posts){
            System.out.println(!post.getPostFiles().isEmpty());
        }

        // 각 Post마다 Member의 이메일 조회
        System.out.println("=================================== find member ==========================================");
        for(Post post : posts){
            System.out.println(post.getWriter().getEmail());
        }
    }

다음과 같은 테스트 코드를 통해서 fetch join이 잘 되는지 확인해보겠습니다. DB에는 20개의 Post가 저장되어 있는 상태입니다.

=================================== find all ==========================================
2023-07-30 02:28:23.640  WARN 15164 --- [           main] o.h.h.internal.ast.QueryTranslatorImpl   : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate: 
    select
        post0_.post_id as post_id1_6_0_,
        postfiles1_.post_file_id as post_fil1_7_1_,
        member2_.member_id as member_i1_1_2_,
        post0_.created_at as created_2_6_0_,
        post0_.updated_at as updated_3_6_0_,
        post0_.content as content4_6_0_,
        post0_.hits as hits5_6_0_,
        post0_.title as title6_6_0_,
        post0_.writer_id as writer_i7_6_0_,
        postfiles1_.created_at as created_2_7_1_,
        postfiles1_.updated_at as updated_3_7_1_,
        postfiles1_.file_url as file_url4_7_1_,
        postfiles1_.post_id as post_id5_7_1_,
        postfiles1_.post_id as post_id5_7_0__,
        postfiles1_.post_file_id as post_fil1_7_0__,
        member2_.created_at as created_2_1_2_,
        member2_.updated_at as updated_3_1_2_,
        member2_.email as email4_1_2_,
        member2_.role as role5_1_2_ 
    from
        post post0_ 
    left outer join
        post_file postfiles1_ 
            on post0_.post_id=postfiles1_.post_id 
    left outer join
        member member2_ 
            on post0_.writer_id=member2_.member_id
Hibernate: 
    select
        count(post0_.post_id) as col_0_0_ 
    from
        post post0_
=================================== find postfile ==========================================
false
false
false
=================================== find member ==========================================
user@example.com
user@example.com
user@example.com

fetch join이 정상적으로 잘되어 PostFile이나 Member를 조회할 때 추가 쿼리가 발생하지 않는 것을 알 수 있습니다. 마지막 쿼리는 페이징을 위한 count 쿼리입니다.

그런데 맨 위에 HHH000104 경고가 떴습니다. 찾아보니 컬렉션 fetch join을 할 때, 데이터를 모두 가져와서 인메모리에 저장하고, 그 다음에 페이징 처리를 수행하기 때문에 발생하는 경고였습니다. 첫번째 쿼리를 보면 limit이 설정되어 있지 않습니다. 페이징 처리를 안하고 데이터를 모두 가져왔다는 의미입니다. 컬렉션 join을 하면 일대다에서 다를 기준으로 row가 생성되기 때문에 데이터가 예측할 수 없이 증가합니다. 그런데 그 데이터를 페이징하면 다를 기준으로 페이징이 되기 때문에 제대로 페이징이 될 수 없습니다. 그래서 일단 페이징을 하지 않고 모든 데이터를 메모리에 올린 다음 페이징을 시도합니다. 그런데 이 데이터의 크기가 크면 out of memory가 발생할 수 있습니다.

참고 : https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85#1-pagination https://choiyeonho903.tistory.com/155

참고 자료를 통해 컬렉션은 fetch join을 하지 않고 lazy 로딩으로 설정하되, batch fetch size를 설정하여 해결할 수 있다는 사실을 알았습니다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @EntityGraph(attributePaths = {"writer"})
    @NotNull Page<Post> findAll(@NotNull Pageable pageable);

fetch join에 컬렉션인 PostFile은 적용하지 않습니다. 이렇게 해서 PostFile은 lazy 로딩으로 불러옵니다.

  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 

yml 파일에 다음과 같이 batch fetch size를 설정합니다. 이렇게 하면 lazy로딩인 컬렉션을 조회할 때 IN 쿼리로 100개씩 한번에 조회하게 됩니다.

이제 다시 테스트 코드를 실행시키고 쿼리를 확인해보겠습니다.

=================================== find all ==========================================
Hibernate: 
    select
        post0_.post_id as post_id1_6_0_,
        member1_.member_id as member_i1_1_1_,
        post0_.created_at as created_2_6_0_,
        post0_.updated_at as updated_3_6_0_,
        post0_.content as content4_6_0_,
        post0_.hits as hits5_6_0_,
        post0_.title as title6_6_0_,
        post0_.writer_id as writer_i7_6_0_,
        member1_.created_at as created_2_1_1_,
        member1_.updated_at as updated_3_1_1_,
        member1_.email as email4_1_1_,
        member1_.role as role5_1_1_ 
    from
        post post0_ 
    left outer join
        member member1_ 
            on post0_.writer_id=member1_.member_id limit ?
Hibernate: 
    select
        count(post0_.post_id) as col_0_0_ 
    from
        post post0_
=================================== find postfile ==========================================
Hibernate: 
    select
        postfiles0_.post_id as post_id5_7_1_,
        postfiles0_.post_file_id as post_fil1_7_1_,
        postfiles0_.post_file_id as post_fil1_7_0_,
        postfiles0_.created_at as created_2_7_0_,
        postfiles0_.updated_at as updated_3_7_0_,
        postfiles0_.file_url as file_url4_7_0_,
        postfiles0_.post_id as post_id5_7_0_ 
    from
        post_file postfiles0_ 
    where
        postfiles0_.post_id in (
            ?, ?, ?
        )
false
false
false
=================================== find member ==========================================
user@example.com
user@example.com
user@example.com

인메모리로 저장한다는 경고는 뜨지 않습니다. 첫번째 쿼리문을 보면 limit이 설정되어 모든 데이터를 불러오지 않고 페이징을 적용하여 데이터를 불러온 것을 볼 수 있습니다. 그리고 두번째 쿼리문을 보면 IN 쿼리를 통해 한번에 PostFile을 조회하는 것을 볼 수 있습니다. 설정한 batch fetch size보다 Post가 많으면 쿼리문이 더 발생할 것입니다.

fetch join보다는 쿼리문이 더 나가긴 하지만, out of memory의 위험성을 줄일 수 있는 해결방안이었습니다.

참고하셔 나중에 페이징과 컬렉션 fetch join을 같이 해야 할 경우가 있다면 도움이 되셨으면 좋겠습니다. 더 좋은 방법이 있다면 알려주세요.

참고 : https://velog.io/@jinyoungchoi95/JPA-%EB%AA%A8%EB%93%A0-N1-%EB%B0%9C%EC%83%9D-%EC%BC%80%EC%9D%B4%EC%8A%A4%EA%B3%BC-%ED%95%B4%EA%B2%B0%EC%B1%85#1-pagination https://choiyeonho903.tistory.com/155

Minuooooo commented 1 year ago

정리를 해보자면, @EntityGraph를 활용한 fetch join을 사용할 수 있는 경우는 반환 형태가 단일, List 등이어야 합니다.

하지만, Page를 반환할 경우에 조심해야 할 점이 컬렉션 fetch join과 함께 사용되면 컬렉션을 기준으로 페이징을 하는 것을 방지하기 위해 스프링에서 쿼리에 limit을 포함시키지 않고 메모리 상에 리스트 전체를 올려버리고 애플리케이션 level에서 페이징을 진행합니다.

이로써 저희는 성능을 위한 우선 순위를 정할 수 있게 되었습니다.

  1. 컬렉션 fetch join 대신 default_batch_fetch_size나 @BatchSize를 지정해준 뒤 컬렉션은 Lazy 로딩으로 가져옵니다. (@EntityGraph에 컬렉션은 명시하지 x)
  2. 단일 fetch join을 사용합니다.

더 좋은 의견이 있으시다면 코멘트 달아주세욥