2026년 Spring Data JPA N+1 문제 해결법: Fetch Join과 EntityGraph

Spring Data JPA의 N+1 문제를 탐지하고 해결하기 위한 완벽 가이드입니다. Fetch join, @EntityGraph, batch fetching, 쿼리 성능 전략을 다룹니다.

fetch join과 EntityGraph를 사용하여 Spring Data JPA의 N+1 문제 해결

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을 사용합니다.

Order.javajava
@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<OrderItem> items = new ArrayList<>();

    // Getters and setters omitted
}
Customer.javajava
@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는 각 주문에 대해 추가 쿼리를 실행합니다.

OrderService.java - Problematic codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // 1 query: SELECT * FROM orders
        List<Order> 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 통계는 각 트랜잭션이 끝날 때 가치 있는 요약을 제공합니다.

text
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을 해결합니다.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Explicit fetch join to load customers
    @Query("SELECT o FROM Order o JOIN FETCH o.customer")
    List<Order> findAllWithCustomer();

    // Multiple fetch join for several associations
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "JOIN FETCH o.items i")
    List<Order> findAllWithCustomerAndItems();

    // Fetch join with WHERE condition
    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.customer c " +
           "WHERE o.createdAt > :since")
    List<Order> 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

이제 서비스는 비즈니스 코드를 수정하지 않고도 최적화된 메서드를 사용합니다.

OrderService.java - Optimized codejava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;

    public List<OrderDto> getAllOrders() {
        // Single query with join
        List<Order> orders = orderRepository.findAllWithCustomer();

        // No additional queries
        return orders.stream()
            .map(order -> new OrderDto(
                order.getId(),
                order.getOrderNumber(),
                order.getCustomer().getName() // Already loaded
            ))
            .toList();
    }
}

Spring Boot 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

해결책 2: 선언적 제어를 위한 @EntityGraph

@EntityGraph 어노테이션은 fetch join에 대한 선언적 대안을 제공합니다. 사용자 정의 JPQL을 작성하지 않고도 어떤 연관 관계를 로드할지 정의합니다.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Inline EntityGraph with attributePaths
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll();

    // EntityGraph with multiple attributes
    @EntityGraph(attributePaths = {"customer", "items"})
    List<Order> findByCreatedAtAfter(LocalDateTime since);

    // Named EntityGraph referencing entity definition
    @EntityGraph(value = "Order.withCustomerAndItems")
    List<Order> findByCustomerId(Long customerId);

    // Combination with custom query
    @EntityGraph(attributePaths = {"customer"})
    @Query("SELECT o FROM Order o WHERE o.orderNumber LIKE :prefix%")
    List<Order> findByOrderNumberPrefix(@Param("prefix") String prefix);
}

명명된 EntityGraph는 여러 리포지토리에서 재사용하기 위해 엔티티에 직접 정의됩니다.

Order.javajava
@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는 쿼리를 배치로 그룹화합니다.

Order.javajava
@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<OrderItem> 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 전략은 어떤 요소든 처음 접근할 때 전체 컬렉션을 로드합니다.

Order.javajava
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> 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 프로젝션은 급진적이지만 효과적인 접근 방식을 제공합니다. 필요한 컬럼만 선택함으로써, 프로젝션은 엔티티와 그 연관 관계의 로딩을 완전히 회피합니다.

OrderSummaryDto.javajava
public record OrderSummaryDto(
    Long orderId,
    String orderNumber,
    String customerName,
    String customerEmail
) {}
OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // 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<OrderSummaryDto> 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<OrderSummaryDto> 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 관리와 최적화된 쿼리에 대한 개선 사항을 도입합니다.

OrderRepository.javajava
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Dynamic EntityGraph with Specification
    @EntityGraph(attributePaths = {"customer"})
    List<Order> findAll(Specification<Order> spec);

    // Pagination with EntityGraph
    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByCustomerNameContaining(
        String name,
        Pageable pageable
    );

    // Slice for efficient pagination
    @EntityGraph(attributePaths = {"customer"})
    Slice<Order> findByCreatedAtBefore(
        LocalDateTime date,
        Pageable pageable
    );
}

조건부 로딩은 컨텍스트에 따라 다른 전략을 적용할 수 있게 해줍니다.

OrderService.javajava
@Service
@RequiredArgsConstructor
public class OrderService {

    private final OrderRepository orderRepository;
    private final EntityManager entityManager;

    public List<Order> 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<Order> getOrdersForListing() {
        // Minimal graph for listing
        return getOrdersWithGraph("Order.withCustomer");
    }

    public List<Order> getOrdersForDetail() {
        // Full graph for detail view
        return getOrdersWithGraph("Order.full");
    }
}

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

N+1 탐지를 위한 성능 테스트

자동화된 테스트는 실행된 SQL 쿼리 수를 카운트하여 N+1 문제가 없음을 확인합니다.

OrderRepositoryPerformanceTest.javajava
@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<Order> 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<Order> 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 메트릭으로 중요한 엔드포인트를 정기적으로 프로파일링

태그

#spring data jpa
#n+1 problem
#fetch join
#entitygraph
#performance

공유

관련 기사