프로젝트/LinkBook

Batch Fetch Size를 통해 페이징 문제 해결

걸어가는 신사 2022. 8. 20. 04:59

1. 문제 발생

Folder Entity

@Entity
@Getter
@Builder(toBuilder = true)
@Table(name = "folder")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Folder extends BaseDateEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Size(max = 50, message = "폴더의 이름은 50자 이하로 입력해주세요")
    @Column(name = "title", nullable = false, columnDefinition = "varchar(50)")
    private String title;

    @Column(name = "image", nullable = false, columnDefinition = "varchar(1000)")
    private String image;

    @Column(name = "content", nullable = false, columnDefinition = "varchar(10000)")
    private String content;

    @Column(name = "origin_id", nullable = true)
    private Long originId;

    @Column(name = "is_pinned", nullable = false)
    private Boolean isPinned;

    @Column(name = "is_private", nullable = false)
    private Boolean isPrivate;

    @Column(name = "likes", nullable = false)
    private int likes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "users_id", referencedColumnName = "id")
    private User user;

    @OneToMany(mappedBy = "folder", cascade = CascadeType.REMOVE)
    private List<Bookmark> bookmarks = new ArrayList<>();

    @OneToMany(mappedBy = "folder", cascade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();

    @OneToMany(mappedBy = "folder", cascade = CascadeType.REMOVE)
    private List<FolderTag> folderTags = new ArrayList<>();

}

문제 발생 쿼리

@Query(value = "SELECT DISTINCT f FROM Folder f JOIN FETCH f.user JOIN FETCH f.bookmarks LEFT",
    countQuery = "SELECT count(f) FROM Folder f")
Page<Folder> findAll(Pageable pageable);
  • N+1 문제를 해결하기 위해 컬렉션 페치 조인을 활용하였다.
WARN 22948 --- [ Test worker] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • 경고 로그가 발생하였다.

 

2. 문제 분석

  • 컬렉션을 페치 조인하면 페이징이 불가능하다.
    • 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
    • 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생성된다.
    • Folder를 기준으로 페이징 하고 싶은데, 다(N) Bookmark, folderTag 기준이 되어버린다.
WARN 22948 --- [ Test worker] o.h.h.internal.ast.QueryTranslatorImpl : HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
  • 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다.
    • 즉, Limit를 사용하지 않고 조회된 결과를 모든 Java Memory에 올려놓고 Pagination을 위한 계산을 시도한다.
    • 데이터가 별로 없으면 정상적으로 동작하는 것 처럼 보일 수 있다. 하지만 데이터의 크기가 커지면 memory leak이 발생한다.

 

3. 문제 해결

  • ToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인한다.
    • ToOne 관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size, @BatchSize를 적용한다.
    • hibernate.default_batch_fetch_size : 글로벌 설정
    • @BatchSize : 개별 최적화
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.

(1) Yaml 파일 수정

  • 글로벌 설정을 통해 N+1 문제를 해결하기 위해 Yaml 파일을 수정해 주었습니다.
spring:
  datasource:
    url: jdbc:h2:mem:test
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    generate-ddl: true
    open-in-view: false
    show-sql: true
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        default_batch_fetch_size: 1000
        format_sql: true
        dialect: org.hibernate.dialect.H2Dialect

(2) Query, 로직 수정

@Query(value = "SELECT DISTINCT f FROM Folder f JOIN FETCH f.user",
    countQuery = "SELECT count(f) FROM Folder f")
Page<Folder> findAll(Pageable pageable);
  • ToOne(OneToOne, ManyToOne) 관계는 페치 조인해주었습니다.
public FolderListResponse getAll(Pageable pageable) {
    Page<Folder> folders = folderRepository.findAll(false, pageable);

    return FolderListResponse.fromEntity(folders);
}
  • 이후 지연 로딩을 통해 bookmarks, FolderTags 등을 가져왔습니다.

(3) 결과

  • 이후 Service Layer에서 컬렉션들을 지연 로딩으로 조회한다.

  • where 절을 보게 되면 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
  • default_batch_fetch_size 개수만큼 모일 때까지 쌓아두었다가, 해당 개수가 다 모이면 쿼리를 보낸다.

 

4. 정리

장점

  • 쿼리 호출 수가 1+N → 1 + 1로 최적화된다.
  • 조인보다 DB 데이터 전송량이 최적화된다.
    • Folder와 Bookmark 조인 시에 Folder가 Bookmark만큼 중복해서 조회된다.
  • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.

default_batch_fetch_size 주의

  • default_batch_fetch_size의 크기는 적당한 사이즈
    • 100~1000 사이를 선택하는 것이 좋다.
    • 이 전략을 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 한다.
  • 1000으로 잡으면 한 번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.

 

Reference

https://velog.io/@jinyoungchoi95/JPA-모든-N1-발생-케이스과-해결책

https://soon-devblog.tistory.com/40

반응형