Search
Duplicate
📃

Pagination과 Fetch Join

글감
BE
Java
Spring
DB
작성자
작성 일자
2023/12/23 17:32
상태
완료
공개여부
공개
Date
생성자
작업자

밴 유저 조회 로직의 N+1 문제

밴 유저를 조회해오는 로직과 쿼리는 다음과 같다.
LocalDateTime now = LocalDateTime.now(); List<LentHistory> lentHistories = lentQueryService.findOverdueLentHistories(now, pageable); List<OverdueUserCabinetDto> result = lentHistories.stream() .map(lh -> cabinetMapper.toOverdueUserCabinetDto(lh, lh.getUser(), lh.getCabinet(), DateUtil.calculateTwoDateDiff(now, lh.getExpiredAt())) ).collect(Collectors.toList()); return cabinetMapper.toOverdueUserCabinetPaginationDto(result, (long) lentHistories.size());
Java
복사
해당 쿼리를 수행하면 위과 같이 동작하고,
그 과정에서 getUser와 getCabinet이 LAZY 로딩되어 이와 같은 2개의 추가 쿼리가 밴 유저의 수만큼 반복되어 발생한다(N+1 발생)

Fetch Join과 Pagination

N+1 문제를 해결하기 위해 Fetch Join을 적용하던 중 다음과 같은 에러가 발생했다.
에러 메세지를 잘 읽어보면 Count 쿼리 검증에 실패해 쿼리 생성을 하지 못했다고 적혀있다. 이는 Hibernate에서 Fetch Join을 사용하게 되면, Pagination에 필요한 count 쿼리를 만들지 못하기 때문에 발생하는 에러다.
조금 더 아래 쪽의 에러 메세지를 살펴보면, Fetch를 해오는 연관 관계가 select list에 없다고 나와있다.
디버깅을 통해 작성한 쿼리문으로 어떻게 CountQuery로 생성하는지 살펴보자.
SimpleJpqQuery.class 파일의 SimpleJpaQuery를 생성하는 쪽을 보면, query를 검증 후 Paginantion이 있으면 CountQuery를 만들어 검증한다.
작성한 쿼리를 String으로 받아와
JPA 내부적으로 이러한 Count 쿼리를 생성한다.
이후 생성된 Count 쿼리 String으로 QueryImpl을 생성한다. 그 과정에서 내부적으로 파서를 통해 구문을 분석하는데,
이런 로직을 통해 Select 구문에 있는 인자들을 selectExpressions에 담는다.
이후 selectExpressions를 순회하면서, select로 선택된 인자들 중 엔티티 형태로 반환해야 하는 것들을 fromElementsForLoad라는 컬렉션에 담는다.
그 후 이전의 파서에서 파싱한 쿼리에 존재하는 엔티티들을 담은 fromElements 컬렉션을 순회하며, From 절에 포함된 엔티티를 제외하고 나머지 엔티티들이 fromElementsForLoad 컬렉션에 해당 엔티티가 있는지 확인한다.
이 때 컬렉션에 엔티티가 없으면 아까 보았던 에러가 발생하게 되는데, 위에서 작성한 쿼리로 생긴 Count 쿼리의 Select 구문에는 count(lh) 밖에 없으니 해당 컬렉션에는 아무것도 들어있지 않다. 따라서 에러가 발생하는 것이다.
조금 더 부연 설명을 하자면, 위의 예시는
SELECT lh FROM LentHistory lh LEFT JOIN FETCH lh.user u LEFT JOIN FETCH lh.cabinet c LEFT JOIN c.cabinetPlace cp WHERE lh.expiredAt < :date AND lh.endedAt IS NULL
SQL
복사
이러한 쿼리를 받아서,
SELECT COUNT(lh) FROM LentHistory lh LEFT JOIN c.cabinetPlace cp WHERE lh.expiredAt < :date AND lh.endedAt IS NULL
SQL
복사
JOIN 다 떼고 위와 같은 count 쿼리를 만들어 낸다.
1.
select, from, join, where 절에 있는 엔티티들을 fromElements에 담기
→ fromElements : [LentHistory, User, Cabinet, CabinetPlace]
2.
만들어낸 count 쿼리의 select, from, where 절에 있는 엔티티들을 fromElementForLoad 컬렉션에 담기
→ fromElementForLoad : [LentHistory]
3.
fromElements를 순회하면서 fromElementForLoad 컬렉션에 포함되는지 확인
→ LentHistory : 포함하니까 통과
→ User : 포함하지 않으니 실패
→ 에러 발생
이후 만들어낸 count 쿼리를 위와 같은 순서로 검증하는 절차를 거치게 된다.
쉽게 요약하자면, Hibernate는 count 쿼리를 자동으로 생성하는 과정에서 Select 구문이나 Where 조건에 포함되지 않은 엔티티들을 Fetch Join으로 같이 가져오려고하면 count 쿼리 자동 생성에 실패하여 에러가 발생한다.
이러한 이유로 위와 같이 @Query 애노테이션에서 count에 해당하는 쿼리를 작성하여 넣어주면 해결된다.
수정 이후 발생하는 쿼리를 확인해보면, 위와 같이 한 번에 조회해오고 이후 추가로 쿼리가 발생하지 않는 것을 볼 수 있다.

추가적인 확인 사항

위의 Fetch Join과 Pagination을 같이 쓰면 에러가 발생하는 이유를 확인하고 보니, select 안에 있기만 하면 자동으로 count 쿼리가 생성이 되나?라는 의문이 들었다.
이와 같이 LentHistory와 User를 동시에 select 하면 괜찮을 것이라 예상하고 컴파일 했으나 에러가 발생한다.
그 이유는 Left Join으로 결합하기 때문에 생성되는 count 쿼리가 user는 빼고 count하기 때문이다.
그럼 Where 조건에 넣으면 자동 생성되나?에 대한 의문도 들었지만, JPA에서 Fetch Join 해오는 엔티티를 별칭을 달아 Where 조건에 넣으면 데이터의 무결성을 해칠 수 있다. 그렇기 때문에 주체와 Join 엔티티를 바꾸어 조회하거나, IN절 혹은 Batch_Size를 통해 N+1 문제를 해결하는 것이 좋다. 때문에 이에 대한 테스트는 별도로 수행하지 않았다.

결과

최적화나 리팩토링 이후에는 실질적으로 속도가 얼마나 줄었는지 확인해볼 필요가 있다. 이를 위해 최적화 이전과 이후의 속도를 비교해보자.
N+1 문제가 발생하는 쿼리의 응답 시간은 100회 평균 19ms 정도 걸린다.
최적화 이후에는 쿼리의 응답 시간이 100회 평균 13ms 정도로 감소하였다.
결과를 놓고 비교하면 19ms13ms로 개선은 이루어졌지만, 발생하는 쿼리 개수에 비해 드라마틱하게 줄어들지 않았다. 그 이유를 고민해본 결과 다음의 몇 가지를 들 수 있을 것 같다.
N+1 문제가 발생하는 조회가 Cabinet, User 조회 시에 유니크 인덱스 스캔으로 굉장히 빠른 조회 방식으로 수행
로컬 환경으로 인해 DB와 Server 간 통신의 네트워크 성능적 부하가 크지 않음
하지만 그럼에도 최적화가 의미가 있는 이유는, Cabinet과 User를 반복해서 조회하는 것으로 인한 DB 커넥션을 획득과 반납이 반복되는 것을 피할 수 있기 때문이다. DB 커넥션을 자주 획득하면 이로 인해 병목 현상의 원인이 될 수 있다.
또한, 프로덕션 서버의 경우에는 DB는 RDS에 두고 서버는 EC2에 올려두었기 때문에 DB와 서버 간의 네트워크 부하가 로컬 환경보다 클 것으로 예상된다. 이로 인해 위 로직의 수행 결과 차이는 더 클 것이라 예측할 수 있다.

참고