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을 사용하는 조합이 많이 쓰입니다.

→ JPA Fetch Join과 페이징을 함께 사용할 때 주의점을 설명해 주세요.

CSLOG에서 보기