JPA N+1 문제: 발생 원인부터 해결 전략까지 완벽 가이드

JPA N+1 문제: 발생 원인부터 해결 전략까지 완벽 가이드

JPA를 사용하다 보면 반드시 만나는 문제가 있다. 코드에는 조회 쿼리 하나만 있는데, 로그에는 수십 개의 SELECT가 찍혀 있다. 회원 10명을 조회했을 뿐인데 쿼리가 11개 나간다. 이것이 N+1 문제다.

N+1 문제가 위험한 이유는 개발 단계에서 잘 드러나지 않기 때문이다. 테스트 데이터가 10건일 때는 쿼리 11개가 나가도 느리지 않다. 하지만 운영 환경에서 데이터가 1만 건이 되면 쿼리 10,001개가 나가고, DB 커넥션 풀이 고갈되고, 응답 시간이 수십 초로 치솟는다. 그때가서야 “코드는 안 바꿨는데 왜 느려졌지?”라는 질문이 나온다. 더 위험한 것은 코드 리뷰에서도 잡기 어렵다는 점이다. member.getTeam().getName()이라는 한 줄에 숨겨진 SELECT 쿼리는 눈에 보이지 않는다.

이 글은 N+1 문제가 정확히 어떤 메커니즘으로 발생하는지, EAGER와 LAZY 모두에서 왜 발생하는지, 그리고 각 해결 전략(Fetch Join, EntityGraph, BatchSize, DTO 직접 조회, Subselect)의 내부 동작 원리와 장단점, 한계, 실무에서의 적합한 선택 시점을 하나씩 깊이 있게 다룬다.


1. N+1 문제란 무엇인가

1.1 정의

N+1 문제는 연관된 엔티티를 조회할 때, 1번의 쿼리로 N개의 결과를 가져온 뒤, 각 결과의 연관 엔티티를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상이다.

1
2
3
1번 쿼리: 회원 N명 조회
+ N번 쿼리: 각 회원의 팀을 개별 조회
= 총 N+1번의 쿼리

1.2 왜 “N+1”인가

이름의 의미를 정확히 이해해야 한다.

  • 1: 최초의 컬렉션 조회 쿼리 (예: SELECT * FROM member)
  • N: 조회된 각 엔티티(N개)에 대해 연관 엔티티를 개별 조회하는 추가 쿼리

따라서 회원 100명을 조회하면 최대 1 + 100 = 101개의 쿼리가 발생한다. 연관관계가 여러 개이면 1 + N × M이 될 수도 있다.

1.3 N+1의 실제 비용

단순히 쿼리 수가 많다는 것은 어떤 비용을 의미하는가?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
회원 1,000명 조회 + 각 회원의 팀 조회 = 1,001개 쿼리

각 쿼리에 드는 비용:
- 네트워크 라운드트립: 약 0.5ms (같은 IDC 기준)
- SQL 파싱 + 실행 계획 수립: 약 0.1ms
- 실제 데이터 조회: 약 0.1ms (단건 PK 조회는 빠르다)
- 합계: 약 0.7ms/쿼리

1,001개 × 0.7ms = 약 700ms

Fetch Join으로 1개 쿼리로 줄이면:
1개 × 2ms = 약 2ms

→ 350배 차이

쿼리 하나하나는 빠르다. 문제는 네트워크 라운드트립이 쿼리 수만큼 반복된다는 것이다. DB와 애플리케이션이 같은 서버에 있으면 차이가 작지만, 별도 서버에 있으면 각 라운드트립의 지연이 누적되어 치명적인 성능 저하를 만든다.

또한 쿼리가 많으면 DB 커넥션 점유 시간이 길어진다. 커넥션 풀의 커넥션 수는 제한되어 있으므로, 한 요청이 커넥션을 오래 점유하면 다른 요청이 대기하게 된다. 이것이 동시 사용자가 많아지면 장애로 번지는 메커니즘이다.


2. N+1 문제의 발생 메커니즘

2.1 엔티티 구조 설정

이후의 모든 예제에서 사용할 기본 엔티티 구조다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

DB에 다음 데이터가 있다고 가정한다.

1
2
3
Team: [1: "개발팀", 2: "기획팀", 3: "디자인팀"]
Member: [1: "김철수" → 팀1, 2: "이영희" → 팀1, 3: "박민수" → 팀2,
         4: "정수진" → 팀2, 5: "최동현" → 팀3]

2.2 LAZY 로딩에서의 N+1

LAZY 로딩은 N+1을 예방하는 것이 아니라 지연시키는 것이다. 연관 엔티티에 접근하는 시점에 쿼리가 나간다.

1
2
3
4
5
6
7
8
9
10
@Transactional(readOnly = true)
public void printMemberTeams() {
    List<Member> members = memberRepository.findAll();
    // 쿼리 1: SELECT m.id, m.name, m.age, m.team_id FROM member m

    for (Member member : members) {
        System.out.println(member.getName() + " → " + member.getTeam().getName());
        // 각 회원의 team.getName()에서 프록시 초기화 → 추가 쿼리 발생!
    }
}

실제 발생하는 SQL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 쿼리 1: 회원 전체 조회
SELECT m.id, m.name, m.age, m.team_id FROM member m;

-- 쿼리 2: 김철수의 팀 조회 (team_id = 1)
SELECT t.id, t.name FROM team t WHERE t.id = 1;

-- 이영희의 팀 조회 (team_id = 1)
-- → 영속성 컨텍스트 1차 캐시에 team_id=1이 이미 있으므로 쿼리 안 나감!

-- 쿼리 3: 박민수의 팀 조회 (team_id = 2)
SELECT t.id, t.name FROM team t WHERE t.id = 2;

-- 정수진의 팀 조회 (team_id = 2) → 1차 캐시 히트, 쿼리 안 나감

-- 쿼리 4: 최동현의 팀 조회 (team_id = 3)
SELECT t.id, t.name FROM team t WHERE t.id = 3;

이 경우 실제로는 1 + 3 = 4개의 쿼리가 나갔다. 팀이 3개뿐이고 1차 캐시가 중복을 제거했기 때문이다. 하지만 최악의 경우(모든 회원이 서로 다른 팀이면) 1 + N개의 쿼리가 나간다. 1차 캐시는 성능 최적화일 뿐, 구조적 해결이 아니다.

핵심: LAZY는 N+1을 해결하지 않는다

1
2
3
4
5
6
7
8
9
10
11
12
13
// LAZY인데 N+1이 안 발생하는 경우:
// → 연관 엔티티에 접근하지 않을 때뿐이다
List<Member> members = memberRepository.findAll();
for (Member member : members) {
    System.out.println(member.getName());  // team에 접근 안 함 → 추가 쿼리 없음
}

// LAZY인데 N+1이 발생하는 경우:
// → 연관 엔티티에 접근하면 즉시
List<Member> members = memberRepository.findAll();
for (Member member : members) {
    System.out.println(member.getTeam().getName());  // team 접근 → N+1!
}

LAZY는 “안 쓰면 안 가져온다”이지, “써도 효율적으로 가져온다”가 아니다. 연관 엔티티를 사용해야 하는 상황에서는 LAZY든 EAGER든 추가 쿼리를 피할 수 없다. 페치 전략을 명시적으로 지정해야 한다.

2.3 EAGER 로딩에서의 N+1

많은 사람들이 “EAGER면 JOIN해서 한 번에 가져오지 않나?”라고 생각한다. JPQL을 사용하면 그렇지 않다.

1
2
3
4
5
6
@Entity
public class Member {
    @ManyToOne(fetch = FetchType.EAGER)  // EAGER로 변경
    @JoinColumn(name = "team_id")
    private Team team;
}
1
2
3
List<Member> members = memberRepository.findAll();
// Spring Data JPA의 findAll()은 내부적으로 JPQL을 실행한다:
// "SELECT m FROM Member m"

이때 발생하는 SQL:

1
2
3
4
5
6
7
8
9
-- 쿼리 1: JPQL이 그대로 SQL로 변환됨
SELECT m.id, m.name, m.age, m.team_id FROM member m;

-- EAGER이므로 JPA가 즉시 연관 엔티티를 로딩해야 한다
-- 하지만 JPQL은 이미 실행되었으므로 JOIN을 추가할 수 없다

-- 쿼리 2: SELECT t.id, t.name FROM team t WHERE t.id = 1;
-- 쿼리 3: SELECT t.id, t.name FROM team t WHERE t.id = 2;
-- 쿼리 4: SELECT t.id, t.name FROM team t WHERE t.id = 3;

LAZY에서는 getTeam().getName()을 호출할 때 추가 쿼리가 나갔지만, EAGER에서는 findAll() 호출 시점에 즉시 추가 쿼리가 나간다. 지연 시점만 다를 뿐 N+1은 동일하게 발생한다.

왜 EAGER인데 JOIN이 안 되는가: JPA의 쿼리 실행 순서

이것을 이해하려면 JPA의 쿼리 실행 파이프라인을 알아야 한다.

1
2
3
4
5
6
7
8
9
10
[JPQL 실행 파이프라인]

1단계 — JPQL 파싱: "SELECT m FROM Member m"을 파싱한다
2단계 — SQL 변환: JPQL을 SQL로 변환한다 → "SELECT m.* FROM member m"
         ↑ 이 시점에서 FetchType은 고려되지 않는다!
         ↑ JPQL은 "개발자가 명시한 쿼리"를 존중한다
3단계 — SQL 실행: 변환된 SQL을 DB에 전송하고 결과를 받는다
4단계 — 엔티티 매핑: 결과를 Member 엔티티로 변환한다
5단계 — EAGER 확인: team이 EAGER이므로 즉시 로딩해야 한다
6단계 — 추가 쿼리 실행: 각 Member의 team_id에 대해 개별 SELECT 실행

핵심은 2단계에서 JPQL → SQL 변환이 이미 끝났다는 것이다. EAGER 설정은 5단계에서 반영되므로, 이미 실행된 SQL에 JOIN을 추가할 수 없다. 그래서 개별 쿼리로 연관 엔티티를 가져온다.

이것이 JPQL의 설계 철학이다. JPQL은 개발자가 작성한 쿼리를 존중한다. "SELECT m FROM Member m"이라고 썼으면 Member만 가져오는 SQL을 만든다. 연관 엔티티를 함께 가져오고 싶으면 개발자가 JOIN FETCH를 명시해야 한다.

em.find()는 다르다. em.find()는 JPQL이 아니라 JPA가 직접 SQL을 생성한다. 이 경우 JPA는 엔티티의 FetchType 설정을 보고 JOIN을 포함한 최적의 SQL을 만들 수 있다.

1
2
3
4
5
6
7
8
// em.find()는 EAGER를 반영한 JOIN SQL을 생성한다
Member member = em.find(Member.class, 1L);
// → SELECT m.*, t.* FROM member m LEFT JOIN team t ON m.team_id = t.id WHERE m.id = 1

// JPQL/Spring Data JPA는 EAGER를 반영하지 않는다
List<Member> members = memberRepository.findAll();
// → SELECT m.* FROM member m
// → 이후 개별 쿼리로 team 로딩 (N+1)

이것이 “모든 연관관계는 LAZY로 설정하라”는 실무 원칙의 근거다. EAGER는 em.find()에서만 의도대로 동작하고, JPQL에서는 N+1을 유발한다. 더 나쁜 것은 EAGER의 N+1은 개발자가 통제할 수 없다는 점이다. LAZY는 최소한 “접근하지 않으면 안 나간다”라는 규칙이라도 있지만, EAGER는 조회하는 순간 무조건 나간다.

2.4 @OneToMany에서의 N+1

지금까지는 @ManyToOne(Member → Team)에서의 N+1을 봤다. @OneToMany(Team → Members)에서도 동일한 문제가 발생하며, 더 위험할 수 있다.

1
2
3
4
5
6
7
8
9
10
@Transactional(readOnly = true)
public void printTeamMembers() {
    List<Team> teams = teamRepository.findAll();
    // 쿼리 1: SELECT t.id, t.name FROM team t

    for (Team team : teams) {
        System.out.println(team.getName() + ": " + team.getMembers().size());
        // 각 팀의 members 컬렉션 접근 → 추가 쿼리!
    }
}
1
2
3
4
-- 쿼리 1: SELECT t.id, t.name FROM team t;
-- 쿼리 2: SELECT m.* FROM member m WHERE m.team_id = 1;
-- 쿼리 3: SELECT m.* FROM member m WHERE m.team_id = 2;
-- 쿼리 4: SELECT m.* FROM member m WHERE m.team_id = 3;

@OneToMany의 N+1이 @ManyToOne보다 더 위험한 이유는 두 가지다.

  1. 1차 캐시의 혜택을 받기 어렵다. @ManyToOne에서는 여러 회원이 같은 팀을 가리킬 수 있으므로 1차 캐시로 중복이 제거된다. 하지만 @OneToMany에서 각 팀의 회원 목록은 서로 다른 쿼리 결과이므로 캐시 히트가 발생하지 않는다.

  2. 결과 데이터의 양이 크다. @ManyToOne의 추가 쿼리는 단건 엔티티(Team 1개)를 가져오지만, @OneToMany의 추가 쿼리는 컬렉션(Member N개)을 가져온다. 팀당 회원이 100명이면 각 추가 쿼리가 100행을 반환한다.

2.5 다중 연관관계에서의 N+1 폭발

실무에서는 엔티티가 여러 연관관계를 가지는 것이 일반적이다. 이 경우 N+1이 곱으로 증가할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    private Delivery delivery;

    @OneToMany(mappedBy = "order")
    private List<OrderItem> orderItems = new ArrayList<>();
}
1
2
3
4
5
6
7
List<Order> orders = orderRepository.findAll();  // 주문 100건
for (Order order : orders) {
    order.getMember().getName();       // +100 쿼리 (최악의 경우)
    order.getDelivery().getAddress();  // +100 쿼리
    order.getOrderItems().size();      // +100 쿼리
}
// 최악: 1 + 100 + 100 + 100 = 301개 쿼리!

연관관계가 3개이면 1 + 3N, 4개이면 1 + 4N이다. N이 커지면 수천 개의 쿼리가 나갈 수 있다.


3. 해결 전략 1: Fetch Join (JPQL JOIN FETCH)

3.1 기본 사용법

Fetch Join은 N+1 문제의 가장 직관적이고 강력한 해결책이다. JPQL에 JOIN FETCH를 명시하여 연관 엔티티를 한 번의 쿼리로 함께 가져온다.

1
2
3
4
5
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("SELECT m FROM Member m JOIN FETCH m.team")
    List<Member> findAllWithTeam();
}

생성되는 SQL:

1
2
3
4
SELECT m.id, m.name, m.age, m.team_id,
       t.id, t.name
FROM member m
INNER JOIN team t ON m.team_id = t.id;

쿼리 1개로 끝난다. 회원과 팀 데이터를 한 번에 조인해서 가져오므로 추가 쿼리가 발생하지 않는다. 가져온 Team 엔티티는 영속성 컨텍스트에 올라가므로, 이후 member.getTeam()에 접근해도 프록시 초기화가 발생하지 않는다.

3.2 @ManyToOne Fetch Join 상세

1
2
3
4
5
// 다중 @ManyToOne을 한 번에 Fetch Join
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.member " +
       "JOIN FETCH o.delivery")
List<Order> findAllWithMemberAndDelivery();
1
2
3
4
SELECT o.*, m.*, d.*
FROM orders o
INNER JOIN member m ON o.member_id = m.id
INNER JOIN delivery d ON o.delivery_id = d.id;

@ManyToOne이나 @OneToOne 같은 ToOne 관계는 Fetch Join을 몇 개든 추가할 수 있다. 조인해도 결과 행 수가 늘어나지 않기 때문이다. 주문 1개에 회원 1명, 배송 1건이므로 결과는 항상 주문 수와 동일하다.

3.3 @OneToMany(컬렉션) Fetch Join과 데이터 뻥튀기

@OneToMany 관계에서 Fetch Join을 사용하면 데이터 뻥튀기(카테시안 곱) 문제가 발생한다.

1
2
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
1
2
3
SELECT t.id, t.name, m.id, m.name, m.team_id
FROM team t
INNER JOIN member m ON t.id = m.team_id;

결과:

1
2
3
4
5
6
t.id | t.name  | m.id | m.name
  1  | 개발팀   |  1   | 김철수
  1  | 개발팀   |  2   | 이영희    ← 개발팀이 2번!
  2  | 기획팀   |  3   | 박민수
  2  | 기획팀   |  4   | 정수진    ← 기획팀이 2번!
  3  | 디자인팀  |  5   | 최동현

개발팀에 회원이 2명이므로 개발팀 행이 2개로 불어난다. List<Team> 결과에 같은 Team 인스턴스가 참조는 동일하지만 중복으로 들어간다.

해결: DISTINCT

1
2
@Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();

JPQL의 DISTINCT는 두 가지 역할을 한다.

  1. SQL에 DISTINCT를 추가한다: DB 레벨에서 완전히 동일한 행을 제거한다. 하지만 위 결과에서 각 행의 m.id, m.name이 다르므로 SQL DISTINCT만으로는 중복이 제거되지 않는다.

  2. 애플리케이션 레벨에서 같은 엔티티의 중복을 제거한다: Hibernate가 결과를 엔티티로 변환한 후, 같은 식별자(PK)를 가진 루트 엔티티의 중복을 제거한다. 이것이 핵심이다.

Hibernate 6(Spring Boot 3) 이후에는 컬렉션 Fetch Join에서 자동으로 중복을 제거하므로 DISTINCT를 명시하지 않아도 된다.

3.4 Fetch Join의 장점

장점 1 — 쿼리 수가 최소화된다 N+1을 무조건 1로 줄인다. 네트워크 라운드트립 비용이 최소가 된다.

장점 2 — DB에서 JOIN이 실행되므로 효율적이다 DB의 조인 최적화를 활용할 수 있다. 인덱스가 적절히 설정되어 있으면 매우 빠르다.

장점 3 — 영속성 컨텍스트에 올라간다 Fetch Join으로 가져온 엔티티는 완전히 초기화된 상태로 영속성 컨텍스트에 들어간다. 이후 어디서든 접근해도 추가 쿼리가 없다.

장점 4 — JPA 표준이다 Hibernate 전용이 아니라 JPA 표준 JPQL 문법이므로 구현체에 종속되지 않는다.

3.5 Fetch Join의 단점과 한계

한계 1: 컬렉션 Fetch Join은 1개만 가능하다

1
2
3
4
// 불가능! 두 개의 컬렉션을 동시에 Fetch Join할 수 없다
@Query("SELECT t FROM Team t JOIN FETCH t.members JOIN FETCH t.projects")
List<Team> findAllWithMembersAndProjects();
// → MultipleBagFetchException 발생!

두 개 이상의 컬렉션을 동시에 Fetch Join하면 카테시안 곱이 기하급수적으로 커진다.

1
2
3
4
5
Team 1: members 3명, projects 2개
→ 조인 결과: 3 × 2 = 6행

Team 1: members 100명, projects 50개
→ 조인 결과: 5,000행!

Hibernate는 이를 감지하고 MultipleBagFetchException을 던진다. 여기서 “Bag”은 Java의 List(중복 허용, 순서 있음)에 대응되는 Hibernate의 컬렉션 타입이다.

우회 방법: List를 Set으로 변경하면 예외를 피할 수 있지만, 카테시안 곱 문제 자체는 해결되지 않는다. 실무에서는 하나만 Fetch Join하고 나머지는 BatchSize를 사용하는 것이 정답이다.

1
2
3
4
5
6
7
@Entity
public class Team {
    // Set으로 변경하면 MultipleBagFetchException은 피할 수 있다
    // 하지만 카테시안 곱은 여전히 발생하므로 권장하지 않는다
    @OneToMany(mappedBy = "team")
    private Set<Member> members = new HashSet<>();
}

한계 2: 컬렉션 Fetch Join과 페이징을 함께 사용할 수 없다

이것이 Fetch Join의 가장 치명적인 한계다.

1
2
3
// 위험! 컬렉션 Fetch Join + 페이징
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);

이 조합은 다음과 같은 경고를 발생시킨다.

1
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

“메모리에서 페이징한다”는 뜻이다. DB에서 모든 데이터를 메모리로 가져온 다음 애플리케이션에서 잘라낸다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[왜 DB 레벨 페이징이 불가능한가]

개발자가 원하는 것:
  "팀 10개를 가져오되, 각 팀의 회원도 함께"

SQL LIMIT 10을 적용하면?:
  SELECT t.*, m.* FROM team t JOIN member m ON t.id = m.team_id LIMIT 10;

문제: 이 LIMIT은 "팀 10개"가 아니라 "행 10개"를 자른다.
  만약 개발팀에 회원 8명, 기획팀에 회원 5명이면:
  - 행 1~8: 개발팀 + 회원 8명
  - 행 9~10: 기획팀 + 회원 2명 (나머지 3명이 잘려나감!)

→ 기획팀의 members 컬렉션이 불완전해진다!
→ 데이터 정합성이 깨진다!

Hibernate는 이 문제를 인식하고, LIMIT을 SQL에 넣지 않고 전체 결과를 메모리로 가져온 후 애플리케이션에서 페이징한다. 팀이 1만 개이고 각 팀에 회원이 50명이면, 50만 행을 메모리로 가져와서 10개만 쓰고 나머지를 버린다. OOM(Out of Memory)의 직접적 원인이 된다.

1
2
3
4
// @ManyToOne Fetch Join + 페이징은 안전하다!
@Query("SELECT m FROM Member m JOIN FETCH m.team")
Page<Member> findAllWithTeam(Pageable pageable);
// → @ManyToOne은 조인해도 행 수가 늘지 않으므로 LIMIT이 정확히 동작한다

한계 3: Fetch Join 대상에 별칭(alias)으로 조건을 걸 수 없다

1
2
3
// JPA 스펙상 허용되지 않는다
@Query("SELECT t FROM Team t JOIN FETCH t.members m WHERE m.age > 20")
List<Team> findTeamsWithAdultMembers();

Fetch Join의 목적은 “연관 엔티티를 완전히 로딩하는 것”이다. 필터링을 걸면 부분적으로 로딩된 컬렉션이 영속성 컨텍스트에 올라간다.

1
2
3
4
5
6
실제 데이터: 개발팀 → [김철수(25세), 이영희(19세)]
필터링 후:   개발팀 → [김철수(25세)]  ← 이영희가 빠져있다!

이 상태에서 team.getMembers()를 사용하면,
이영희가 없는 불완전한 리스트를 기반으로 로직이 수행된다.
team.getMembers().size()는 2가 아니라 1을 반환한다.

Hibernate는 이 쿼리를 실행하긴 하지만, 영속성 컨텍스트의 일관성이 깨질 수 있으므로 비권장이다. 필터링이 필요하면 일반 JOIN + WHERE 조건으로 분리하거나 DTO로 직접 조회해야 한다.

한계 4: 동적 쿼리에서 사용이 번거롭다

검색 조건이 동적으로 변하는 쿼리(Querydsl 등)에서 Fetch Join을 사용하려면 조건에 따라 JOIN FETCH를 추가하거나 생략하는 로직이 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
// Querydsl에서 동적 Fetch Join
public List<Member> searchMembers(MemberSearchCondition condition) {
    JPAQuery<Member> query = queryFactory
        .selectFrom(member)
        .join(member.team, team).fetchJoin();  // 항상 Fetch Join

    if (condition.getTeamName() != null) {
        query.where(team.name.eq(condition.getTeamName()));
    }

    return query.fetch();
}

3.6 Fetch Join이 적합한 상황

1
2
3
4
5
6
7
8
✅ ToOne 관계(@ManyToOne, @OneToOne)의 N+1 해결
✅ 페이징이 필요 없는 컬렉션 로딩
✅ 연관 엔티티를 반드시 사용하는 것이 확실한 경우
✅ 쿼리 수를 절대적으로 최소화해야 하는 경우

❌ 컬렉션 + 페이징이 필요한 경우
❌ 두 개 이상의 컬렉션을 동시에 로딩해야 하는 경우
❌ 연관 엔티티에 필터링 조건을 걸어야 하는 경우

4. 해결 전략 2: @EntityGraph

4.1 기본 사용법

@EntityGraph는 Fetch Join을 어노테이션 기반으로 선언적으로 사용하는 방법이다. JPQL을 직접 작성하지 않아도 Spring Data JPA의 메서드 이름 기반 쿼리에 페치 전략을 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface MemberRepository extends JpaRepository<Member, Long> {

    // JPQL과 함께 사용
    @EntityGraph(attributePaths = {"team"})
    @Query("SELECT m FROM Member m")
    List<Member> findAllWithTeam();

    // 메서드 이름 기반 쿼리에도 적용 가능
    @EntityGraph(attributePaths = {"team"})
    List<Member> findByName(String name);

    // 여러 연관관계를 지정
    @EntityGraph(attributePaths = {"team", "orders"})
    List<Member> findByAgeGreaterThan(int age);
}

생성되는 SQL:

1
2
3
4
SELECT m.id, m.name, m.age, m.team_id,
       t.id, t.name
FROM member m
LEFT OUTER JOIN team t ON m.team_id = t.id;

4.2 EntityGraph의 장점

장점 1 — 메서드 이름 기반 쿼리에 적용 가능

1
2
3
// @Query 없이 메서드 이름만으로 N+1 해결
@EntityGraph(attributePaths = {"team"})
List<Member> findByAgeGreaterThan(int age);

Fetch Join은 반드시 @Query로 JPQL을 작성해야 하지만, EntityGraph는 Spring Data JPA의 메서드 이름 파생 쿼리에도 적용할 수 있다. 간단한 조회에서 편리하다.

장점 2 — 코드가 선언적이고 깔끔하다

1
2
3
4
5
6
7
// Fetch Join: JPQL 작성 필요
@Query("SELECT m FROM Member m JOIN FETCH m.team WHERE m.age > :age")
List<Member> findByAgeFetchJoin(@Param("age") int age);

// EntityGraph: 어노테이션만 추가
@EntityGraph(attributePaths = {"team"})
List<Member> findByAgeGreaterThan(int age);

장점 3 — LEFT OUTER JOIN이 기본이다

1
2
3
4
5
6
// Fetch Join은 INNER JOIN → team이 null인 회원은 제외됨
@Query("SELECT m FROM Member m JOIN FETCH m.team")

// EntityGraph는 LEFT OUTER JOIN → team이 null인 회원도 포함됨
@EntityGraph(attributePaths = {"team"})
@Query("SELECT m FROM Member m")

team이 없는(null) 회원도 결과에 포함해야 한다면 EntityGraph가 더 자연스럽다. Fetch Join으로 같은 결과를 얻으려면 LEFT JOIN FETCH를 명시해야 한다.

4.3 EntityGraph의 단점

단점 1 — 복잡한 쿼리에는 부적합하다

EntityGraph는 “어떤 연관관계를 함께 로딩할 것인가”만 지정할 수 있다. JOIN 조건, 서브쿼리, 집계 함수 등 복잡한 쿼리 로직은 표현할 수 없다. 복잡한 조회에는 Fetch Join(JPQL)이 필요하다.

단점 2 — 동적으로 변경하기 어렵다

어노테이션은 컴파일 타임에 고정되므로, 런타임에 페치 전략을 동적으로 변경할 수 없다. Querydsl처럼 동적 쿼리를 빌드하는 경우에는 Fetch Join이 더 유연하다.

단점 3 — 컬렉션 관련 한계는 Fetch Join과 동일하다

EntityGraph도 내부적으로는 JOIN을 사용하므로, 데이터 뻥튀기, 컬렉션 페이징 불가, 다중 컬렉션 제한 등 Fetch Join의 한계를 그대로 가진다.

단점 4 — attributePaths가 문자열이다

1
2
@EntityGraph(attributePaths = {"team"})
// "team"이 오타나 리팩토링으로 변경되어도 컴파일 에러가 안 난다

문자열 기반이므로 타입 안전성이 없다. 필드명을 바꾸면 런타임에 에러가 발생한다.

4.4 Named EntityGraph: 재사용 가능한 그래프

엔티티 클래스에 그래프를 정의해두고 여러 Repository 메서드에서 재사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Entity
@NamedEntityGraph(
    name = "Member.withTeam",
    attributeNodes = @NamedAttributeNode("team")
)
@NamedEntityGraph(
    name = "Member.withTeamAndOrders",
    attributeNodes = {
        @NamedAttributeNode("team"),
        @NamedAttributeNode(value = "orders", subgraph = "orders-subgraph")
    },
    subgraphs = @NamedSubgraph(
        name = "orders-subgraph",
        attributeNodes = @NamedAttributeNode("orderItems")
    )
)
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    private Team team;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}
1
2
3
4
5
6
7
public interface MemberRepository extends JpaRepository<Member, Long> {
    @EntityGraph("Member.withTeam")
    List<Member> findByName(String name);

    @EntityGraph("Member.withTeamAndOrders")
    Optional<Member> findDetailById(Long id);
}

4.5 Fetch Join vs EntityGraph 비교 정리

기준 Fetch Join @EntityGraph
JOIN 타입 INNER JOIN (기본) LEFT OUTER JOIN (기본)
사용 방식 @Query로 JPQL 직접 작성 어노테이션 선언
메서드 이름 쿼리 불가 가능
복잡한 쿼리 가능 (서브쿼리, 조건 등) 불가
동적 쿼리 Querydsl 등에서 유연하게 제어 가능 어노테이션이므로 정적
타입 안전성 JPQL 문자열 (동일) attributePaths 문자열 (동일)
컬렉션 한계 뻥튀기, 페이징 불가 동일
적합한 상황 복잡한 조회, 동적 쿼리 단순 조회, 메서드 이름 쿼리

4.6 EntityGraph가 적합한 상황

1
2
3
4
5
6
7
8
✅ 메서드 이름 기반 쿼리에 페치 전략을 적용할 때
✅ 간단한 ToOne 관계 로딩
✅ team이 null인 엔티티도 포함해야 할 때 (LEFT JOIN 기본)
✅ 팀 내 표준 페치 그래프를 Named EntityGraph로 공유할 때

❌ 복잡한 조건이 있는 쿼리
❌ 동적으로 페치 전략을 변경해야 하는 경우
❌ 컬렉션 + 페이징이 필요한 경우 (Fetch Join과 같은 한계)

5. 해결 전략 3: @BatchSize (IN 절 최적화)

5.1 기본 원리

@BatchSize는 N+1 문제를 근본적으로 제거하지는 않지만, N번의 개별 쿼리를 ⌈N/batch_size⌉번의 IN 쿼리로 줄여준다. 프록시를 하나 초기화할 때, Hibernate가 영속성 컨텍스트에서 같은 역할(같은 연관관계)의 초기화되지 않은 프록시를 batch_size만큼 모아서 하나의 IN 쿼리로 처리한다.

1
2
3
4
5
6
# 글로벌 설정 (application.yml)
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

또는 특정 연관관계에만 적용:

1
2
3
4
5
6
@Entity
public class Team {
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@ManyToOne의 경우 대상 엔티티 클래스에 붙인다:

1
2
3
4
5
6
7
@Entity
@BatchSize(size = 100)
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

5.2 동작 흐름 상세

1
2
3
4
5
6
7
8
9
10
@Transactional(readOnly = true)
public void printTeamMembers() {
    List<Team> teams = teamRepository.findAll();
    // 쿼리 1: SELECT t.id, t.name FROM team t
    // → 팀 250개 조회됨

    for (Team team : teams) {
        System.out.println(team.getName() + ": " + team.getMembers().size());
    }
}

BatchSize 없이 (N+1 발생):

1
2
3
4
5
6
SELECT t.id, t.name FROM team t;                        -- 쿼리 1
SELECT m.* FROM member m WHERE m.team_id = 1;            -- 쿼리 2
SELECT m.* FROM member m WHERE m.team_id = 2;            -- 쿼리 3
...
SELECT m.* FROM member m WHERE m.team_id = 250;          -- 쿼리 251
-- 총 251개 쿼리

BatchSize = 100으로 설정 시:

1
2
3
4
5
SELECT t.id, t.name FROM team t;                                         -- 쿼리 1
SELECT m.* FROM member m WHERE m.team_id IN (1, 2, 3, ..., 100);        -- 쿼리 2
SELECT m.* FROM member m WHERE m.team_id IN (101, 102, ..., 200);       -- 쿼리 3
SELECT m.* FROM member m WHERE m.team_id IN (201, 202, ..., 250);       -- 쿼리 4
-- 총 4개 쿼리!

251개 → 4개. 약 63배 감소다.

5.3 BatchSize의 내부 동작 메커니즘

Hibernate는 어떻게 “같은 역할의 초기화되지 않은 프록시”를 인식하는가?

1
2
3
4
5
6
7
[영속성 컨텍스트 내부 상태]

Team 1  → members: PersistentBag(UNINITIALIZED)
Team 2  → members: PersistentBag(UNINITIALIZED)
Team 3  → members: PersistentBag(UNINITIALIZED)
...
Team 250 → members: PersistentBag(UNINITIALIZED)

team1.getMembers().size()가 호출되면:

  1. Hibernate는 Team 1의 members 컬렉션(PersistentBag)이 아직 초기화되지 않았음을 감지한다
  2. 영속성 컨텍스트에서 같은 역할(Team.members)의 미초기화 컬렉션을 검색한다
  3. batch_size(100)만큼 모아서 하나의 IN 쿼리를 실행한다
  4. 결과로 Team 1~100의 members가 모두 초기화된다
  5. 이후 team2~team100의 getMembers()에 접근하면 이미 초기화되어 있으므로 추가 쿼리 없음
  6. team101에 접근하면 다시 101~200을 묶어서 IN 쿼리 실행

5.4 Hibernate의 BatchSize 패딩 최적화

Hibernate 5.x 이후에서는 IN절의 파라미터 수를 2의 거듭제곱 또는 고정 패딩 값으로 맞추는 최적화를 수행한다. 이를 통해 DB의 SQL 파싱 캐시 히트율을 높인다.

1
2
3
4
5
6
spring:
  jpa:
    properties:
      hibernate:
        query:
          in_clause_parameter_padding: true
1
2
3
4
5
6
7
batch_size = 100이고 실제 IN 파라미터가 73개인 경우:

패딩 OFF: WHERE team_id IN (1, 2, ..., 73)         ← 73개 파라미터
패딩 ON:  WHERE team_id IN (1, 2, ..., 73, 73, ..., 73, 128)  ← 128개 파라미터

128은 73보다 큰 가장 작은 2의 거듭제곱이다.
나머지 자리는 마지막 값(73)으로 채워진다.

IN절의 파라미터 수가 항상 같은 패턴(32, 64, 128 등)이면 DB가 동일한 실행 계획을 캐시하여 재사용할 수 있다. 파라미터 수가 매번 다르면 매번 새로운 실행 계획을 수립해야 하므로 비효율적이다.

5.5 BatchSize의 장점

장점 1 — 글로벌 설정 한 줄로 애플리케이션 전체에 적용된다

1
spring.jpa.properties.hibernate.default_batch_fetch_size: 100

이 한 줄이 모든 LAZY 로딩에 IN절 최적화를 적용한다. 코드를 전혀 수정하지 않아도 된다. 실수로 N+1이 발생하더라도 피해가 제한된다.

장점 2 — 페이징과 자유롭게 결합할 수 있다

1
2
3
4
// 안전: BatchSize + 페이징
Page<Team> teams = teamRepository.findAll(PageRequest.of(0, 10));
// 쿼리 1: SELECT * FROM team LIMIT 10 OFFSET 0  (DB 레벨 페이징!)
// 쿼리 2: SELECT * FROM member WHERE team_id IN (1, 2, ..., 10)

Fetch Join + 컬렉션은 메모리 페이징이라는 치명적 문제가 있지만, BatchSize는 DB 레벨 페이징이 정상 동작한다. 컬렉션 + 페이징이 필요한 시나리오에서 BatchSize가 사실상 유일한 안전한 선택지다.

장점 3 — 다중 컬렉션에도 문제없이 적용된다

1
2
3
4
5
6
7
8
@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @OneToMany(mappedBy = "team")
    private List<Project> projects = new ArrayList<>();
}

Fetch Join은 다중 컬렉션에서 MultipleBagFetchException이 발생하지만, BatchSize는 각 컬렉션에 대해 독립적으로 IN 쿼리를 실행하므로 문제가 없다.

1
2
3
4
5
6
-- 팀 조회
SELECT * FROM team;
-- members IN 쿼리
SELECT * FROM member WHERE team_id IN (1, 2, 3, ...);
-- projects IN 쿼리
SELECT * FROM project WHERE team_id IN (1, 2, 3, ...);

장점 4 — 데이터 뻥튀기가 없다

Fetch Join은 조인으로 인해 결과 행이 증가하지만, BatchSize는 별도 쿼리이므로 각 쿼리의 결과가 정확하다.

장점 5 — 기존 코드를 전혀 수정하지 않아도 된다

설정 한 줄만 추가하면 모든 LAZY 로딩에 자동 적용된다. Repository 코드, 서비스 코드, JPQL 모두 수정 불필요하다.

5.6 BatchSize의 단점

단점 1 — 쿼리가 1개가 아니라 1 + α개다

1
2
3
Fetch Join: 항상 1개 쿼리
BatchSize(100)로 팀 250개 + members:
  → 1 + ⌈250/100⌉ = 4개 쿼리

Fetch Join보다는 쿼리 수가 많다. 네트워크 라운드트립이 추가로 발생한다. 하지만 N+1의 251개에 비하면 4개는 무시할 수 있는 수준이다.

단점 2 — IN절의 크기가 클 수 있다

batch_size를 1000으로 설정하면 WHERE id IN (1, 2, ..., 1000) 같은 SQL이 생성된다. IN절의 파라미터가 많으면:

  • SQL 문자열 자체가 커져 네트워크 비용 증가
  • 일부 DBMS에 파라미터 수 제한이 있다 (Oracle: IN절 최대 1000개)
  • SQL 파싱 비용 증가

실무에서는 100~500 사이를 사용한다.

단점 3 — “정확히 이 쿼리에서 이 연관관계를 가져온다”는 제어가 약하다

Fetch Join은 “이 쿼리에서 이 연관관계를 함께 로딩한다”를 명시적으로 선언한다. BatchSize는 글로벌 설정이므로 “언제 IN 쿼리가 나가는가”를 코드에서 바로 파악하기 어렵다. 프록시 초기화 시점에 의존하므로, 어느 라인에서 추가 쿼리가 나가는지 예측하려면 LAZY 프록시의 동작을 잘 이해해야 한다.

단점 4 — 불필요한 데이터를 로딩할 수 있다

1
2
3
4
team1.getMembers().size();  // team1의 members를 초기화하려고 했는데
// BatchSize 때문에 team2, team3, ..., team100의 members도 함께 로딩됨

// team2~100의 members를 실제로 사용하지 않으면 불필요한 데이터 로딩

하지만 실무에서 리스트를 조회한 후 루프를 도는 패턴이 대부분이므로, 미리 로딩해두는 것이 대부분 유리하다.

5.7 적절한 BatchSize 값 선택

batch_size 팀 1000개 기준 쿼리 수 적합한 상황
10 1 + 100 = 101 테스트/디버깅용
50 1 + 20 = 21 소규모 서비스
100 1 + 10 = 11 가장 범용적 (권장)
500 1 + 2 = 3 대규모 데이터 조회가 빈번
1000 1 + 1 = 2 Oracle 제한에 주의

5.8 BatchSize가 적합한 상황

1
2
3
4
5
6
7
8
✅ 애플리케이션 전체에 N+1 기본 방어를 깔 때 (글로벌 설정)
✅ 컬렉션(@OneToMany) + 페이징이 필요할 때
✅ 다중 컬렉션을 동시에 로딩해야 할 때
✅ 기존 코드를 수정하지 않고 N+1을 완화해야 할 때
✅ Fetch Join의 한계(뻥튀기, 페이징, 다중 컬렉션)를 우회해야 할 때

❌ 쿼리 수를 반드시 1개로 줄여야 하는 극한 성능 요구
❌ 쿼리 실행을 코드에서 명시적으로 제어해야 하는 경우

6. 해결 전략 4: @Fetch(FetchMode.SUBSELECT)

6.1 동작 원리

SUBSELECT는 원본 쿼리를 서브쿼리로 사용하여 연관 엔티티를 정확히 한 번의 추가 쿼리로 모두 로딩한다.

1
2
3
4
5
6
@Entity
public class Team {
    @Fetch(FetchMode.SUBSELECT)
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}
1
2
3
4
5
6
7
8
9
List<Team> teams = teamRepository.findAll();
// 쿼리 1: SELECT t.id, t.name FROM team t

for (Team team : teams) {
    System.out.println(team.getMembers().size());
}
// 쿼리 2: SELECT m.* FROM member m
//         WHERE m.team_id IN (SELECT t.id FROM team t)
// → 원본 쿼리가 서브쿼리로 들어간다!

6.2 BatchSize와의 차이

1
2
3
4
5
6
7
8
9
10
11
BatchSize(100)으로 팀 250개 로딩:
  쿼리 1: SELECT * FROM team
  쿼리 2: SELECT * FROM member WHERE team_id IN (1, 2, ..., 100)
  쿼리 3: SELECT * FROM member WHERE team_id IN (101, ..., 200)
  쿼리 4: SELECT * FROM member WHERE team_id IN (201, ..., 250)
  → 총 4개 쿼리

SUBSELECT로 팀 250개 로딩:
  쿼리 1: SELECT * FROM team
  쿼리 2: SELECT * FROM member WHERE team_id IN (SELECT id FROM team)
  → 총 2개 쿼리 (항상 정확히 2개)

6.3 SUBSELECT의 장점

장점 1 — 쿼리가 항상 정확히 2개다

데이터가 100개든 10만 개든 원본 쿼리 1개 + SUBSELECT 1개 = 2개로 고정된다. BatchSize처럼 데이터 양에 따라 쿼리 수가 변하지 않는다.

장점 2 — IN절의 파라미터 수 제한이 없다

BatchSize는 IN절에 실제 ID 값을 나열하므로 파라미터 수 제한(Oracle 1000개)에 걸릴 수 있다. SUBSELECT는 서브쿼리를 사용하므로 이 제한과 무관하다.

6.4 SUBSELECT의 단점

단점 1 — 원본 쿼리가 복잡하면 서브쿼리도 복잡해진다

1
2
3
// 원본 쿼리가 WHERE 조건, JOIN, ORDER BY를 포함하면
@Query("SELECT t FROM Team t WHERE t.region = :region ORDER BY t.name")
List<Team> findByRegion(@Param("region") String region);
1
2
3
4
5
-- SUBSELECT가 원본 쿼리 전체를 포함한다
SELECT m.* FROM member m
WHERE m.team_id IN (
    SELECT t.id FROM team t WHERE t.region = ? ORDER BY t.name
);

원본 쿼리가 복잡할수록 서브쿼리의 비용도 커진다.

단점 2 — 부분 로딩이 불가능하다

BatchSize는 “접근한 시점에 batch_size만큼”만 로딩하므로, 첫 100개만 사용하고 나머지는 사용하지 않을 수 있다. SUBSELECT는 첫 접근 시 모든 연관 엔티티를 한 번에 로딩한다. 팀이 1만 개인데 실제로는 10개만 사용해도, 1만 개 팀의 회원을 모두 가져온다.

단점 3 — Hibernate 전용이다

@Fetch(FetchMode.SUBSELECT)는 JPA 표준이 아니라 Hibernate 전용 어노테이션이다. 다른 JPA 구현체(EclipseLink 등)에서는 동작하지 않는다.

단점 4 — 글로벌 설정이 없다

BatchSize는 default_batch_fetch_size로 글로벌 적용이 가능하지만, SUBSELECT는 각 연관관계에 개별적으로 어노테이션을 붙여야 한다.

6.5 SUBSELECT가 적합한 상황

1
2
3
4
5
6
7
8
9
✅ 연관 엔티티를 "전부 다" 사용하는 것이 확실한 경우
✅ 데이터 양이 예측 가능하고 적정 범위일 때
✅ Oracle 등 IN절 파라미터 수 제한이 문제될 때
✅ 쿼리 수를 최소화(정확히 2개)해야 할 때

❌ 데이터 양이 매우 많거나 예측 불가능한 경우
❌ 원본 쿼리가 복잡한 경우
❌ 글로벌 적용이 필요한 경우
❌ JPA 구현체 독립성이 중요한 경우

7. 해결 전략 5: DTO 직접 조회

7.1 핵심 아이디어

N+1 문제의 근본 원인은 엔티티의 연관관계 탐색이다. 엔티티 대신 필요한 데이터만 DTO로 직접 뽑으면, 연관관계 자체가 없으므로 N+1 문제가 원천적으로 발생하지 않는다.

1
2
3
4
5
public record MemberTeamDto(
    Long memberId,
    String memberName,
    String teamName
) {}
1
2
3
4
5
6
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Query("SELECT new com.example.dto.MemberTeamDto(m.id, m.name, t.name) " +
           "FROM Member m JOIN m.team t")
    List<MemberTeamDto> findMemberTeamDtos();
}
1
2
3
4
-- 쿼리 1개로 필요한 데이터만 정확히 가져온다
SELECT m.id, m.name, t.name
FROM member m
JOIN team t ON m.team_id = t.id;

7.2 다양한 DTO Projection 방식

방식 1: JPQL new 연산자

1
2
3
@Query("SELECT new com.example.dto.MemberTeamDto(m.id, m.name, t.name) " +
       "FROM Member m JOIN m.team t")
List<MemberTeamDto> findMemberTeamDtos();

가장 명시적이지만, 패키지명을 포함한 전체 클래스명을 JPQL에 써야 한다.

방식 2: Interface 기반 Projection

1
2
3
4
5
public interface MemberTeamProjection {
    Long getMemberId();
    String getMemberName();
    String getTeamName();
}
1
2
3
@Query("SELECT m.id as memberId, m.name as memberName, t.name as teamName " +
       "FROM Member m JOIN m.team t")
List<MemberTeamProjection> findMemberTeamProjections();

인터페이스만 정의하면 Spring Data JPA가 프록시 구현체를 자동으로 만들어준다. 단, 별칭(alias)과 getter 이름이 일치해야 한다.

방식 3: Class 기반 Projection (DTO)

1
public record MemberTeamDto(Long memberId, String memberName, String teamName) {}
1
2
3
4
// 생성자의 파라미터 이름과 SELECT 별칭이 일치해야 한다
@Query("SELECT m.id as memberId, m.name as memberName, t.name as teamName " +
       "FROM Member m JOIN m.team t")
List<MemberTeamDto> findMemberTeamDtos();

방식 4: Querydsl Projection

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public List<MemberTeamDto> searchMembers(MemberSearchCondition condition) {
    return queryFactory
        .select(Projections.constructor(MemberTeamDto.class,
            member.id,
            member.name,
            team.name
        ))
        .from(member)
        .join(member.team, team)
        .where(
            teamNameEq(condition.getTeamName()),
            ageBetween(condition.getAgeLoe(), condition.getAgeGoe())
        )
        .fetch();
}

7.3 컬렉션 DTO 조회 전략

@OneToMany를 포함한 DTO 조회는 두 가지 방식이 있다.

방식 A: 조인으로 한 번에 (flat)

1
2
3
4
5
6
@Query("SELECT new com.example.dto.OrderItemFlatDto(" +
       "o.id, m.name, oi.productName, oi.price) " +
       "FROM Order o " +
       "JOIN o.member m " +
       "JOIN o.orderItems oi")
List<OrderItemFlatDto> findOrderItemFlat();

조인이므로 데이터가 뻥튀기된다. 주문 1개에 상품 3개면 3행이 나온다. 애플리케이션에서 groupBy로 묶어야 한다.

방식 B: 루트 조회 + IN 쿼리 (2단계)

1
2
3
4
5
6
7
8
9
10
11
// 1단계: 루트 DTO 조회
@Query("SELECT new com.example.dto.OrderDto(o.id, m.name, o.totalAmount) " +
       "FROM Order o JOIN o.member m " +
       "WHERE o.status = :status")
List<OrderDto> findOrderDtos(@Param("status") OrderStatus status);

// 2단계: orderIds를 모아서 IN 쿼리로 상세 데이터 조회
@Query("SELECT new com.example.dto.OrderItemDto(oi.order.id, oi.productName, oi.price) " +
       "FROM OrderItem oi " +
       "WHERE oi.order.id IN :orderIds")
List<OrderItemDto> findOrderItemDtos(@Param("orderIds") List<Long> orderIds);
1
2
3
4
5
6
7
8
9
10
11
12
// 서비스에서 조합
public List<OrderDto> findOrders(OrderStatus status) {
    List<OrderDto> orders = orderRepository.findOrderDtos(status);
    List<Long> orderIds = orders.stream().map(OrderDto::orderId).toList();
    List<OrderItemDto> items = orderRepository.findOrderItemDtos(orderIds);

    Map<Long, List<OrderItemDto>> itemMap = items.stream()
        .collect(Collectors.groupingBy(OrderItemDto::orderId));

    orders.forEach(o -> o.setOrderItems(itemMap.getOrDefault(o.orderId(), List.of())));
    return orders;
}

이 방식은 쿼리 2개로 N+1을 완전히 해결하면서, 데이터 뻥튀기도 없다.

7.4 DTO 직접 조회의 장점

장점 1 — N+1이 원천적으로 불가능하다

엔티티가 아니므로 연관관계 탐색이 없다. 프록시도 없다. SQL에 정의된 데이터만 정확히 가져온다.

장점 2 — 필요한 컬럼만 SELECT하므로 성능이 좋다

엔티티 조회는 모든 컬럼을 SELECT한다. DTO는 필요한 컬럼만 SELECT하므로 네트워크 전송량이 줄어든다. 특히 BLOB, TEXT 같은 대용량 컬럼이 있는 엔티티에서 효과적이다.

장점 3 — 영속성 컨텍스트의 부담이 없다

DTO는 영속성 컨텍스트에 올라가지 않는다. 변경 감지(Dirty Checking) 대상이 아니므로 스냅샷 비교 비용이 없다. 대량 데이터 조회에서 메모리 사용량이 줄어든다.

장점 4 — 페이징, 정렬이 자유롭다

DTO 조회는 순수한 SQL 결과이므로 DB 레벨 LIMIT/OFFSET이 정확하게 동작한다.

7.5 DTO 직접 조회의 단점

단점 1 — 화면에 종속적인 쿼리가 만들어진다

1
2
3
4
5
6
7
8
// 주문 목록 화면용 DTO
public record OrderListDto(Long orderId, String memberName, BigDecimal totalAmount) {}

// 주문 상세 화면용 DTO
public record OrderDetailDto(Long orderId, String memberName, String address, ...) {}

// 관리자 대시보드용 DTO
public record OrderAdminDto(Long orderId, String memberName, OrderStatus status, ...) {}

화면마다 다른 DTO와 쿼리가 필요하다. 화면이 추가되면 DTO와 쿼리도 추가된다.

단점 2 — 엔티티의 행위를 사용할 수 없다

DTO는 단순 데이터 운반체이므로, 엔티티의 비즈니스 메서드(order.cancel(), product.decreaseStock() 등)를 호출할 수 없다. 데이터를 변경해야 하면 다시 엔티티를 조회해야 한다.

단점 3 — 유지보수 비용이 증가한다

테이블 구조가 변경되면 관련된 모든 DTO와 JPQL을 수정해야 한다. 엔티티 기반 조회는 엔티티 매핑만 수정하면 되지만, DTO 조회는 각 쿼리를 개별적으로 수정해야 한다.

7.6 DTO 직접 조회가 적합한 상황

1
2
3
4
5
6
7
8
9
✅ 읽기 전용 조회 (데이터를 변경하지 않는 경우)
✅ 화면에 맞는 특정 형태의 데이터가 필요한 경우
✅ 통계, 집계, 리포트
✅ 대용량 데이터 조회에서 메모리를 절약해야 할 때
✅ 성능이 극도로 중요한 핫 쿼리

❌ 엔티티를 변경해야 하는 경우
❌ 동일한 데이터를 여러 화면에서 재사용하는 경우
❌ 빠르게 프로토타이핑하는 초기 개발 단계

8. 전략 비교 총정리

8.1 기능 비교

기준 Fetch Join EntityGraph BatchSize SUBSELECT DTO 직접 조회
쿼리 수 1 1 1 + ⌈N/size⌉ 2 1~2
페이징 (컬렉션) ❌ 메모리 ❌ 메모리 ✅ DB ✅ DB ✅ DB
다중 컬렉션 ❌ 1개만 ❌ 1개만 ✅ 가능 ✅ 가능 ✅ 가능
데이터 뻥튀기 ✅ 있음 ✅ 있음 ❌ 없음 ❌ 없음 ❌ 없음
코드 수정 쿼리 작성 어노테이션 설정만 어노테이션 DTO + 쿼리
JPA 표준 ❌ (Hibernate) ❌ (Hibernate)
엔티티 반환
적용 범위 쿼리별 쿼리별 글로벌/엔티티 엔티티 쿼리별

8.2 성능 비교 (팀 100개, 팀당 회원 10명 기준)

전략 쿼리 수 총 전송 행 수 메모리 사용
N+1 (미해결) 101 100 + 1000 = 1100 엔티티 1100개
Fetch Join 1 1000 (뻥튀기) 엔티티 1100개
EntityGraph 1 1000 (뻥튀기) 엔티티 1100개
BatchSize(100) 2 100 + 1000 = 1100 엔티티 1100개
SUBSELECT 2 100 + 1000 = 1100 엔티티 1100개
DTO 직접 조회 1 1000 (필요 컬럼만) DTO 1000개 (가벼움)

Fetch Join은 쿼리 1개이지만 조인으로 인해 팀 데이터가 반복되어 전송 행 수가 증가한다. BatchSize는 쿼리 2개이지만 각 쿼리의 결과가 정확하므로 전송 데이터가 깔끔하다.

8.3 선택 기준 의사결정 트리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
연관 엔티티를 사용하는가?
│
├── 아니오 → 조치 불필요 (LAZY이면 쿼리 안 나감)
│
└── 예
     │
     ├── 엔티티를 변경해야 하는가?
     │    │
     │    ├── 예 → 엔티티 조회 필수
     │    │        │
     │    │        ├── ToOne 관계만 → Fetch Join
     │    │        │
     │    │        └── 컬렉션 포함 → BatchSize (글로벌)
     │    │                          + 필요 시 ToOne은 Fetch Join 추가
     │    │
     │    └── 아니오 (읽기 전용)
     │         │
     │         ├── 페이징이 필요한가?
     │         │    │
     │         │    ├── 예 + ToOne → Fetch Join + Page ✅
     │         │    │
     │         │    └── 예 + 컬렉션 → BatchSize ✅
     │         │                       (Fetch Join은 메모리 페이징이므로 ❌)
     │         │
     │         ├── 다중 컬렉션을 로딩해야 하는가?
     │         │    │
     │         │    ├── 예 → BatchSize (다중 컬렉션 문제없음)
     │         │    │        또는 1개는 Fetch Join + 나머지 BatchSize
     │         │    │
     │         │    └── 아니오 → Fetch Join (쿼리 1개로 최적)
     │         │
     │         └── 화면에 특화된 데이터인가?
     │              │
     │              ├── 예 → DTO 직접 조회
     │              │
     │              └── 아니오 → Fetch Join 또는 EntityGraph
     │
     └── 성능이 극도로 중요한가? (대량 데이터, 핫 쿼리)
          │
          └── DTO 직접 조회 검토

9. 실무 적용 전략: 계층별 접근

9.1 Layer 1 — 글로벌 방어: BatchSize

1
2
3
4
5
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

모든 프로젝트의 첫 번째 조치. 이 한 줄이 애플리케이션 전체의 N+1 피해를 제한한다. 코드 수정 없이 설정만으로 적용된다.

9.2 Layer 2 — 핫스팟 최적화: Fetch Join

1
2
3
4
5
6
// 주문 목록 API — 가장 자주 호출되는 쿼리
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.member " +
       "JOIN FETCH o.delivery " +
       "WHERE o.status = :status")
List<Order> findOrdersWithMemberAndDelivery(@Param("status") OrderStatus status);

쿼리 로그를 분석하여 N+1이 빈번하게 발생하는 핫스팟 쿼리를 식별하고, 해당 쿼리에만 Fetch Join을 적용한다.

9.3 Layer 3 — 복잡한 조회: DTO 직접 조회

1
2
3
4
5
6
// 대시보드 API — 통계/집계 데이터
@Query("SELECT new com.example.dto.DashboardDto(" +
       "t.name, COUNT(m), AVG(m.age)) " +
       "FROM Team t LEFT JOIN t.members m " +
       "GROUP BY t.name")
List<DashboardDto> findDashboardData();

통계, 리포트, 특수한 화면 등 엔티티 구조와 맞지 않는 데이터가 필요한 경우에 사용한다.

9.4 실무 예시: 주문 조회 API의 단계별 최적화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 1단계: 아무 최적화 없이 (N+1 발생)
@Transactional(readOnly = true)
public List<OrderResponse> getOrders() {
    List<Order> orders = orderRepository.findAll();  // 1 쿼리
    return orders.stream().map(order -> new OrderResponse(
        order.getId(),
        order.getMember().getName(),      // +N 쿼리
        order.getDelivery().getAddress(), // +N 쿼리
        order.getOrderItems().stream()    // +N 쿼리
            .map(OrderItemResponse::from)
            .toList()
    )).toList();
    // 최악: 1 + 3N 쿼리
}

// 2단계: BatchSize 글로벌 설정 (설정만 추가)
// → 1 + 3 × ⌈N/100⌉ 쿼리 (N=100이면 4개)

// 3단계: ToOne은 Fetch Join으로 최적화
@Query("SELECT o FROM Order o " +
       "JOIN FETCH o.member " +
       "JOIN FETCH o.delivery")
List<Order> findAllWithMemberAndDelivery();

// 서비스: orderItems는 BatchSize로 처리
@Transactional(readOnly = true)
public List<OrderResponse> getOrders() {
    List<Order> orders = orderRepository.findAllWithMemberAndDelivery();
    // 쿼리 1: orders + member + delivery (Fetch Join)
    return orders.stream().map(order -> new OrderResponse(
        order.getId(),
        order.getMember().getName(),      // 이미 로딩됨
        order.getDelivery().getAddress(), // 이미 로딩됨
        order.getOrderItems().stream()    // BatchSize로 IN 쿼리 1개
            .map(OrderItemResponse::from)
            .toList()
    )).toList();
    // 총 2개 쿼리! (Fetch Join 1개 + BatchSize IN 1개)
}

// 4단계 (극한 최적화): DTO 직접 조회
// → 1~2개 쿼리, 필요한 컬럼만 전송, 영속성 컨텍스트 부담 없음

10. N+1 문제 감지 방법

10.1 개발 단계: SQL 로그

1
2
3
4
5
6
7
8
9
10
11
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE  # Hibernate 6 (파라미터 바인딩)

10.2 테스트 단계: 쿼리 카운트 검증

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
void 주문_조회_시_N_plus_1이_발생하지_않아야_한다() {
    // given
    createTestOrders(50);

    // when
    em.flush();
    em.clear();

    long startCount = getQueryCount();
    List<Order> orders = orderRepository.findAllWithMemberAndDelivery();
    for (Order order : orders) {
        order.getMember().getName();
        order.getDelivery().getAddress();
        order.getOrderItems().size();
    }
    long endCount = getQueryCount();

    // then
    long totalQueries = endCount - startCount;
    assertThat(totalQueries)
        .as("Fetch Join + BatchSize로 쿼리 수가 5개 이하여야 한다")
        .isLessThanOrEqualTo(5);
}

datasource-proxyspring-boot-data-source-decorator 라이브러리를 사용하면 쿼리 수를 정확히 카운트할 수 있다.

10.3 운영 단계: 모니터링

N+1 문제는 “특정 쿼리가 느린 것”이 아니라 “쿼리 가 비정상적으로 많은 것”이다. 따라서 단일 쿼리의 실행 시간보다 트랜잭션당 쿼리 수를 모니터링하는 것이 더 효과적이다.

  • Spring Boot Actuator + Micrometer: hibernate.query.execution.count 메트릭
  • p6spy: SQL 실행 로그 + 소요 시간 기록
  • APM (DataDog, New Relic 등): 요청당 DB 쿼리 수 모니터링, 임계값 알림

결론

N+1 문제는 JPA를 사용하는 한 피할 수 없는 주제다. 하지만 발생 원인을 정확히 이해하면 해결은 체계적으로 할 수 있다.

발생 원인: JPQL은 개발자가 작성한 쿼리를 SQL로 직접 변환하므로, 엔티티의 FetchType 설정을 반영하지 않는다. em.find()만이 FetchType을 반영한 JOIN SQL을 생성한다. EAGER는 즉시, LAZY는 접근 시점에 추가 쿼리가 발생하며, 둘 다 N+1을 유발한다.

해결 전략은 상호 보완적이다. 하나의 전략으로 모든 상황을 해결하는 것이 아니라, 각 전략의 강점이 빛나는 상황에 적절히 배치하는 것이 핵심이다.

  • BatchSize(글로벌): 전체 방어망. 코드 수정 없이 모든 LAZY 로딩에 적용된다. 페이징, 다중 컬렉션 모두 안전하다.
  • Fetch Join: 정밀 타격. ToOne 관계에 특히 효과적이고, 쿼리 1개로 최적화한다. 컬렉션에서는 페이징과 다중 컬렉션 한계에 주의한다.
  • EntityGraph: 선언적 Fetch Join. 간단한 조회와 메서드 이름 쿼리에 편리하다. LEFT JOIN이 기본이라 null 데이터 포함이 필요할 때 자연스럽다.
  • SUBSELECT: 정확히 2개 쿼리. 데이터 양이 예측 가능하고 전부 사용하는 경우에 효과적이다.
  • DTO 직접 조회: 근본적 해결. 읽기 전용 화면 특화 데이터, 통계, 대량 조회에 최적이다.

실무에서 가장 효과적인 조합은 “글로벌 BatchSize + 핫스팟 Fetch Join + 복잡한 조회 DTO”다. 이 세 단계를 적용하면 대부분의 N+1 성능 문제를 해결할 수 있다.