JPA 컬렉션 Fetch Join과 메모리 페이징 문제
N+1 쿼리를 줄이려고 연관 객체를 함께 조회했더니 다른 문제가 생겼습니다. 쿼리에서 페이지를 자르는 대신 메모리에서 페이징한다는 경고가 나왔습니다. 당시에는 단일 연관관계만 함께 조회하고 컬렉션에는 배치 조회를 적용하는 방향으로 바꿨습니다.
이벤트와 태그의 관계

event와 event_type은 N:1 관계입니다. event와 hash_tag는 N:M 관계여서 중간에 event_hash_tag 테이블을 두었습니다.
이벤트를 페이징해 가져오는 저장소 메서드는 다음과 같았습니다.
public interface EventRepository extends JpaRepository<Event, Long> {
Page<Event> findAll(Pageable pageable);
}가져온 엔티티는 DTO로 변환했습니다.
public record EventDto(
Long id,
String eventTitle,
Integer price,
Integer availableSlot,
EventTypeDto eventType,
List<HashTagDto> hashTags
) {
public static EventDto from(Event event) {
return new EventDto(
event.getId(),
event.getTitle(),
event.getPrice(),
event.getAvailableSlot(),
EventTypeDto.from(event.getEventType()),
event.getHashTags().stream()
.map(HashTagDto::from)
.toList()
);
}
}이 과정에서 연관 객체에 접근하며 추가 쿼리가 발생했습니다.
Hibernate:
select
e1_0.id,
e1_0.available_slot,
e1_0.created_at,
e1_0.event_type_id,
e1_0.price,
e1_0.title,
e1_0.updated_at
from
event e1_0
limit
?, ?
//...
Hibernate:
select
eht1_0.event_id,
eht1_0.id,
eht1_0.created_at,
ht1_0.id,
ht1_0.created_at,
ht1_0.tag_name,
ht1_0.updated_at,
eht1_0.updated_at
from
event_hash_tag eht1_0
left join
hash_tag ht1_0
on ht1_0.id=eht1_0.hash_tag_id
where
eht1_0.event_id=?위 로그에서는 일부를 생략했습니다. 당시에는 이벤트 9개를 가져오는데 쿼리가 약 50개 나갔습니다.
모두 함께 가져오면 해결될 줄 알았습니다
그렇다면 태그와 이벤트 종류를 모두 함께 가져오면 되는 것 아닐까요? Fetch Join을 생각하고 연관 객체를 미리 조회하도록 @EntityGraph를 설정했습니다.
@EntityGraph(attributePaths = {"eventHashTags", "eventHashTags.hashTag", "eventType"})
Page<Event> findAll(Pageable pageable);실제 로그에서는 다음과 같이 left join으로 조회했습니다.
EntityGraph 자체가 항상 특정 SQL fetch join을 뜻하지는 않습니다. 여기서는 로그에 컬렉션 조인이 나타났습니다.
Hibernate:
select
e1_0.id,
e1_0.available_slot,
e1_0.created_at,
eht1_0.event_id,
eht1_0.id,
eht1_0.created_at,
ht1_0.id,
ht1_0.created_at,
ht1_0.tag_name,
ht1_0.updated_at,
eht1_0.updated_at,
et1_0.id,
et1_0.created_at,
et1_0.event_type_name,
et1_0.updated_at,
e1_0.price,
e1_0.title,
e1_0.updated_at
from
event e1_0
left join
event_hash_tag eht1_0
on e1_0.id=eht1_0.event_id
left join
hash_tag ht1_0
on ht1_0.id=eht1_0.hash_tag_id
left join
event_type et1_0
on et1_0.id=e1_0.event_type_id
Hibernate:
select
count(e1_0.id)
from
event e1_0추가 조회는 줄었지만 다음 경고가 나왔습니다.
2024-11-10T19:17:25.487+09:00 WARN 48987 — [nio-8080-exec-2] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
조인 결과의 행 수가 이벤트 수와 달랐습니다
당시 참고한 Fetch Join과 페이징 글을 읽고 원인을 확인했습니다. 이벤트 하나에 태그가 여러 개 있으면 컬렉션 조인 결과에서 이벤트 한 개가 여러 행으로 나타납니다. 이 행에 그대로 limit을 적용하면 이벤트 단위의 페이지와 일치하지 않고 컬렉션도 일부만 가져올 수 있습니다.
이 경우 Hibernate는 조회 결과를 메모리에서 페이징했습니다. 위 로그에서도 이벤트 조회 SQL에 limit이 없습니다.
컬렉션 fetch와 페이징의 주의점은 Hibernate HQL 문서에 설명되어 있습니다.
단일 연관관계와 컬렉션을 나눴습니다
eventHashTags 컬렉션에는 @BatchSize를 설정했습니다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "event", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true)
private List<EventHashTag> eventHashTags = new ArrayList<>();@EntityGraph에는 N:1 관계인 eventType만 남겼습니다.
@EntityGraph(attributePaths = {"eventType"})
Page<Event> findAll(Pageable pageable);이벤트 페이지는 먼저 조회합니다. 컬렉션이 필요할 때는 여러 이벤트의 컬렉션을 묶어 조회하도록 바꿨습니다. 추가 쿼리는 남아 있습니다. 여러 건을 묶어서 조회할 뿐이므로 모든 추가 조회가 사라졌다고 생각하면 안 됩니다. size = 100은 배치 크기 설정이며 매번 정확히 100개를 조회한다는 뜻은 아닙니다.