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.

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.
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.
@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
}@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.
@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.
-- 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 queriesMendeteksi 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.
# 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: TRACEStatistik Hibernate memberikan ringkasan berharga di akhir setiap transaksi.
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.
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.
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.
-- 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.idService kini menggunakan metode yang dioptimalkan tanpa memodifikasi kode bisnis.
@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.
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.
@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.
@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.
-- 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.
# application.yml
spring:
jpa:
properties:
hibernate:
# Global default batch size
default_batch_fetch_size: 25Batch 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.
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items = new ArrayList<>();-- 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.
public record OrderSummaryDto(
Long orderId,
String orderNumber,
String customerName,
String customerEmail
) {}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.
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.
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.
@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.
@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 FETCHuntuk relasi@ManyToOneyang sering diakses - ✅ Terapkan
@EntityGraphuntuk pemuatan deklaratif yang dapat digunakan ulang - ✅ Konfigurasi
@BatchSizeuntuk koleksi@OneToManyyang 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
Bagikan
Artikel terkait

Wawancara Spring GraphQL: Resolver, DataLoader, dan Solusi Masalah N+1
Persiapkan diri untuk wawancara Spring GraphQL dengan panduan lengkap ini. Resolver, DataLoader, penanganan masalah N+1, mutation, dan praktik terbaik untuk pertanyaan teknis.

Spring Boot 3.4 Virtual Threads: Pertanyaan Wawancara dan Benchmark Performa
Kuasai Java 21 Virtual Threads dengan Spring Boot 3.4: 15 pertanyaan wawancara, benchmark performa, dan pola migrasi untuk lulus wawancara teknis.

Logging Spring Boot di 2026: log terstruktur produksi dengan Logback dan JSON
Panduan lengkap logging terstruktur di Spring Boot. Konfigurasi Logback JSON, MDC untuk tracing, praktik terbaik di produksi, dan integrasi ELK Stack.