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
반응형
'프로젝트 > LinkBook' 카테고리의 다른 글
RefreshToken 도입 (0) | 2022.08.23 |
---|---|
AccessToken 로그인 로직 (feat. Spring Security) (0) | 2022.08.23 |
토큰(JWT)기반 인증 도입 (0) | 2022.08.23 |
회원가입 이메일 인증 구현 (0) | 2022.08.21 |
계층형 Category 구현 (Enum으로의 전환) (0) | 2022.08.19 |
댓글