N+1 문제
연관된 엔티티를 조회할 때 1번의 쿼리로 N개의 결과를 가져온 후, 각 결과마다 추가 쿼리가 N번 발생하는 문제입니다.
예시 상황
@Entity
public class Team {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members;
}
@Entity
public class Member {
@Id
private Long id;
private String name;
@ManyToOne
private Team team;
}
List<Team> teams = teamRepository.findAll(); // 팀 조회
for (Team team : teams) {
System.out.println(team.getMembers().size()); // 멤버 접근
}
실제 발생하는 쿼리
-- 1번: 팀 전체 조회
SELECT * FROM team;
-- N번: 각 팀의 멤버 조회 (팀이 10개면 10번)
SELECT * FROM member WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
...
팀이 100개면 총 101번의 쿼리가 발생합니다.
왜 발생하는가
JPA의 지연 로딩(Lazy Loading) 때문입니다. @OneToMany는 기본이 LAZY라서 실제로 getMembers()를 호출할 때 쿼리가 나갑니다.
해결 방법
1. Fetch Join
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
-- 1번의 쿼리로 해결
SELECT * FROM team t
INNER JOIN member m ON t.id = m.team_id;
컬렉션 Fetch Join과 페이징을 함께 쓸 때 주의점
~ToMany 관계에서 Fetch Join과 페이징 쿼리(Pageable)를 함께 사용하면 다음과 같은 경고가 발생합니다.
firstResult/maxResults specified with collection fetch; applying in memory
컬렉션을 Fetch Join하면 Member 수만큼 결과 행이 늘어나므로(카티션 곱) DB 레벨에서 정상적인 LIMIT/OFFSET 페이징을 적용할 수 없습니다. 그래서 하이버네이트는 조인 결과 전체를 메모리에 올린 뒤 애플리케이션 레벨에서 페이징을 수행하는데, 대상 데이터가 많으면 이 과정에서 OutOfMemoryError가 발생할 수 있습니다.
가장 안전한 해결책은 컬렉션을 Fetch Join하지 않고 아래 3번의 Batch Size로 N+1을 해결하는 것입니다. Fetch Join은 ~ToOne 관계이거나 페이징이 필요 없는 경우에만 사용하는 것이 안전합니다.
2. EntityGraph
@EntityGraph(attributePaths = {"members"})
@Query("SELECT t FROM Team t")
List<Team> findAllWithMembers();
3. Batch Size 설정
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members;
-- IN 절로 묶어서 조회
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... 100);
또는 전역 설정:
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
각 방법의 특징
| 방법 | 장점 | 단점 |
|---|---|---|
| Fetch Join | 쿼리 1번으로 해결 | 페이징 불가, 컬렉션 2개 이상 시 문제 |
| EntityGraph | 선언적, 재사용 가능 | Fetch Join과 동일한 한계 |
| Batch Size | 페이징 가능, 설정 간단 | 쿼리가 1번은 아님 (1 + N/batchSize) |
실무에서는 보통 Batch Size를 전역으로 설정해두고, 필요한 곳에서 Fetch Join을 사용하는 조합이 많이 쓰입니다.