Java & Spring/JPA

N+1문제와 Fetch 전략 - LAZY, Fetch Join, @BatchSize

승주우에요 2026. 5. 9. 17:21

1. Fetch 전략 — EAGER vs LAZY

전략 동작          JPA                                                                                                          기본값

EAGER 엔티티 로드 시 연관 엔티티도 즉시 JOIN으로 함께 로드 @ManyToOne, @OneToOne
LAZY 연관 엔티티의 프록시만 만들어두고, 실제 접근 시 추가 쿼리 @OneToMany, @ManyToMany

 

@ManyToOne과 @OneToOne의 기본값이 EAGER라는 점이 함정이다. 명시하지 않으면 조회할 때마다 연관 엔티티를 함께 JOIN해온다.

 

모든 연관관계는 LAZY로 두는 것이 권장된다.

@ManyToOne(fetch = FetchType.LAZY) // 명시적으로 LAZY
@JoinColumn(name = "user_id")
private User user;

EAGER가 문제가 되는 이유

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.EAGER) Customer customer;
    @ManyToOne(fetch = FetchType.EAGER) Product product;
    @ManyToOne(fetch = FetchType.EAGER) Coupon coupon;
}

findById()로 Order 하나를 조회하면 JOIN이 3번 따라붙는다. Customer가 또 EAGER 연관을 가지면 JOIN이 더 늘어난다. 어느 화면에서 어느 데이터가 필요한지는 화면마다 다른데, 엔티티 정의에서 EAGER로 박아두면 항상 모든 것을 가져온다.


2. N+1 문제

정의

// User : Order = 1 : N
List<User> users = userRepository.findAll(); // 1번 쿼리

for (User user : users) {
    user.getOrders().size(); // 각 User마다 추가 쿼리 → N번
}

// 총 1 + N번
// 사용자 100명 → 101번
// 사용자 1000명 → 1001번

LAZY로 설정해도 사라지지 않는다. LAZY는 필요할 때 가져온다는 약속일 뿐이다. 반복문 안에서 접근하면 매 반복마다 SELECT가 나간다.

왜 발생하는가

LAZY 프록시 메커니즘 때문이다. User.orders가 LAZY일 때 Hibernate는 실제 List 대신 PersistentBag 같은 프록시 컬렉션을 넣어둔다. 이 프록시는 첫 접근(size(), iterator() 등) 시점에 SELECT를 실행한다.

각 User 인스턴스가 각자의 프록시 컬렉션을 가지므로, 100명을 순회하며 각 user의 orders에 접근하면 100번의 별도 SELECT가 발생한다.

N+1이 터지는 또 다른 상황 — EAGER + JPQL

em.createQuery("SELECT o FROM Order o", Order.class).getResultList();

JPQL은 작성한 그대로 SELECT * FROM orders를 실행한다. 이후 각 Order의 EAGER 연관에 대해 추가 SELECT가 발생한다. 작성자는 JOIN을 기대했는데, JPA는 그렇게 동작하지 않는다.


3. 해결책 비교

전략                                                 쿼리 수               장점                                                  단점

Fetch Join 1개 단일 쿼리, 가장 명시적 Cartesian Product, 페이징 불가
@EntityGraph 1개 선언적, JPQL 변경 없음 Fetch Join과 동일한 한계
@BatchSize N/M + 1개 Cartesian 회피, 페이징 가능 여러 쿼리
@Fetch(SUBSELECT) 2개 정확히 2쿼리 첫 접근 시 전체 로드

 

4. Fetch Join

가장 직접적인 해결책이다.

public interface UserRepository extends JpaRepository<User, Long> {
    @Query("SELECT u FROM User u JOIN FETCH u.orders")
    List<User> findAllWithOrders();
}
-- 단일 쿼리
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

한계 ① — Cartesian Product (카티시안 곱)

User 100명, 각 User마다 Order 10건이라면 결과는 1000행이 된다. Hibernate가 중복 제거를 해서 객체 그래프상으로는 정상이지만, 네트워크로 1000행이 전송된다.

한계 ② — 페이징 불가

@Query("SELECT u FROM User u JOIN FETCH u.orders")
Page<User> findAllWithOrders(Pageable pageable);
// HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!

Hibernate가 경고를 띄우며 DB가 아닌 메모리에서 페이징을 한다. 전체 결과를 가져온 뒤 메모리에서 잘라낸다. 데이터가 많으면 OOM 위험이 있다.

한계 ③ — 둘 이상의 컬렉션 동시 fetch 불가

@Query("SELECT u FROM User u JOIN FETCH u.orders JOIN FETCH u.coupons")
// MultipleBagFetchException: cannot simultaneously fetch multiple bags

컬렉션 두 개를 동시에 fetch join하면 Cartesian Product가 폭발한다 (User 100 × Order 10 × Coupon 5 = 5000행). Hibernate가 아예 막는다. (DB는 이들의 모든 가능한 조합을 만들어내기 때문!)


5. @EntityGraph

선언적으로 어떤 연관을 함께 로드할지 지정한다.

public interface UserRepository extends JpaRepository<User, Long> {
    @EntityGraph(attributePaths = "orders")
    List<User> findAll();

    @EntityGraph(attributePaths = {"orders", "coupons"})
    Optional<User> findById(Long id);
}

내부적으로 LEFT JOIN으로 풀리며, Fetch Join과 거의 동일한 결과다. JPQL을 직접 작성하지 않아도 된다는 점이 깔끔하다.

SELECT u.id, u.name, o.id, o.product, o.user_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;

Cartesian Product 문제와 페이징 문제는 Fetch Join과 동일하게 가진다.

재사용이 필요하다면 @NamedEntityGraph로 미리 정의해두고 이름으로 참조할 수 있다.

@Entity
@NamedEntityGraph(
    name = "User.withOrders",
    attributeNodes = @NamedAttributeNode("orders")
)
public class User { ... }

// Repository에서 이름으로 참조
@EntityGraph(value = "User.withOrders")
List<User> findAll();

6. @BatchSize

컬렉션이나 프록시를 여러 ID를 모아서 IN 절로 한 번에 로드한다.

@Entity
public class User {
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    @BatchSize(size = 100) // 100개씩 모아 IN 절로 로드
    private List<Order> orders;
}
-- 1번째 쿼리: 모든 User 조회
SELECT * FROM users;

-- 2번째 쿼리: 100명의 User에 대한 Order 일괄 조회
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 100);

-- 3번째 쿼리: 다음 100명
SELECT * FROM orders WHERE user_id IN (101, 102, ..., 200);

User 1000명 기준으로 N+1은 1001번 쿼리였지만, BatchSize 100이면 1 + 10 = 11번으로 줄어든다.

전역 설정도 가능하다.

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

장점

  • Cartesian Product가 없다
  • 페이징과 호환된다
  • 컬렉션 N+1에 가장 효과적이다

단점

  • IN 절이 너무 길면 DB 옵티마이저가 비효율적으로 처리할 수 있다. size는 100~500 범위를 권장한다.

7. @Fetch(SUBSELECT)

컬렉션을 처음 접근하는 순간, 현재 영속성 컨텍스트에 있는 모든 부모 엔티티의 자식을 SUBSELECT로 한 번에 가져온다.

@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@Fetch(FetchMode.SUBSELECT)
private List<Order> orders;
-- 1번째: 부모 조회
SELECT * FROM users WHERE ...;

-- 첫 user.getOrders() 호출 시:
SELECT * FROM orders
WHERE user_id IN (
    SELECT id FROM users WHERE ... -- 위 쿼리를 서브쿼리로 재사용
);

정확히 2개의 쿼리만 나간다. 단, 첫 접근 시점에 모든 부모의 모든 자식을 다 가져온다. 한 명의 orders만 필요한 상황이라도 전체를 로드한다.


8. 언제 무엇을 쓸까

상황 권장

@ManyToOne 한 단계 로드 Fetch Join 또는 @EntityGraph
단일 컬렉션 + 페이징 없음 Fetch Join
단일 컬렉션 + 페이징 필요 @BatchSize
여러 컬렉션 동시 로드 @BatchSize (fetch join 동시 불가)
전역 기본값 default_batch_fetch_size: 100

실무에서는 default_batch_fetch_size를 전역으로 깔아두고, 특정 쿼리에서 성능이 중요한 경우에만 Fetch Join으로 명시적으로 제어하는 방식이 많이 쓰인다.


9. N+1 감지하는 방법

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        generate_statistics: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.stat: DEBUG

generate_statistics: true를 켜면 쿼리 카운트가 로그에 찍힌다. 테스트 코드에서 쿼리 수를 직접 검증하고 싶다면 hypersistence-utils 같은 라이브러리를 활용할 수 있다.


10. 정리

주제                                                  핵심

N+1이란 1번 조회 후 각 결과마다 추가 쿼리가 N번 발생하는 문제
LAZY가 답이 아닌 이유 LAZY는 필요할 때 가져오는 것. 반복문 접근 시 N번 쿼리는 여전히 발생
Fetch Join 1개의 쿼리로 해결. 단, Cartesian Product 및 페이징 불가
@EntityGraph Fetch Join의 선언적 버전. 한계도 동일
@BatchSize IN 절로 묶어서 로드. 페이징 가능. 컬렉션 N+1에 가장 실용적
@Fetch(SUBSELECT) 정확히 2쿼리. 첫 접근 시 전체 로드

N+1은 JPA를 잘못 쓰고 있다는 신호이기도 하지만, 동시에 어떤 데이터를 어떤 시점에 로드할지 설계하지 않았다는 신호이기도 하다. OSIV를 끄고 Service 레이어에서 필요한 데이터를 명확히 로드하는 습관이 근본적인 해결책이다.