Masalah performa pada endpoint Spring Boot sering terlihat sederhana di level API, misalnya response tiba-tiba lambat saat jumlah data bertambah. Namun di baliknya, penyebab yang sangat umum adalah N+1 query di JPA/Hibernate dan index database yang tidak terpakai. Dua masalah ini sering muncul bersamaan: aplikasi menembakkan terlalu banyak query kecil, sementara query utamanya sendiri membaca data lebih banyak dari yang seharusnya karena strategi index yang kurang tepat.

Jika Anda memakai Spring Data JPA, tanda-tandanya biasanya cukup jelas: log SQL menunjukkan pola query berulang, metrik request memperlihatkan lonjakan latency pada endpoint tertentu, dan hasil EXPLAIN atau EXPLAIN ANALYZE menunjukkan scan yang mahal atau filter/sort yang tidak menggunakan index secara efektif. Artikel ini fokus pada gejala nyata, cara diagnosis, dan perbaikan yang praktis.

Gejala Umum di Produksi

Masalah N+1 dan index yang tidak terpakai jarang terlihat pada data kecil. Gejalanya baru terasa ketika volume data bertambah, relasi makin banyak diakses, atau endpoint menerima kombinasi filter dan sorting yang lebih kompleks.

Tanda endpoint terkena N+1 query

  • Satu request menghasilkan puluhan hingga ratusan query SQL.
  • Waktu response naik seiring jumlah item dalam list.
  • CPU aplikasi dan koneksi database meningkat meski query individu terlihat sederhana.
  • Log Hibernate menunjukkan pola query yang sama dieksekusi berulang untuk ID berbeda.

Tanda index tidak terpakai

  • Query list dengan filter atau sorting lambat meski tabel sudah diberi index.
  • EXPLAIN menunjukkan full table scan, sequential scan, atau penggunaan index yang kurang selektif.
  • Sorting memicu langkah tambahan yang mahal karena urutan index tidak cocok dengan query.
  • Composite index ada, tetapi kolom query tidak mengikuti urutan yang efektif.

Cara Mengidentifikasi: Log SQL, Metrik, dan EXPLAIN

1. Aktifkan log SQL secukupnya

Untuk diagnosis lokal atau staging, aktifkan log SQL dan parameter binding. Hindari membiarkan log terlalu verbose di produksi tanpa kontrol karena bisa membebani aplikasi dan menghasilkan log yang sulit dibaca.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE

Pada beberapa setup Hibernate, nama logger untuk binding parameter bisa berbeda. Jika tidak muncul, tetap fokus pada prinsipnya: Anda perlu melihat query yang dieksekusi dan seberapa sering query itu muncul.

2. Pantau metrik request lambat

Jangan hanya melihat rata-rata response time. Endpoint yang terkena N+1 biasanya punya pola seperti berikut:

  • Latency naik tajam pada halaman dengan ukuran data lebih besar.
  • P95/P99 jauh lebih buruk dibanding rata-rata.
  • Jumlah query per request meningkat mengikuti jumlah baris hasil utama.

Jika Anda memakai actuator, APM, atau interceptor sendiri, catat minimal:

  • nama endpoint,
  • durasi request,
  • jumlah query SQL per request bila memungkinkan,
  • waktu eksekusi query yang paling mahal.

3. Gunakan EXPLAIN atau EXPLAIN ANALYZE

Begitu query yang lambat teridentifikasi, jalankan EXPLAIN atau EXPLAIN ANALYZE langsung di database. Tujuannya bukan sekadar melihat apakah index dipakai, tetapi memahami mengapa optimizer memilih rencana tertentu.

EXPLAIN ANALYZE
SELECT o.id, o.customer_id, o.status, o.created_at
FROM orders o
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 20;

Perhatikan beberapa hal:

  • Apakah planner melakukan full scan atau index scan.
  • Apakah filtering terjadi setelah banyak data dibaca.
  • Apakah sorting terjadi terpisah karena index tidak cocok.
  • Apakah jumlah row yang diproses jauh lebih besar daripada hasil akhir.

Catatan: Index ada belum tentu dipakai. Optimizer bisa memilih scan biasa jika index tidak selektif, urutan kolom tidak cocok, atau query melakukan fungsi/operasi yang membuat index sulit dimanfaatkan.

Contoh Kasus: Endpoint Order Melambat

Misalkan ada endpoint yang menampilkan daftar order beserta nama customer dan item di dalamnya. Di level kode, semuanya tampak normal. Masalah muncul saat jumlah order per halaman membesar.

Entity yang relevan

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "created_at")
    private Instant createdAt;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    private Long id;

    private String name;
}

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private String sku;
    private Integer quantity;
}

Query repository yang tampak aman, tetapi memicu N+1

public interface OrderRepository extends JpaRepository<Order, Long> {

    Page<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status, Pageable pageable);
}

Di service atau controller:

Page<Order> orders = orderRepository.findByStatusOrderByCreatedAtDesc(OrderStatus.PAID, pageable);

return orders.getContent().stream()
    .map(order -> new OrderResponse(
        order.getId(),
        order.getCustomer().getName(),
        order.getItems().size(),
        order.getCreatedAt()))
    .toList();

Masalahnya ada pada akses order.getCustomer().getName() dan order.getItems().size(). Karena relasi lazy, Hibernate dapat mengeksekusi query tambahan untuk setiap order. Jika ada 20 order di satu halaman, pola yang muncul bisa menjadi:

  • 1 query untuk mengambil daftar order,
  • 20 query untuk customer,
  • 20 query untuk items.

Itulah bentuk klasik 1 + N + N.

Log SQL yang perlu dicurigai

Biasanya Anda akan melihat pola seperti ini berulang:

select o.id, o.customer_id, o.status, o.created_at
from orders o
where o.status = ?
order by o.created_at desc
limit ?

select c.id, c.name
from customers c
where c.id = ?

select oi.id, oi.order_id, oi.sku, oi.quantity
from order_items oi
where oi.order_id = ?

Jika query kedua dan ketiga muncul berkali-kali untuk request yang sama, Anda hampir pasti sedang berhadapan dengan N+1.

Penyebab Umum N+1 Query di JPA/Hibernate

Lazy loading yang diakses saat serialisasi atau mapping DTO

Relasi LAZY bukan masalah. Justru ini default yang sering lebih aman daripada selalu EAGER. Yang bermasalah adalah ketika relasi lazy diakses secara tidak sadar di loop, mapper, serializer JSON, atau method utility seperti size().

Relasi one-to-many pada halaman list

Relasi one-to-many sering menjadi sumber ledakan query karena satu entitas utama bisa punya banyak child. Mengambil semuanya sekaligus untuk halaman list juga tidak selalu tepat karena jumlah row hasil join bisa membengkak.

Mapping entity terlalu dekat dengan kebutuhan response

Jika endpoint hanya butuh beberapa kolom, mengambil entity penuh beserta relasinya sering lebih mahal daripada memakai projection atau DTO query yang langsung sesuai kebutuhan response.

Perbaikan N+1: Pilih Teknik yang Sesuai

1. Fetch join untuk relasi yang benar-benar dibutuhkan

Fetch join cocok ketika Anda tahu relasi tertentu pasti diperlukan dalam query tersebut. Ini mengurangi query tambahan dengan mengambil data terkait dalam satu query.

@Query("""
    select o from Order o
    join fetch o.customer
    where o.status = :status
    order by o.createdAt desc
""")
List<Order> findRecentByStatusWithCustomer(@Param("status") OrderStatus status, Pageable pageable);

Untuk relasi many-to-one seperti customer, pendekatan ini biasanya aman dan efektif. Namun untuk one-to-many seperti items, Anda perlu hati-hati.

Trade-off fetch join pada one-to-many

Jika satu order punya banyak item, join fetch bisa menghasilkan duplikasi row pada hasil SQL karena satu order akan muncul sekali untuk setiap item. Dampaknya:

  • trafik data dari database ke aplikasi lebih besar,
  • memori aplikasi naik karena result set membengkak,
  • pagination menjadi rumit atau tidak akurat pada beberapa kasus.

Karena itu, fetch join tidak selalu cocok untuk collection pada endpoint list berpaginasi.

2. EntityGraph untuk mengontrol relasi yang dimuat

@EntityGraph memberi cara yang lebih deklaratif untuk meminta relasi tertentu dimuat bersama query utama.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"customer"})
    Page<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status, Pageable pageable);
}

Pendekatan ini cocok jika Anda ingin menjaga method repository tetap ringkas. Untuk relasi sederhana seperti many-to-one, hasilnya sering cukup baik. Tetap evaluasi SQL yang dihasilkan, jangan mengasumsikan selalu optimal di semua kasus.

3. Projection atau DTO query untuk endpoint read-only

Jika endpoint hanya butuh subset data, projection sering menjadi solusi paling bersih. Anda menghindari loading entity penuh dan mengurangi risiko akses lazy yang tidak disengaja.

public record OrderSummaryResponse(
    Long orderId,
    String customerName,
    Instant createdAt
) {}

@Query("""
    select new com.example.api.OrderSummaryResponse(
        o.id,
        c.name,
        o.createdAt
    )
    from Order o
    join o.customer c
    where o.status = :status
    order by o.createdAt desc
""")
Page<OrderSummaryResponse> findOrderSummariesByStatus(
    @Param("status") OrderStatus status,
    Pageable pageable
);

Untuk jumlah item per order, sering lebih baik dihitung lewat query agregasi terpisah atau query khusus daripada memuat seluruh koleksi item hanya untuk memanggil size().

4. Batch fetching untuk mengurangi query berulang

Batch fetching tidak menghilangkan N+1 sepenuhnya, tetapi dapat mengubah banyak query kecil menjadi lebih sedikit query yang mengambil beberapa relasi sekaligus. Ini berguna saat Anda belum bisa atau belum ingin mengubah query utama menjadi fetch join atau projection.

Secara konsep, Hibernate dapat mengambil beberapa relasi lazy dalam batch ketika entitas-entitas itu diakses. Ini sering membantu pada relasi many-to-one atau koleksi tertentu. Namun hasilnya tetap perlu diverifikasi lewat log SQL karena efektivitasnya bergantung pada pola akses data.

Kapan dipakai: gunakan batch fetching saat Anda butuh kompromi yang lebih aman terhadap memori dan struktur query, terutama bila fetch join pada collection akan membuat result set terlalu besar.

Index Database Tidak Terpakai: Penyebab dan Perbaikannya

Setelah N+1 dikurangi, bottleneck berikutnya sering terlihat lebih jelas: query utama tetap lambat karena filter dan sort tidak didukung index yang tepat.

Kasus umum: filter dan sort tidak sesuai index

Misalnya endpoint mengambil order berdasarkan status dan diurutkan berdasarkan waktu pembuatan:

SELECT o.id, o.customer_id, o.status, o.created_at
FROM orders o
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC
LIMIT 20;

Jika hanya ada index pada created_at atau hanya pada status, hasilnya belum tentu optimal. Query ini sering lebih terbantu oleh composite index yang mengikuti pola filter lalu sorting, misalnya pada kolom (status, created_at).

Mengapa urutan kolom composite index penting

Urutan kolom dalam composite index bukan detail kecil. Query yang memfilter status lalu mengurutkan created_at biasanya lebih cocok dengan index (status, created_at) dibanding (created_at, status). Jika urutannya terbalik, optimizer mungkin tidak bisa memanfaatkan index secara efektif untuk kebutuhan filter sekaligus sort.

Contoh mismatch yang sering terjadi:

  • Query: WHERE status = ? ORDER BY created_at DESC
  • Index yang ada: (created_at, status)

Index tersebut belum tentu membantu sesuai harapan karena kolom pertama pada index tidak sejalan dengan pola pencarian utama query.

Contoh desain index yang lebih relevan

CREATE INDEX idx_orders_status_created_at
ON orders (status, created_at);

Untuk query lain, misalnya pencarian order customer tertentu dengan filter status:

SELECT o.id, o.status, o.created_at
FROM orders o
WHERE o.customer_id = ? AND o.status = ?
ORDER BY o.created_at DESC;

Desain index yang relevan bisa berbeda, misalnya:

CREATE INDEX idx_orders_customer_status_created_at
ON orders (customer_id, status, created_at);

Intinya, desain index harus mengikuti pola query yang nyata, bukan sekadar menambahkan index pada setiap kolom.

Kapan index tetap tidak dipakai meski sudah dibuat

  • Kolom yang difilter dibungkus fungsi atau transformasi tertentu.
  • Kondisi query tidak selektif sehingga optimizer memilih scan biasa.
  • Urutan kolom pada composite index tidak cocok.
  • Query melakukan sorting pada kolom yang tidak tersambung efektif dengan filter di index.
  • Statistik database belum akurat sehingga planner memilih rencana yang kurang baik.

Contoh Diagnosis Gabungan: N+1 dan Index

Bayangkan endpoint GET /orders?status=PAID lambat. Setelah diperiksa:

  1. Log SQL menunjukkan 1 query order + banyak query customer dan items.
  2. EXPLAIN ANALYZE untuk query order utama menunjukkan scan mahal karena sorting dan filtering tidak didukung index yang sesuai.

Perbaikannya jangan hanya di satu sisi. Jika Anda hanya menambah index, jumlah query masih terlalu banyak. Jika Anda hanya memperbaiki N+1, query utama masih bisa tetap mahal. Hasil terbaik biasanya datang dari kombinasi:

  • mengurangi jumlah query di level ORM,
  • membuat query utama lebih ramah terhadap index.

Optimasi di Query atau di Skema?

Fokus ke query saat

  • Masalah utama adalah ledakan jumlah query karena lazy loading.
  • Endpoint hanya butuh sebagian kecil kolom.
  • Query saat ini tidak efisien karena memuat relasi yang tidak diperlukan.

Fokus ke skema/index saat

  • Jumlah query sudah masuk akal, tetapi query utama masih lambat.
  • EXPLAIN menunjukkan scan atau sort yang mahal.
  • Pola filter dan sorting pada endpoint sudah stabil dan sering dipakai.

Sering kali perlu keduanya

Pada aplikasi nyata, bottleneck jarang tunggal. Optimasi query ORM tanpa melihat rencana eksekusi SQL bisa membuat Anda hanya memindahkan masalah. Sebaliknya, optimasi index tanpa mengurangi N+1 juga sering tidak cukup.

Trade-off yang Harus Dipahami

Fetch join vs memory

Fetch join mengurangi jumlah round trip ke database, tetapi dapat memperbesar result set. Untuk relasi collection, ini bisa membuat penggunaan memori dan bandwidth antar aplikasi-database meningkat.

Projection vs fleksibilitas entity

Projection sangat efisien untuk endpoint baca, tetapi tidak cocok jika Anda memang perlu lifecycle entity penuh atau akan memodifikasi data yang sama di konteks persistence yang aktif.

Batch fetching vs satu query besar

Batch fetching sering menjadi jalan tengah. Jumlah query turun, tetapi tidak selalu seminimal fetch join. Kelebihannya, risiko ledakan row dari join collection bisa lebih terkendali.

Terlalu banyak index juga ada biaya

Setiap index tambahan meningkatkan biaya write: insert, update, dan maintenance. Jangan menambah index tanpa memverifikasi bahwa query penting benar-benar mendapat manfaat darinya.

Checklist Diagnosis dan Verifikasi Sebelum-Sesudah

Checklist diagnosis

  • Apakah endpoint lambat hanya pada dataset besar atau page size tertentu?
  • Apakah log SQL menunjukkan pola query berulang untuk relasi yang sama?
  • Apakah relasi lazy diakses di loop, mapper DTO, atau serializer?
  • Apakah query utama melakukan filter dan sort pada kolom yang belum punya index relevan?
  • Apakah urutan kolom composite index sesuai dengan pola WHERE dan ORDER BY?
  • Apakah hasil EXPLAIN menunjukkan scan atau sort yang mahal?

Checklist perbaikan

  • Gunakan fetch join atau @EntityGraph untuk relasi yang benar-benar diperlukan.
  • Pakai projection/DTO query untuk endpoint read-only agar hanya mengambil kolom yang dibutuhkan.
  • Pertimbangkan batch fetching jika fetch join pada collection terlalu berat.
  • Rancang index berdasarkan pola query nyata, bukan asumsi.
  • Periksa kembali urutan kolom pada composite index.

Checklist verifikasi hasil

  • Bandingkan jumlah query per request sebelum dan sesudah.
  • Bandingkan durasi endpoint pada data dan page size yang sama.
  • Jalankan ulang EXPLAIN atau EXPLAIN ANALYZE setelah perubahan index/query.
  • Perhatikan apakah penggunaan memori naik akibat fetch join pada collection.
  • Pastikan pagination dan hasil response tetap benar setelah optimasi.

Penutup

Untuk mengatasi bottleneck SQL nyata di Spring Boot, jangan berhenti pada satu lapisan. N+1 query JPA perlu ditangani dari cara Anda memuat relasi, sementara index yang tidak terpakai harus diperiksa dari pola query dan hasil EXPLAIN. Mulailah dari gejala: request lambat, query berulang di log, dan rencana eksekusi yang tidak efisien. Setelah itu, pilih perbaikan yang tepat: fetch join, EntityGraph, projection, batch fetching, atau redesign index.

Ukuran keberhasilan optimasi bukan sekadar kode yang terlihat lebih rapi, tetapi jumlah query yang turun, rencana eksekusi yang membaik, dan latency endpoint yang benar-benar lebih rendah pada skenario data nyata.