2026-07-10

사진: Unsplash 의 Growtika
개요
JPA를 사용하다 보면 반드시 마주치는 문제가 N+1 쿼리입니다. 연관 엔티티를 조회할 때 예상치 못한 추가 쿼리가 N번 발생하여 성능을 크게 저하시킵니다. 이 글에서는 N+1 문제의 원인을 파악하고, 실무에서 가장 많이 쓰이는 두 가지 해결책인 Fetch Join과 EntityGraph를 코드 중심으로 비교합니다.
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 수를 측정하는 습관을 들이는 것이 중요합니다.
메인 이미지 출처 : 사진: Unsplash의 Growtika
태그: JPA, N+1, Fetch Join, EntityGraph, Spring Boot 카테고리: WEB/Spring
