# 2026년 Spring Data JPA N+1 문제 해결법: Fetch Join과 EntityGraph > Spring Data JPA의 N+1 문제를 탐지하고 해결하기 위한 완벽 가이드입니다. Fetch join, @EntityGraph, batch fetching, 쿼리 성능 전략을 다룹니다. - Published: 2026-03-17 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring data jpa, n+1 problem, fetch join, entitygraph, performance - Reading time: 12 min --- N+1 문제는 JPA에서 가장 흔한 성능 함정 중 하나입니다. 100개의 주문을 가져오는 단순한 쿼리가 101개의 SQL 쿼리를 유발할 수 있습니다. 주문에 대해 1개, 그리고 연관된 각 고객에 대해 1개씩입니다. 이 조용한 쿼리 증식은 성능을 저하시키고 데이터베이스에 과부하를 일으킵니다. > **N+1의 실제 영향** > > 저자와 함께 50개의 기사를 반환하는 엔드포인트가 N+1 때문에 10ms에서 500ms로 증가할 수 있습니다. 조기 발견이 운영 환경에서의 심각한 문제를 예방합니다. ## JPA에서의 N+1 문제 이해 N+1 문제는 JPA가 엔티티 컬렉션을 로드한 후 각 엔티티의 연관 관계를 로드하기 위해 추가 쿼리를 실행할 때 발생합니다. 이 동작은 `@OneToMany`와 `@ManyToMany` 관계의 기본 lazy loading에서 비롯됩니다. 주문과 고객이 있는 고전적인 모델을 고려해 봅시다. 각 주문은 한 고객에게 속하며, 이 관계는 기본적으로 lazy loading을 사용합니다. ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; private LocalDateTime createdAt; // ManyToOne relationship is lazy by default since JPA 2.0 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // OneToMany relationship lazy by default @OneToMany(mappedBy = "order", fetch = FetchType.LAZY) private List items = new ArrayList<>(); // Getters and setters omitted } ``` ```java // Customer.java @Entity @Table(name = "customers") public class Customer { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; // Getters and setters omitted } ``` 쿼리가 주문을 가져온 다음 고객 이름에 접근할 때, Hibernate는 각 주문에 대해 추가 쿼리를 실행합니다. ```java // OrderService.java - Problematic code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // 1 query: SELECT * FROM orders List orders = orderRepository.findAll(); // For each order, accessing customer triggers a query return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), // N queries: SELECT * FROM customers WHERE id = ? order.getCustomer().getName() )) .toList(); } } ``` 100개의 주문에 대해 이 코드는 101개의 SQL 쿼리를 실행합니다. Hibernate 로그가 이 파괴적인 패턴을 드러냅니다. ```sql -- Query 1: fetch orders SELECT o.id, o.order_number, o.created_at, o.customer_id FROM orders o -- Queries 2-101: fetch each customer SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 1 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 2 SELECT c.id, c.name, c.email FROM customers c WHERE c.id = 3 -- ... 97 more identical queries ``` ## Hibernate 로그로 N+1 문제 탐지 첫 번째 단계는 SQL 로그를 활성화하여 문제가 있는 쿼리를 식별하는 것입니다. 다음 설정은 Hibernate가 실행하는 모든 쿼리를 표시합니다. ```yaml # application.yml spring: jpa: show-sql: true properties: hibernate: # Format SQL for better readability format_sql: true # Display session statistics (queries, time) generate_statistics: true logging: level: # Detailed SQL query logging org.hibernate.SQL: DEBUG # Display prepared statement parameters org.hibernate.orm.jdbc.bind: TRACE ``` Hibernate 통계는 각 트랜잭션이 끝날 때 가치 있는 요약을 제공합니다. ``` Session Metrics { 23421 nanoseconds spent acquiring 1 JDBC connection; 0 nanoseconds spent releasing 0 JDBC connections; 1254789 nanoseconds spent preparing 101 JDBC statements; 15478963 nanoseconds spent executing 101 JDBC statements; 0 nanoseconds spent executing 0 JDBC batches; } ``` 간단한 주문 목록에 대한 101개의 JDBC 명령문 수는 N+1 문제를 명확하게 알려줍니다. > **운영 환경에서는 비활성화** > > SQL 로그와 통계는 성능에 영향을 줍니다. 이 옵션들은 운영 환경에서는 비활성화하고 개발 및 테스트 환경에서만 사용해야 합니다. ## 해결책 1: JPQL을 사용한 Fetch Join Fetch join은 join을 통해 단일 SQL 쿼리로 연관 관계를 로드합니다. 이 명시적인 접근 방식은 필요한 모든 데이터를 한 번에 가져와서 N+1을 해결합니다. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Explicit fetch join to load customers @Query("SELECT o FROM Order o JOIN FETCH o.customer") List findAllWithCustomer(); // Multiple fetch join for several associations @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "JOIN FETCH o.items i") List findAllWithCustomerAndItems(); // Fetch join with WHERE condition @Query("SELECT o FROM Order o " + "JOIN FETCH o.customer c " + "WHERE o.createdAt > :since") List findRecentOrdersWithCustomer( @Param("since") LocalDateTime since ); } ``` Fetch join은 N+1 쿼리를 단일 최적화된 쿼리로 변환합니다. ```sql -- Single query with join SELECT o.id, o.order_number, o.created_at, o.customer_id, c.id, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id ``` 이제 서비스는 비즈니스 코드를 수정하지 않고도 최적화된 메서드를 사용합니다. ```java // OrderService.java - Optimized code @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; public List getAllOrders() { // Single query with join List orders = orderRepository.findAllWithCustomer(); // No additional queries return orders.stream() .map(order -> new OrderDto( order.getId(), order.getOrderNumber(), order.getCustomer().getName() // Already loaded )) .toList(); } } ``` ## 해결책 2: 선언적 제어를 위한 @EntityGraph `@EntityGraph` 어노테이션은 fetch join에 대한 선언적 대안을 제공합니다. 사용자 정의 JPQL을 작성하지 않고도 어떤 연관 관계를 로드할지 정의합니다. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Inline EntityGraph with attributePaths @EntityGraph(attributePaths = {"customer"}) List findAll(); // EntityGraph with multiple attributes @EntityGraph(attributePaths = {"customer", "items"}) List findByCreatedAtAfter(LocalDateTime since); // Named EntityGraph referencing entity definition @EntityGraph(value = "Order.withCustomerAndItems") List findByCustomerId(Long customerId); // Combination with custom query @EntityGraph(attributePaths = {"customer"}) @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%") List findByOrderNumberPrefix(@Param("prefix") String prefix); } ``` 명명된 EntityGraph는 여러 리포지토리에서 재사용하기 위해 엔티티에 직접 정의됩니다. ```java // Order.java @Entity @Table(name = "orders") @NamedEntityGraph( name = "Order.withCustomer", attributeNodes = @NamedAttributeNode("customer") ) @NamedEntityGraph( name = "Order.withCustomerAndItems", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode("items") } ) @NamedEntityGraph( name = "Order.full", attributeNodes = { @NamedAttributeNode("customer"), @NamedAttributeNode(value = "items", subgraph = "items-product") }, subgraphs = @NamedSubgraph( name = "items-product", attributeNodes = @NamedAttributeNode("product") ) ) public class Order { // Fields unchanged } ``` 서브그래프는 중첩된 연관 관계 로딩을 가능하게 합니다. 위의 예시는 주문, 그 항목들, 그리고 각 항목의 제품을 단일 쿼리로 로드합니다. ## 해결책 3: 컬렉션을 위한 Batch Fetching Batch fetching은 `@OneToMany` 컬렉션에 대해 fetch join의 대안을 제공합니다. 각 컬렉션을 개별적으로 로드하는 대신, Hibernate는 쿼리를 배치로 그룹화합니다. ```java // Order.java @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String orderNumber; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer; // Batch fetching on the collection @OneToMany(mappedBy = "order") @BatchSize(size = 25) private List items = new ArrayList<>(); } ``` `@BatchSize(size = 25)`을 사용하면 Hibernate는 1개가 아닌 25개의 주문에 대한 항목을 한 번에 로드합니다. 100개의 주문에 대해 쿼리 수가 101에서 5로 감소합니다. ```sql -- Without batch fetching: 100 queries SELECT * FROM order_items WHERE order_id = 1 SELECT * FROM order_items WHERE order_id = 2 -- ... 98 more queries -- With @BatchSize(size = 25): 4 queries SELECT * FROM order_items WHERE order_id IN (1, 2, 3, ..., 25) SELECT * FROM order_items WHERE order_id IN (26, 27, 28, ..., 50) SELECT * FROM order_items WHERE order_id IN (51, 52, 53, ..., 75) SELECT * FROM order_items WHERE order_id IN (76, 77, 78, ..., 100) ``` 전역 배치 크기 설정은 애플리케이션 내의 모든 컬렉션에 적용됩니다. ```yaml # application.yml spring: jpa: properties: hibernate: # Global default batch size default_batch_fetch_size: 25 ``` > **Batch와 Fetch Join 비교** > > Batch fetching은 fetch join이 너무 큰 데카르트 곱을 생성하는 경우에 적합합니다. 10개의 항목과 5개의 결제가 있는 주문의 경우, fetch join은 50개의 행을 반환합니다. Batch fetching은 더 효율적인 2개의 별도 쿼리를 실행합니다. ## 로딩 전략 비교 각 전략은 사용 컨텍스트에 따라 장점을 제공합니다. 다음 표는 최적의 사용 사례를 요약합니다. | 전략 | 사용 사례 | 장점 | 단점 | |----------|----------|------------|---------------| | **Fetch Join** | `@ManyToOne` 관계 | 단일 SQL 쿼리 | 컬렉션과 데카르트 곱 | | **@EntityGraph** | 선언적 로딩 | 재사용 가능, 가독성 좋음 | JPQL보다 덜 유연 | | **Batch Fetching** | `@OneToMany` 컬렉션 | 데카르트 곱 회피 | 다수의 쿼리 | | **Subselect** | 자주 접근하지 않는 컬렉션 | 필요할 때만 로드 | 상관 서브쿼리 | Subselect 전략은 어떤 요소든 처음 접근할 때 전체 컬렉션을 로드합니다. ```java // Order.java @OneToMany(mappedBy = "order") @Fetch(FetchMode.SUBSELECT) private List items = new ArrayList<>(); ``` ```sql -- Generated subselect query SELECT * FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE created_at > ?) ``` ## DTO 프로젝션으로 N+1 회피 DTO 프로젝션은 급진적이지만 효과적인 접근 방식을 제공합니다. 필요한 컬럼만 선택함으로써, 프로젝션은 엔티티와 그 연관 관계의 로딩을 완전히 회피합니다. ```java // OrderSummaryDto.java public record OrderSummaryDto( Long orderId, String orderNumber, String customerName, String customerEmail ) {} ``` ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // DTO projection with constructor @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c") List findAllOrderSummaries(); // DTO projection with condition @Query("SELECT new com.example.dto.OrderSummaryDto(" + "o.id, o.orderNumber, c.name, c.email) " + "FROM Order o JOIN o.customer c " + "WHERE o.createdAt > :since") List findRecentOrderSummaries( @Param("since") LocalDateTime since ); } ``` 이 접근 방식은 엔티티 매핑 오버헤드 없이 최적의 SQL 쿼리를 생성합니다. ```sql SELECT o.id, o.order_number, c.name, c.email FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.created_at > ? ``` ## Spring Data JPA의 고급 설정 Spring Data JPA 3.x는 EntityGraph 관리와 최적화된 쿼리에 대한 개선 사항을 도입합니다. ```java // OrderRepository.java public interface OrderRepository extends JpaRepository { // Dynamic EntityGraph with Specification @EntityGraph(attributePaths = {"customer"}) List findAll(Specification spec); // Pagination with EntityGraph @EntityGraph(attributePaths = {"customer"}) Page findByCustomerNameContaining( String name, Pageable pageable ); // Slice for efficient pagination @EntityGraph(attributePaths = {"customer"}) Slice findByCreatedAtBefore( LocalDateTime date, Pageable pageable ); } ``` 조건부 로딩은 컨텍스트에 따라 다른 전략을 적용할 수 있게 해줍니다. ```java // OrderService.java @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final EntityManager entityManager; public List getOrdersWithGraph(String graphName) { // Dynamic EntityGraph retrieval EntityGraph graph = entityManager .getEntityGraph(graphName); return entityManager .createQuery("SELECT o FROM Order o", Order.class) .setHint("jakarta.persistence.loadgraph", graph) .getResultList(); } public List getOrdersForListing() { // Minimal graph for listing return getOrdersWithGraph("Order.withCustomer"); } public List getOrdersForDetail() { // Full graph for detail view return getOrdersWithGraph("Order.full"); } } ``` ## N+1 탐지를 위한 성능 테스트 자동화된 테스트는 실행된 SQL 쿼리 수를 카운트하여 N+1 문제가 없음을 확인합니다. ```java // OrderRepositoryPerformanceTest.java @DataJpaTest @AutoConfigureTestDatabase(replace = Replace.NONE) class OrderRepositoryPerformanceTest { @Autowired private OrderRepository orderRepository; @Autowired private EntityManager entityManager; @PersistenceContext private EntityManager em; private Statistics statistics; @BeforeEach void setUp() { // Enable Hibernate statistics Session session = entityManager.unwrap(Session.class); SessionFactory factory = session.getSessionFactory(); statistics = factory.getStatistics(); statistics.setStatisticsEnabled(true); statistics.clear(); } @Test void findAllWithCustomer_shouldExecuteSingleQuery() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch with fetch join List orders = orderRepository.findAllWithCustomer(); // Then: single query executed assertThat(orders).hasSize(50); assertThat(statistics.getQueryExecutionCount()) .as("Should execute only 1 query with fetch join") .isEqualTo(1); } @Test void findAll_withoutOptimization_triggersNPlus1() { // Given: 50 orders in database createTestOrders(50); entityManager.clear(); statistics.clear(); // When: fetch without optimization List orders = orderRepository.findAll(); // Access customers to trigger lazy loading orders.forEach(o -> o.getCustomer().getName()); // Then: N+1 queries (51 instead of 1) assertThat(statistics.getQueryExecutionCount()) .as("Should detect N+1 problem") .isGreaterThan(1); } private void createTestOrders(int count) { for (int i = 0; i < count; i++) { Customer customer = new Customer(); customer.setName("Customer " + i); customer.setEmail("customer" + i + "@test.com"); entityManager.persist(customer); Order order = new Order(); order.setOrderNumber("ORD-" + i); order.setCustomer(customer); order.setCreatedAt(LocalDateTime.now()); entityManager.persist(order); } entityManager.flush(); } } ``` `getQueryExecutionCount()`에 대한 단언은 코드 진화 중에도 최적화가 유지되도록 보장합니다. ## 결론 N+1 문제는 Spring Data JPA에서 주요한 성능 도전 과제이지만, 여러 효과적인 해결책이 존재합니다. Fetch join과 `@EntityGraph`는 단일 쿼리에서 연관 관계를 로드하여 대부분의 경우를 해결합니다. Batch fetching은 fetch join이 데카르트 곱을 생성하는 큰 컬렉션에 대한 대안을 제공합니다. **N+1 예방 체크리스트:** - ✅ 개발 환경에서 SQL 로그를 활성화하여 다중 쿼리 탐지 - ✅ 자주 접근하는 `@ManyToOne` 관계에 `JOIN FETCH` 사용 - ✅ 재사용 가능한 선언적 로딩을 위해 `@EntityGraph` 적용 - ✅ 큰 `@OneToMany` 컬렉션에 `@BatchSize` 설정 - ✅ 읽기 전용 작업에는 DTO 프로젝션 선호 - ✅ 실행되는 쿼리 수를 검증하는 테스트 작성 - ✅ 운영 환경에서는 SQL 로그와 통계 비활성화 - ✅ Hibernate 메트릭으로 중요한 엔드포인트를 정기적으로 프로파일링 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/spring-boot/spring-data-jpa-n-plus-1-fetch-join-entitygraph