Solusi N+1 di Spring Data JPA Tahun 2026: Fetch Join dan EntityGraph

Panduan lengkap untuk mendeteksi dan memperbaiki masalah N+1 di Spring Data JPA. Fetch join, @EntityGraph, batch fetching, dan strategi performa kueri.

Mengatasi masalah N+1 dengan Spring Data JPA menggunakan fetch join dan EntityGraph

Masalah N+1 merupakan salah satu jebakan performa paling umum di JPA. Sebuah kueri sederhana untuk mengambil 100 pesanan dapat memicu 101 kueri SQL: satu untuk pesanan, kemudian satu untuk setiap pelanggan terkait. Penggandaan kueri yang senyap ini menurunkan performa dan membebani database.

Dampak Nyata N+1

Sebuah endpoint yang mengembalikan 50 artikel beserta penulisnya dapat melonjak dari 10ms ke 500ms karena N+1. Deteksi dini mencegah masalah kritis di produksi.

Memahami Masalah N+1 di JPA

Masalah N+1 terjadi ketika JPA memuat koleksi entitas, lalu mengeksekusi kueri tambahan untuk setiap entitas guna memuat asosiasinya. Perilaku ini berasal dari lazy loading default pada relasi @OneToMany dan @ManyToMany.

Perhatikan model klasik dengan pesanan dan pelanggan. Setiap pesanan dimiliki oleh seorang pelanggan, dan relasi ini menggunakan lazy loading secara default.

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
}

Ketika sebuah kueri mengambil pesanan lalu mengakses nama pelanggan, Hibernate mengeksekusi kueri tambahan untuk setiap pesanan.

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();
    }
}

Untuk 100 pesanan, kode ini mengeksekusi 101 kueri SQL. Log Hibernate mengungkap pola destruktif ini.

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

Mendeteksi Masalah N+1 dengan Log Hibernate

Langkah pertama adalah mengaktifkan log SQL untuk mengidentifikasi kueri bermasalah. Konfigurasi berikut menampilkan setiap kueri yang dieksekusi oleh 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

Statistik Hibernate memberikan ringkasan berharga di akhir setiap transaksi.

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;
}

Jumlah 101 statement JDBC untuk daftar pesanan sederhana jelas menandakan masalah N+1.

Nonaktifkan di Produksi

Log SQL dan statistik berdampak pada performa. Opsi-opsi ini harus tetap dinonaktifkan di produksi dan hanya digunakan di lingkungan pengembangan dan pengujian.

Solusi 1: Fetch Join dengan JPQL

Fetch join memuat asosiasi dalam satu kueri SQL melalui sebuah join. Pendekatan eksplisit ini menyelesaikan N+1 dengan mengambil semua data yang diperlukan sekaligus.

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 mengubah N+1 kueri menjadi satu kueri yang dioptimalkan.

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

Service kini menggunakan metode yang dioptimalkan tanpa memodifikasi kode bisnis.

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();
    }
}

Siap menguasai wawancara Spring Boot Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Solusi 2: @EntityGraph untuk Kontrol Deklaratif

Anotasi @EntityGraph menawarkan alternatif deklaratif untuk fetch join. Anotasi ini mendefinisikan asosiasi mana yang harus dimuat tanpa menulis JPQL kustom.

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 bernama didefinisikan langsung pada entitas untuk digunakan kembali di beberapa repository.

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
}

Subgraph memungkinkan pemuatan asosiasi bertingkat. Contoh di atas memuat pesanan, item-itemnya, dan produk dari setiap item dalam satu kueri.

Solusi 3: Batch Fetching untuk Koleksi

Batch fetching menawarkan alternatif untuk fetch join pada koleksi @OneToMany. Alih-alih memuat setiap koleksi secara individual, Hibernate mengelompokkan kueri dalam batch.

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<>();
}

Dengan @BatchSize(size = 25), Hibernate memuat item untuk 25 pesanan sekaligus, bukan 1. Untuk 100 pesanan, jumlah kueri turun dari 101 menjadi 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)

Konfigurasi global ukuran batch berlaku untuk semua koleksi di aplikasi.

yaml
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        # Global default batch size
        default_batch_fetch_size: 25
Batch vs Fetch Join

Batch fetching cocok untuk kasus di mana fetch join menghasilkan produk Cartesian yang terlalu besar. Untuk pesanan dengan 10 item dan 5 pembayaran, fetch join mengembalikan 50 baris. Batch fetching mengeksekusi 2 kueri terpisah yang lebih efisien.

Membandingkan Strategi Pemuatan

Setiap strategi menawarkan keuntungan tergantung pada konteks penggunaan. Tabel berikut merangkum kasus penggunaan yang optimal.

| Strategi | Kasus Penggunaan | Keuntungan | Kerugian | |----------|----------|------------|---------------| | Fetch Join | Relasi @ManyToOne | Kueri SQL tunggal | Produk Cartesian dengan koleksi | | @EntityGraph | Pemuatan deklaratif | Dapat digunakan ulang, mudah dibaca | Kurang fleksibel daripada JPQL | | Batch Fetching | Koleksi @OneToMany | Menghindari produk Cartesian | Banyak kueri | | Subselect | Koleksi yang jarang diakses | Memuat hanya saat dibutuhkan | Subkueri terkorelasi |

Strategi subselect memuat seluruh koleksi pada akses pertama ke elemen mana pun.

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 > ?)

Menghindari N+1 dengan Proyeksi DTO

Proyeksi DTO menawarkan pendekatan radikal namun efektif. Dengan memilih hanya kolom yang diperlukan, proyeksi sepenuhnya menghindari pemuatan entitas dan asosiasinya.

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
    );
}

Pendekatan ini menghasilkan kueri SQL optimal tanpa overhead pemetaan entitas.

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 > ?

Konfigurasi Lanjutan dengan Spring Data JPA

Spring Data JPA 3.x memperkenalkan peningkatan untuk manajemen EntityGraph dan kueri yang dioptimalkan.

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
    );
}

Pemuatan kondisional memungkinkan penerapan strategi berbeda berdasarkan konteks.

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");
    }
}

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tes Performa untuk Mendeteksi N+1

Tes otomatis memverifikasi tidak adanya masalah N+1 dengan menghitung kueri SQL yang dieksekusi.

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();
    }
}

Asersi pada getQueryExecutionCount() memastikan optimasi tetap berlaku selama evolusi kode.

Kesimpulan

Masalah N+1 merupakan tantangan performa besar di Spring Data JPA, namun beberapa solusi efektif tersedia. Fetch join dan @EntityGraph menyelesaikan sebagian besar kasus dengan memuat asosiasi dalam satu kueri. Batch fetching menawarkan alternatif untuk koleksi besar di mana fetch join menghasilkan produk Cartesian.

Daftar Periksa Pencegahan N+1:

  • ✅ Aktifkan log SQL di pengembangan untuk mendeteksi kueri ganda
  • ✅ Gunakan JOIN FETCH untuk relasi @ManyToOne yang sering diakses
  • ✅ Terapkan @EntityGraph untuk pemuatan deklaratif yang dapat digunakan ulang
  • ✅ Konfigurasi @BatchSize untuk koleksi @OneToMany yang besar
  • ✅ Utamakan proyeksi DTO untuk operasi hanya-baca
  • ✅ Tulis tes yang memverifikasi jumlah kueri yang dieksekusi
  • ✅ Nonaktifkan log SQL dan statistik di produksi
  • ✅ Profil endpoint kritis secara teratur dengan metrik Hibernate

Tag

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

Bagikan

Artikel terkait