단순 조회 API에서 예상치 못한 높은 지연 시간이 발생했다. 초기에는 쿼리 성능 문제로 의심했으나, 실제 로그 분석 결과 Hibernate의 페이징 처리 방식에서 문제가 발견되었다.
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
이 경고 로그는 Hibernate가 페이징 처리를 메모리에서 수행하고 있다는 것을 나타낸다.
Collection fetch join을 사용하면서 동시에 페이징을 적용할 때 Hibernate가 DB에서 모든 데이터를 가져온 후 메모리에서 페이징을 수행한다고 한다.
원본 코드
JPAQuery<User> query = queryFactory.selectFrom(user)
.leftJoin(user.files, userFile).fetchJoin()
.leftJoin(user.userInflow, userInflow).fetchJoin()
.leftJoin(user.personalities, userPersonality).fetchJoin()
.leftJoin(user.interests, userInterest).fetchJoin()
.leftJoin(user.managerUserEvaluation, managerUserEvaluation).fetchJoin()
.where(booleanBuilder)
.orderBy(user.activatedAt.desc());
int total = query.fetch().size();
List<User> results = query.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
return new PageImpl<>(results, pageable, total);
문제의 원본 코드이다.
- OneToMany 관계(files, personalities, interests)에 대한 fetch join 사용
- 동일한 쿼리로 전체 카운트와 결과를 조회
- 페이징 처리가 메모리에서 발생
개선된 코드
JPAQuery<User> query = queryFactory
.selectFrom(user)
.leftJoin(user.userInflow, userInflow).fetchJoin()
.leftJoin(user.managerUserEvaluation, managerUserEvaluation).fetchJoin()
.where(booleanBuilder)
.orderBy(user.activatedAt.desc());
JPAQuery<Long> countQuery = queryFactory
.select(user.count())
.from(user)
.where(booleanBuilder);
List<User> results = query
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long totalCount = countQuery.fetchOne();
return new PageImpl<>(results, pageable, totalCount != null ? totalCount : 0L);
아래와 같은 방식으로 해당 문제를 해결할 수 있었다.
- OneToMany 관계에 대한 fetch join 제거
- 카운트 쿼리 분리
- 데이터베이스 수준에서 페이징이 동작하도록 수정
성능 개선 전후 비교
- 응답 시간이 초 단위에서 밀리초 단위로 크게 개선되었고, 에러도 완전히 해소되었다.