JPA N+1 문제 완벽 해결 — Fetch Join vs EntityGraph 실무 비교

2026-07-10


사진: Unsplash 의 Growtika


개요

JPA를 사용하다 보면 반드시 마주치는 문제가 N+1 쿼리입니다. 연관 엔티티를 조회할 때 예상치 못한 추가 쿼리가 N번 발생하여 성능을 크게 저하시킵니다. 이 글에서는 N+1 문제의 원인을 파악하고, 실무에서 가장 많이 쓰이는 두 가지 해결책인 Fetch JoinEntityGraph를 코드 중심으로 비교합니다.


N+1 문제란 무엇인가

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

예를 들어 Order 엔티티가 Member와 연관되어 있을 때, 주문 목록 100건을 조회하면 총 101번의 쿼리가 실행됩니다.

// Order 엔티티
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY) // LAZY 설정
    @JoinColumn(name = "member_id")
    private Member member;

    private String status;
}

// 문제가 발생하는 코드
List<Order> orders = orderRepository.findAll(); // 쿼리 1번
for (Order order : orders) {
    System.out.println(order.getMember().getName()); // 각 order마다 쿼리 1번씩 발생
}

FetchType.EAGER로 설정해도 Hibernate가 별도 SELECT를 날리기 때문에 문제는 동일하게 발생합니다.


해결책 1 — Fetch Join

JPQL에서 JOIN FETCH를 사용하면 연관 엔티티를 하나의 쿼리로 함께 가져옵니다.

// Repository
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o JOIN FETCH o.member")
    List<Order> findAllWithMember();

    // 페이징과 함께 사용할 때 (컬렉션 fetch join 시 페이징 불가 주의)
    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.member WHERE o.status = :status")
    List<Order> findByStatusWithMember(@Param("status") String status);
}

Fetch Join은 쿼리를 직접 제어할 수 있어 명확하지만, 몇 가지 제약이 있습니다.

  • 컬렉션(OneToMany) fetch join + 페이징을 함께 쓰면 Hibernate가 메모리에서 페이징을 처리하여 HHH90003004 경고가 발생합니다.
  • 여러 컬렉션을 동시에 fetch join하면 MultipleBagFetchException이 발생합니다.
  • 페이징이 필요한 컬렉션 조회는 default_batch_fetch_size 또는 @BatchSize를 활용하는 것이 낫습니다.

해결책 2 — EntityGraph

@EntityGraph는 JPQL 없이 메서드 이름 쿼리나 기본 메서드에 그래프를 오버레이하는 방식입니다.

// 방법 1: attributePaths 직접 지정
public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"member"})
    List<Order> findAll();

    @EntityGraph(attributePaths = {"member", "orderItems"})
    Optional<Order> findById(Long id);
}

// 방법 2: @NamedEntityGraph 정의 후 참조
@Entity
@NamedEntityGraph(
    name = "Order.withMember",
    attributeNodes = @NamedAttributeNode("member")
)
public class Order {
    // ...
}

// Repository에서 참조
@EntityGraph(value = "Order.withMember", type = EntityGraph.EntityGraphType.FETCH)
List<Order> findByStatus(String status);

EntityGraphType.FETCH는 명시한 속성만 EAGER, 나머지는 LAZY로 처리합니다. EntityGraphType.LOAD는 엔티티의 기본 fetch 전략을 유지합니다.


Fetch Join vs EntityGraph 비교

항목 Fetch Join EntityGraph

적용 방식 JPQL JOIN FETCH 어노테이션
코드 가독성 쿼리가 길어질 수 있음 선언적, 간결
동적 조건 결합 자유롭게 가능 제한적
컬렉션 + 페이징 불가 (메모리 페이징 경고) 동일하게 불가
복수 컬렉션 조인 MultipleBagFetchException 동일 제약
Spring Data 메서드와 결합 불가 (JPQL 필요) 가능
세밀한 쿼리 제어 높음 낮음

컬렉션 N+1 — BatchSize로 해결하기

OneToMany 관계에서 페이징이 필요할 때는 fetch join 대신 @BatchSize를 사용하면 IN 절 배치 쿼리로 문제를 완화할 수 있습니다.

// application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

// 또는 개별 컬렉션에 지정
@Entity
public class Order {
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private List<OrderItem> orderItems = new ArrayList<>();
}

default_batch_fetch_size: 100을 설정하면 지연 로딩 시 최대 100개 ID를 IN 절로 묶어 조회합니다. 100건 주문의 orderItems를 가져올 때 쿼리가 101번에서 2번으로 줄어듭니다.


실무 선택 가이드

상황에 따라 적절한 전략을 선택하는 것이 중요합니다.

  • 단일 엔티티 또는 ManyToOne 조회 → Fetch Join 또는 EntityGraph 모두 적합
  • Spring Data 메서드 이름 쿼리에 그래프 추가 → EntityGraph
  • 복잡한 조건이 포함된 동적 쿼리 → Fetch Join (JPQL 또는 QueryDSL)
  • OneToMany + 페이징 → default_batch_fetch_size + 지연 로딩
  • 여러 컬렉션 동시 조회 → default_batch_fetch_size 또는 별도 쿼리 분리

정리

항목 설명

N+1 원인 지연 로딩 시 연관 엔티티마다 개별 SELECT 발생
Fetch Join JPQL로 단일 쿼리에 연관 엔티티 포함, 컬렉션 페이징 불가
EntityGraph 어노테이션으로 선언, 메서드 쿼리와 결합 가능
BatchSize 컬렉션 + 페이징 조합 시 가장 안전한 해결책

결론

N+1 문제는 JPA 사용 시 피할 수 없지만 원인을 이해하면 적절한 전략으로 완전히 제어할 수 있습니다. ManyToOne 단순 조회에는 Fetch Join이나 EntityGraph를, OneToMany 페이징 조회에는 BatchSize를 선택하면 대부분의 상황을 커버할 수 있습니다. 쿼리 로그를 반드시 확인하면서 실제로 발행되는 SQL 수를 측정하는 습관을 들이는 것이 중요합니다.


메인 이미지 출처 : 사진: UnsplashGrowtika

태그: JPA, N+1, Fetch Join, EntityGraph, Spring Boot 카테고리: WEB/Spring