자바 스프링 백엔드 개발자를 준비하며 정리한 JPA N+1 문제입니다.
"Fetch Join 쓰면 되죠?"에서 끝나지 않고, 왜 발생하는지, 각 해결책의 한계는 무엇인지까지 정리했습니다.
연관된 엔티티를 조회할 때, 처음 1번의 쿼리 때문에 연관 데이터를 가져오기 위한 쿼리가 N번 추가로 실행되는 문제입니다.
예를 들어 팀(Team) 목록을 조회하고, 각 팀의 멤버(Member)에 접근하면 다음과 같이 됩니다.
SELECT * FROM team; -- 1번 (팀 N개 조회)
SELECT * FROM member WHERE team_id = 1; -- N번 시작
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
...
팀이 100개면 쿼리가 101번 나갑니다. 데이터가 늘수록 DB 호출 횟수가 선형으로 늘어나 성능이 급격히 나빠집니다. 문제는 코드만 봐서는 눈치채기 어렵다는 점입니다.
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
@OneToMany(mappedBy = "team") // 기본 fetch = LAZY
private List<Member> members = new ArrayList<>();
}
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
List<Team> teams = teamRepository.findAll(); // 쿼리 1번
for (Team team : teams) {
System.out.println(team.getMembers().size()); // 팀마다 쿼리 1번씩 → N번
}
findAll()로 팀만 가져오고, members는 프록시(지연 로딩) 상태입니다. 반복문에서 실제로 접근하는 순간마다 쿼리가 나갑니다.
application.yml에 설정하면 SQL을 볼 수 있습니다.
spring:
jpa:
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
💡 개발 중에는 눈으로 확인하고, 테스트에서는 쿼리 횟수를 검증(Hibernate Statistics, p6spy 등)하면 N+1을 조기에 발견할 수 있습니다.
핵심은 JPQL(Spring Data JPA의 findAll(), 쿼리 메서드 포함)이 SQL로 바로 번역된다는 점입니다.
findAll()은 SELECT t FROM Team t로 실행되어 팀 테이블만 조회합니다.@OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
해결되지 않습니다. JPQL은 fetch 전략을 무시하고 먼저 팀만 조회한 뒤, EAGER라서 곧바로 팀마다 멤버 조회 쿼리를 실행합니다. 오히려 필요 없는 곳에서도 항상 조회되어 더 위험합니다.
결론: 모든 연관관계는
LAZY로 설정하고, 필요한 곳에서만 한 번에 가져오는 전략을 쓴다.
(@ManyToOne,@OneToOne은 기본값이 EAGER이므로 명시적으로 LAZY 지정해야 합니다.)
JPQL에서 join fetch를 쓰면 연관 엔티티를 한 번의 쿼리로 함께 조회합니다.
@Query("select t from Team t join fetch t.members")
List<Team> findAllWithMembers();
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id; -- 쿼리 1번
장점: 쿼리 1번으로 해결, 직관적입니다.
주의할 점
Pageable을 쓰면 Hibernate가 경고(HHH000104: firstResult/maxResults specified with collection fetch; applying in memory)를 내고 모든 데이터를 메모리로 읽어서 페이징합니다. → OutOfMemory 위험MultipleBagFetchException) 카테시안 곱으로 데이터가 폭발하기 때문입니다.distinct가 필요합니다. (Hibernate 6 / Spring Boot 3부터는 엔티티 중복을 자동 제거합니다.)메서드에 애노테이션만 붙여서 페치 조인과 같은 효과를 냅니다. (내부적으로 LEFT OUTER JOIN으로 동작)
@EntityGraph(attributePaths = {"members"})
@Query("select t from Team t")
List<Team> findAllWithMembers();
// 쿼리 메서드에도 적용 가능
@EntityGraph(attributePaths = {"members"})
List<Team> findByName(String name);
장점: JPQL을 직접 쓰지 않아도 되고 간결합니다.
한계: 본질은 페치 조인과 같아서 컬렉션 페이징 문제도 동일하게 존재합니다.
지연 로딩 시 연관 데이터를 IN 쿼리로 묶어서 한 번에 가져옵니다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
또는 개별로 지정할 수도 있습니다.
@BatchSize(size = 100)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
결과 쿼리는 다음과 같습니다.
SELECT * FROM team; -- 1번
SELECT * FROM member WHERE team_id IN (1, 2, 3, ... 100); -- 팀 100개씩 묶어서 1번
팀이 100개면 101번 → 2번으로 줄어듭니다. (N+1 → 1+⌈N/size⌉)
장점
팁: 사이즈는 보통 100~1000 사이에서 DB의 IN 절 한계와 WAS 메모리를 고려해 정합니다.
엔티티가 아닌 필요한 컬럼만 DTO로 바로 조회합니다.
@Query("select new com.example.dto.TeamMemberDto(t.name, m.name) " +
"from Team t join t.members m")
List<TeamMemberDto> findTeamMemberDtos();
장점: 필요한 컬럼만 가져와 성능이 좋고, 영속성 컨텍스트를 거치지 않아 N+1 자체가 일어나지 않습니다.
단점: 엔티티를 재사용하기 어렵고 조회 전용에 한정됩니다. 화면에 딱 맞춘 조회 API에 적합합니다.
| 상황 | 추천 |
|---|---|
ToOne (@ManyToOne, @OneToOne) 연관 | Fetch Join (페이징 영향 없음) |
ToMany (@OneToMany) 연관 + 페이징 | default_batch_fetch_size + 필요 시 ToOne만 fetch join |
| 컬렉션을 한 번에, 페이징 없음 | Fetch Join / EntityGraph |
| 조회 전용 복잡한 화면 | DTO 직접 조회 (QueryDSL 등) |
default_batch_fetch_size를 전역으로 설정해 둔다. (컬렉션 N+1 방어선)// ToOne은 fetch join, ToMany는 batch size로 처리
@Query("select o from Order o join fetch o.member join fetch o.delivery")
List<Order> findAllWithMemberDelivery(Pageable pageable);
// orderItems 같은 컬렉션은 LAZY + batch size가 IN 쿼리로 자동 처리
A. 연관 관계가 있는 엔티티를 조회할 때, 처음 조회 쿼리 1번 이후에 연관된 엔티티를 가져오기 위한 쿼리가 N번 추가로 실행되는 문제입니다. 예를 들어 팀 100개를 조회한 뒤 각 팀의 멤버에 접근하면 쿼리가 101번 나갑니다.
원인은 JPQL이 글로벌 fetch 전략을 고려하지 않고 SQL로 그대로 번역되기 때문입니다. 먼저 팀만 조회하고, 이후 지연 로딩된 연관 데이터에 접근할 때 건건이 추가 쿼리가 실행됩니다.
A. 해결되지 않습니다. JPQL은 fetch 전략을 무시하고 우선 본 엔티티만 조회한 뒤, EAGER 설정 때문에 즉시 연관 엔티티를 추가 쿼리로 조회하므로 오히려 N+1이 발생합니다. 또한 연관 데이터가 필요 없는 상황에서도 항상 조회되어 불필요한 쿼리가 생깁니다. 그래서 모든 연관관계는 LAZY로 두고, 필요한 시점에 Fetch Join 등으로 함께 조회하는 것이 좋습니다.
A. 네 가지를 주로 씁니다.
1. Fetch Join: JPQL에서 연관 엔티티를 한 번의 쿼리로 함께 조회합니다.
2. @EntityGraph: 애노테이션으로 페치 조인과 같은 효과를 냅니다. (LEFT OUTER JOIN)
3. Batch Size: 지연 로딩 시 연관 데이터를 IN 쿼리로 묶어서 조회해 쿼리 수를 1+N에서 1+N/size로 줄입니다.
4. DTO 직접 조회: 필요한 컬럼만 DTO로 조회해 엔티티 연관 로딩 자체를 피합니다.
A. 가장 큰 문제는 컬렉션(1:N) 페치 조인과 페이징을 함께 쓸 수 없다는 점입니다. 1:N 조인은 결과 row가 늘어나기 때문에, Hibernate가 DB에서 페이징하지 못하고 모든 데이터를 메모리로 가져와 페이징합니다. 데이터가 많으면 OutOfMemory 위험이 있습니다.
또한 둘 이상의 컬렉션은 페치 조인할 수 없고(MultipleBagFetchException), 별칭을 사용해 조건을 거는 것은 영속성 컨텍스트의 일관성을 해칠 수 있어 지양합니다.
A. 두 단계로 접근합니다. 먼저 ToOne 관계는 Fetch Join으로 가져오고(row가 늘어나지 않아 페이징에 영향이 없습니다), 컬렉션은 LAZY로 두고 default_batch_fetch_size(또는 @BatchSize)를 설정합니다. 그러면 본 엔티티는 DB에서 정상적으로 페이징되고, 조회된 엔티티들의 컬렉션만 IN 쿼리로 한 번에 가져오므로 쿼리 수도 크게 줄어듭니다.
A. 둘 다 연관 엔티티를 한 번의 쿼리로 함께 가져온다는 점에서 목적은 같습니다. 차이는 사용 방식과 조인 종류입니다. Fetch Join은 JPQL에 직접 join fetch를 작성하고 기본이 INNER JOIN이며, EntityGraph는 애노테이션으로 선언하고 LEFT OUTER JOIN으로 동작합니다. 쿼리를 직접 쓰지 않아도 된다는 편의성은 있지만, 컬렉션 페이징 문제 같은 한계는 동일합니다.
A. 개발 단계에서 SQL 로그를 켜서 실행되는 쿼리 개수를 직접 확인하고, p6spy나 Hibernate Statistics로 쿼리 횟수를 테스트로 검증하는 방법이 있습니다. 예방 차원에서는 모든 연관관계를 LAZY로 설정하고, default_batch_fetch_size를 전역으로 지정해 두며, ToOne은 Fetch Join, 복잡한 조회는 DTO로 분리하는 원칙을 세워 둡니다. 운영 환경에서는 APM(예: Pinpoint)으로 API당 쿼리 수를 모니터링하는 것도 좋습니다.
@ManyToOne의 기본 fetch 전략은 무엇이고, 왜 LAZY로 바꿔야 하나요?A. @ManyToOne과 @OneToOne은 기본이 EAGER입니다. (@OneToMany, @ManyToMany는 LAZY) EAGER는 연관 엔티티가 필요 없는 경우에도 항상 조회하고, JPQL에서 N+1을 유발하며, 연관관계가 얽히면 예상치 못한 쿼리가 많이 발생합니다. 그래서 fetch = FetchType.LAZY로 명시적으로 지연 로딩을 지정하고, 필요한 시점에 Fetch Join으로 가져오는 것이 권장됩니다.