요즘 과거 프로젝트들을 하나씩 리팩토링하고 있습니다.
그중 1년 전쯤 개발했던 이력서 기반 AI 면접 및 화상 면접 서비스 ‘Interview Partner’를 다시 살펴보던 중, 배포된 운영 서버 로그에서 아래와 같은 Hibernate 경고 메시지를 발견했습니다.
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
단순한 페이징 API 호출이었는데도, 쿼리에 LIMIT, OFFSET이 적용되지 않고 전체 데이터를 메모리에 올려 처리한다는 Hibernate 경고가 출력됐습니다. 코드를 다시 보니, 그때의 저는 “N+1 문제는 피해야 하니까” 정도의 인식만으로, 제대로 학습하지 않은 채 단순히 @EntityGraph를 적용해 문제를 해결했다고 여겼습니다.
위 사진은 당시 적용했던 Repository 메서드입니다.
하지만 결과적으로 페이징은 제대로 동작하지 않았고, 모든 데이터를 메모리에 불러온 뒤 애플리케이션 레벨에서 잘라내는 비효율적인 방식으로 처리되고 있었습니다.
이는 성능 저하로 이어지고, 데이터가 많아질 경우 OOM(Out Of Memory)과 같은 심각한 문제로도 발전할 수 있습니다.
1년 전 작성한 코드를 보며, ‘그때의 선택이 정말 최선이었을까’라는 생각이 들었고, 부족했던 점에 부끄러움을 느꼈습니다.
이번 기회에 관련 내용을 제대로 학습하고, 문제가 되었던 부분을 리팩토링하게 되었습니다.
room과 tag는 다대다(N:M) 관계이며,
관계형 데이터베이스에서는 정규화된 두 테이블만으로는 다대다 관계를 표현할 수 없기 때문에,
중간 연결 테이블인 room_tag를 추가하여 이를 1:N - N:1 관계로 풀어냈습니다.
하나의 room은 여러 개의 room_tag를 가질 수 있고, 하나의 tag도 여러 room_tag에 참조될 수 있습니다.
JPA를 사용하다 보면 자주 마주치는 대표적인 성능 이슈 중 하나가 바로 N+1 문제입니다.
예를 들어, 다음과 같은 코드가 있습니다.
@OneToMany(mappedBy = "room", orphanRemoval = true, cascade = CascadeType.ALL)
private List<RoomTag> roomTags = new ArrayList<>();
List<Room> rooms = roomRepository.findAll(); //10개가 반환된다고 가정, 각 Room 당 RoomTag는 5개씩
for (Room room : rooms) {
for (RoomTag roomTag : room.getRoomTags()) {
log.info("roomTag.getId() = {}", roomTag.getId());
}
}
👉 총 11번의 쿼리가 발생합니다.
1번의 쿼리로 10개의 Room을 가져온 뒤, 각 Room의 roomTags를 조회하기 위해 각각 별도의 쿼리가 실행되기 때문입니다.
즉, 1(조회 쿼리) + 10(Room마다 roomTags 조회) = 총 11번의 쿼리가 나가게 됩니다.
이것이 바로 N+1 문제의 대표적인 예입니다.
@OneToMany의 기본 fetch 전략은 LAZY입니다. 즉, Room 엔티티만 먼저 조회되고, roomTags 컬렉션은 프록시 객체(proxy) 로 남아 있게 됩니다. 이 프록시는 Hibernate가 실제 데이터를 즉시 로딩하지 않고, 필요한 시점에 쿼리를 날려 데이터를 가져오기 위한 일종의 ‘가짜 객체’입니다.
따라서 다음 코드에서:
for (Room room : rooms) {
for (RoomTag roomTag : room.getRoomTags()) {
log.info("roomTag.getId() = {}", roomTag.getId());
}
}
그 결과로 Room 조회 쿼리 1번, 각 Room의 RoomTag 조회 쿼리 10번
총 11번의 쿼리가 실행되며, 이것이 바로 대표적인 N+1 문제입니다.
EAGER는 ‘언제’ 로딩할지를 결정하는 전략일 뿐, ‘어떻게’ 로딩할지를 제어하지는 못합니다. 특히 컬렉션 연관관계에서 EAGER를 설정하면, Hibernate는 각 연관 데이터를 개별 쿼리로 불러오는 방식을 사용하기 때문에, N+1 문제는 여전히 발생할 수 있습니다. 또한 EAGER를 사용하면 모든 조회 시점에 연관 엔티티까지 항상 함께 로딩되기 때문에, 예를 들어 단순히 Room만 조회하고 싶은 상황에서도 불필요하게 RoomTag까지 함께 조회되는 문제가 발생합니다.
이 때문에 fetch 전략은 LAZY로 사용하되, 다른 방법으로 문제를 해결해야합니다.
N+1 문제를 해결하기 위해 JPA에서는 대표적으로 아래 세 가지 방법이 있습니다:
JPQL에서 JOIN FETCH 구문을 사용하면 연관된 엔티티를 즉시 로딩하면서도 단일 쿼리로 조회할 수 있습니다.
@Query("SELECT r FROM Room r JOIN FETCH r.roomTags")
List<Room> findAllWithRoomTags();
List<Room> rooms = roomRepository.findAllWithRoomTags();
for (Room room : rooms) {
for (RoomTag roomTag : room.getRoomTags()) {
System.out.println("roomTag.getId() = " + roomTag.getId());
}
}
기존에는 Room 10개 조회 시, RoomTag를 각각 LAZY 로딩하면서 총 11개의 쿼리가 실행되었지만, Fetch Join을 사용하면 단 1개의 쿼리로 모든 데이터를 불러올 수 있습니다.
아래는 실제 실행되는 SQL 예시입니다.
select
r1_0.id,
r1_0.create_date,
r1_0.details,
r1_0.max_participants,
r1_0.owner_id,
rt1_0.room_id,
rt1_0.id,
rt1_0.tag_id,
r1_0.session_id,
r1_0.status,
r1_0.title,
r1_0.update_date
from
room r1_0
join
room_tag rt1_0
on r1_0.id=rt1_0.room_id
JOIN FETCH는 내부적으로 INNER JOIN으로 번역되며, 연관 엔티티를 함께 조회하므로 N+1 문제를 해결할 수 있습니다.
List<Room> rooms = roomRepository.findAllWithRoomTags();
System.out.println("Room 갯수 = " + rooms.size());
select
count(r1_0.id)
from
room r1_0
join
room_tag rt1_0
on r1_0.id = rt1_0.room_id;
❗ 아니요, 두 결과는 다릅니다.
SQL 쿼리는 조인 결과의 row 수를 모두 세기 때문에, Room 하나에 여러 RoomTag가 있다면 그만큼 중복된 Room ID가 포함되어 row 수가 많아집니다.
이를 해결하기 위해 Hibernate 6 이전 버전에서는 중복 제거를 위해 distinct 키워드를 사용해야 했습니다.
@Query("SELECT DISTINCT r FROM Room r JOIN FETCH r.roomTags")
List<Room> findAllWithRoomTags();
그러나 Hibernate 6부터는 아래처럼 중복된 엔티티를 메모리에서 제거해 주므로 distinct 없이도 안전하게 사용할 수 있습니다.
https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#hql-distinct
JPA는 연관 엔티티를 로딩할 때 @EntityGraph라는 어노테이션을 통해 Fetch 전략을 설정할 수 있습니다.
이를 사용하면 JPQL을 별도로 작성하지 않아도 연관 엔티티를 함께 로딩할 수 있습니다.
// 1. JPQL 명시적 사용 (복잡한 쿼리 시)
@EntityGraph(attributePaths = {"roomTags"})
@Query("SELECT r FROM Room r")
List<Room> findAllWithRoomTags();
//2. 메서드 이름 기반 사용 (간단할 때)
@EntityGraph(attributePaths = {"roomTags"})
List<Room> findAll();
위와 둘 중 하나를 선택해서 실행하면 아래와 같은 쿼리가 나갑니다.
select
r1_0.id,
r1_0.create_date,
r1_0.details,
r1_0.max_participants,
r1_0.owner_id,
rt1_0.room_id,
rt1_0.id,
rt1_0.tag_id,
r1_0.session_id,
r1_0.status,
r1_0.title,
r1_0.update_date
from
room r1_0
left join
room_tag rt1_0
on r1_0.id=rt1_0.room_id
Room은 있는데, RoomTag가 없으면 해당 필드들은 NULL로 채워집니다.
EntityGraph도 Fetch Join과 마찬가지로 따로 Distinct을 하지 않아도 중복된 엔티티를 내부에서 Set으로 처리해서 제거해 줍니다.
https://stackoverflow.com/questions/70988649/why-does-not-entitygraph-annotation-in-jpa-need-to-use-distinct-keyword-or-s/73348400#73348400
결론부터 말하면, Fetch Join과 @EntityGraph는 페이징 쿼리와 함께 사용할 수 없습니다.
바로 이 부분이 제가 작년에 작성한 코드의 핵심적인 문제점이었습니다.
문제가 발생하는 이유
@Query("SELECT r FROM Room r JOIN FETCH r.roomTags")
Page<Room> findAllWithRoomTags(Pageable pageable); // fetch join 잘못된 사용
@EntityGraph(attributePaths = {"roomTags"})
@Query("SELECT r FROM Room r")
Page<Room> findAllWithRoomTags(Pageable pageable); // EntityGraph 잘못된 사용
WARN: HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
https://vladmihalcea.com/join-fetch-pagination-spring/?utm_source=chatgpt.com
application.yml 또는 application.properties에 다음 설정을 추가하면 됩니다.
로컬에서 개발 시 해당 옵션을 켜고 개발을 하면, 메모리 페이징 시 경고가 아닌 에러를 발생시킵니다.
이때문에 사전에 인지할 수 있습니다.
spring:
jpa:
properties:
hibernate:
query:
fail_on_pagination_over_collection_fetch: true
org.springframework.orm.jpa.JpaSystemException:
setFirstResult() or setMaxResults() specified with collection fetch join
(in-memory pagination was about to be applied, but 'hibernate.query.fail_on_pagination_over_collection_fetch' is enabled)
참고 링크: Join fetch and pagination in Spring (vladmihalcea.com)
hibernate.default_batch_fetch_size
, @BatchSize
를 적용합니다.글로벌
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
개별로 설정하려면 @BatchSize
를 적용하면 됩니다. (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
default_batch_fetch_size는 100~1000 사이가 적절하며, 1000이 가장 성능이 좋지만,
데이터베이스의 IN 절 한계나 DB 순간 부하를 고려해 선택해야 합니다.
메모리 사용량은 동일하므로, 결국 DB와 애플리케이션의 감당 가능 수준에 따라 결정하면 됩니다.
기존에는 @EntityGraph를 사용하면서 페이징 처리를 함께 적용해 N+1 문제를 해결하려고 했지만, 내부적으로는 Hibernate가 메모리에서 페이징을 처리하기 때문에 성능 문제가 발생했습니다.
이에 따라, 저는 @EntityGraph
를 제거하고 Batch Size 설정 방식으로 리팩토링하여 정상적인 페이징 처리와 함께 N+1 문제도 해결할 수 있도록 개선하였습니다. 또한, 로컬 개발 환경에서는 fail_on_pagination_over_collection_fetch
설정을 활성화하여 실수로 컬렉션 Fetch Join과 페이징을 함께 사용하는 잘못된 쿼리가 실행되지 않도록 예방하였습니다.
어떤 기술이나 라이브러리를 사용할 때 단순히 기능 구현만을 목적으로 사용하는 것이 아니라, 내부 동작 원리에 대한 이해가 반드시 필요하다는 것을 이번 경험을 통해 느꼈습니다. 겉으로 보기엔 잘 동작하는 것처럼 보여도, 내부에서 어떻게 처리되는지를 모르고 사용하면 예상치 못한 성능 문제나 장애로 이어질 수 있다는 것을 직접 겪으며 깨달았습니다.
또한 이번 계기로 N+1 문제에 대해 보다 근본적인 원인과 해결 방식(Batch Size 설정, DTO 조회 방식, Fetch 전략 등)을 학습하고 적용하는 계기가 되었고, 성능을 고려한 개발의 중요성을 체감하게 되었습니다.