문제 상황
엔티티 연관관계
- Feed는 FeedImage와 일대다 연관관계를 가진다.
- Feed는 member와 다대일 연관관계를 가진다.
- FeedImage와 Member는 모두 지연 로딩 설정되어있다.
@Entity
@SuperBuilder
@Getter
@NoArgsConstructor
public class Feed extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Lob
private String content;
private int hits;
private int commentCount;
@Convert(converter = FeedStatusConvertor.class)
private FeedStatus status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@Builder.Default
@OneToMany(mappedBy = "feed")
private List<FeedImage> feedImages = new ArrayList<>();
...
}
Feed 조회 메서드
- FeedRepositoryImpl.searchByCreateBy(), FeedRepositoryImpl.searchBySearchText()는 FeedImage, Member를 페치 조인하고 페이징한 데이터를 조회하는 메서드이다.
@RequiredArgsConstructor
public class FeedRepositoryImpl implements FeedRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
private final AlbumUtil albumUtil;
@Override
public Page<Feed> searchByCreatedBy(Collection<FeedStatus> status,
Collection<String> createdBy, Pageable pageable) {
List<Feed> content = jpaQueryFactory
.select(feed).distinct()
.from(feed)
.leftJoin(feed.feedImages, feedImage).fetchJoin()
.leftJoin(feed.member, member).fetchJoin()
.where(searchByCreatedByCondition(status, createdBy))
.orderBy(albumUtil.getOrderSpecifier(pageable.getSort(), feed))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = jpaQueryFactory
.select(feed.count())
.from(feed)
.where(searchByCreatedByCondition(status, createdBy));
return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
}
@Override
public Page<Feed> searchBySearchText(Collection<FeedStatus> status, String searchText,
Pageable pageable) {
List<Feed> content = jpaQueryFactory
.select(feed).distinct()
.from(feed)
.leftJoin(feed.feedImages, feedImage).fetchJoin()
.leftJoin(feed.member, member).fetchJoin()
.where(searchBySearchTextCondition(status, searchText))
.orderBy(albumUtil.getOrderSpecifier(pageable.getSort(), feed))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = jpaQueryFactory
.select(feed.count())
.from(feed)
.where(searchBySearchTextCondition(status, searchText));
return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
}
...
}
문제점
- 컬렉션인 FeedImage를 페치 조인하고, 페이징을 시도함으로써 문제가 발생했다.
- 컬렉션 페치 조인을 사용할 경우, JPA 및 QueryDsl의 페이징 관련 명령어가 DB Sql에 반영되지 않는다.
- 컬렉션 페치 조인이 DB Sql에 반영될 경우, 기준 테이블(Feed)이 아닌 컬렉션 페치 조인 테이블(FeedImage)을 기준으로 row가 생성되기 때문이다.
- DB에서 페이징을 할 수 없기 때문에 DB로부터 모든 데이터를 읽어오고, 어플리케이션 차원에서 페이징을 수행한다. 이 때 매우 많은 데이터가 한 번에 어플리케이션으로 전달되면 예상치 못한 장애가 발생할 수 있다.
- firstResult/maxResults specified with collection fetch; applying in memory!
해결 방안
- hibernate.default_batch_fetch_size를 통해 문제를 해결하고자 한다.
- hibernate.default_batch_fetch_size는 설정한 숫자 만큼 기준 엔티티 식별자를 where in 쿼리의 값으로 사용하여, 지연 로딩될 엔티티를 한 번에 조회하는 기능이다.
hibernate.default_batch_fetch_size
- hibernate.default_batch_fetch_size 기능을 활성화 하였다.
- fetch size는 일반적으로 100~1000 사이의 값을 사용한다. Album 프로젝트의 경우 최대 페이징 사이즈가 100으로 설정되어 있기 때문에 fetch size도 100으로 설정하였다.
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
default_batch_fetch_size: 100
Feed 조회 메서드 수정
FeedRepositoryImpl.searchByCreateBy(), FeedRepository.searchBySearchText()의 메서드를 아래와 같이 수정하였다.
- 컬렉션이 아닌 Member에 대해서는 페치 조인을 그대로 유지한다. 컬렉션이 아닌 엔티티 대한 fetch join은 DB 차원의 페이징에 문제를 유발하지 않기 때문이다.
- 컬렉션인 FeedImage에 대한 조인(페치 조인)을 제외한다. 이제 JPA 및 QueryDsl의 페이징 명령어는 DB SQL에 반영될 것이다.
- 컬렉션 페치 조인으로 인한 중복 데이터가 생성되지 않을 것이기 때문에 distinct를 제거한다.
@RequiredArgsConstructor
public class FeedRepositoryImpl implements FeedRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
private final AlbumUtil albumUtil;
@Override
public Page<Feed> searchByCreatedBy(Collection<FeedStatus> status,
Collection<String> createdBy, Pageable pageable) {
List<Feed> content = jpaQueryFactory
.select(feed)
.from(feed)
.leftJoin(feed.member, member).fetchJoin()
.where(searchByCreatedByCondition(status, createdBy))
.orderBy(albumUtil.getOrderSpecifier(pageable.getSort(), feed))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = jpaQueryFactory
.select(feed.count())
.from(feed)
.where(searchByCreatedByCondition(status, createdBy));
return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
}
@Override
public Page<Feed> searchBySearchText(Collection<FeedStatus> status, String searchText,
Pageable pageable) {
List<Feed> content = jpaQueryFactory
.select(feed)
.from(feed)
.leftJoin(feed.member, member).fetchJoin()
.where(searchBySearchTextCondition(status, searchText))
.orderBy(albumUtil.getOrderSpecifier(pageable.getSort(), feed))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
JPAQuery<Long> count = jpaQueryFactory
.select(feed.count())
.from(feed)
.where(searchBySearchTextCondition(status, searchText));
return PageableExecutionUtils.getPage(content, pageable, count::fetchOne);
}
...
}
결과
- 컬렉션 페치 조인을 제외하여 DB 차원에서 페이징이 가능해졌다.
- where in 쿼리를 통해 컬렉션 데이터를 조회하기 위한 쿼리 호출 수를 최적화 할 수 있었다. 즉 페치 조인을 사용하지 않았음에도 N+1 문제는 방지할 수 있었다.
- 아래는 컬렉션 페치 조인의 페이징 문제 해결 후, FeedRepositoryImpl.SearchByCreatedBy()의 SQL 호출 과정이다.
// Feed 조회(Member 페치 조인)
Hibernate:
select
feed0_.id as id1_2_0_,
member1_.id as id1_6_1_,
feed0_.created_at as created_2_2_0_,
feed0_.modified_at as modified3_2_0_,
feed0_.modified_by as modified4_2_0_,
feed0_.comment_count as comment_5_2_0_,
feed0_.content as content6_2_0_,
feed0_.hits as hits7_2_0_,
feed0_.member_id as member_10_2_0_,
feed0_.status as status8_2_0_,
feed0_.title as title9_2_0_,
member1_.created_at as created_2_6_1_,
member1_.modified_at as modified3_6_1_,
member1_.modified_by as modified4_6_1_,
member1_.birth_date as birth_da5_6_1_,
member1_.image_original_name as image_or6_6_1_,
member1_.image_path as image_pa7_6_1_,
member1_.image_store_name as image_st8_6_1_,
member1_.login_type as login_ty9_6_1_,
member1_.nickname as nicknam10_6_1_,
member1_.password as passwor11_6_1_,
member1_.phone as phone12_6_1_,
member1_.role as role13_6_1_,
member1_.status as status14_6_1_,
member1_.username as usernam15_6_1_
from
feed feed0_
left outer join
member member1_
on feed0_.member_id=member1_.id
where
feed0_.status in (
? , ?
)
order by
feed0_.hits desc limit ?
// 페이징을 위한 count 쿼리
Hibernate:
select
count(feed0_.id) as col_0_0_
from
feed feed0_
where
feed0_.status in (
? , ?
)
// where in 쿼리를 통해 컬렉션 데이터(FeedImage)를 한번에 조회
Hibernate:
select
feedimages0_.feed_id as feed_id8_4_1_,
feedimages0_.id as id1_4_1_,
feedimages0_.id as id1_4_0_,
feedimages0_.created_at as created_2_4_0_,
feedimages0_.modified_at as modified3_4_0_,
feedimages0_.modified_by as modified4_4_0_,
feedimages0_.feed_id as feed_id8_4_0_,
feedimages0_.image_original_name as image_or5_4_0_,
feedimages0_.image_path as image_pa6_4_0_,
feedimages0_.image_store_name as image_st7_4_0_
from
feed_image feedimages0_
where
feedimages0_.feed_id in (
?, ?, ?
)