Saat endpoint list di Spring Boot mulai melambat pada tabel besar, penyebabnya biasanya bukan satu hal. Kombinasi offset pagination yang makin mahal, query count tambahan dari JPA, sorting tanpa index, dan pengambilan data yang berlebihan dari Hibernate sering menjadi bottleneck utama. Jika tidak dibaca sampai level SQL dan rencana eksekusi database, optimasi sering salah sasaran.

Artikel ini membahas optimasi pagination dan index JPA di Spring Boot untuk data besar dengan sudut pandang yang praktis. Fokusnya adalah mempercepat endpoint list yang digunakan terus-menerus, memahami gejala dan akar masalahnya, lalu memilih teknik yang tepat: tetap memakai offset pagination, beralih ke keyset/cursor pagination, atau memperbaiki desain index dan query.

Gejala yang Umum Muncul di Endpoint List

Pada tahap awal, endpoint list biasanya terlihat baik-baik saja. Masalah baru terasa setelah jumlah baris naik, filter bertambah, dan kebutuhan sorting makin kompleks. Gejala yang paling sering muncul adalah:

  • Response time meningkat di halaman besar, misalnya page 1 cepat tetapi page 500 jauh lebih lambat.
  • CPU dan I/O database naik saat traffic list endpoint meningkat.
  • Query count sangat mahal karena setiap request pagination memicu dua query: satu untuk data, satu untuk total jumlah baris.
  • Sorting lambat ketika kolom ORDER BY tidak memiliki index yang sesuai.
  • Payload dan memory usage membesar karena entity lengkap beserta relasi ikut diambil padahal UI hanya butuh beberapa field.

Jika Anda melihat endpoint GET /orders atau GET /transactions menjadi lambat seiring pertumbuhan data, hampir pasti salah satu atau beberapa faktor di atas sedang terjadi.

Akar Masalah: Kenapa Makin Lambat?

1. Offset pagination memaksa database melewati banyak baris

Pagination berbasis offset, misalnya LIMIT 20 OFFSET 100000, terlihat sederhana tetapi mahal pada data besar. Database tetap harus menemukan, memindai, dan melewati banyak baris sebelum mengembalikan 20 data terakhir yang diminta. Walaupun ada index, biaya untuk melompati baris tetap bisa signifikan tergantung filter dan sorting.

Masalahnya makin terasa ketika user sering membuka halaman belakang, atau ketika API internal memproses banyak page secara berurutan.

2. Query count dari Page<T> tidak selalu murah

Di Spring Data JPA, penggunaan Page<T> biasanya menghasilkan dua query:

  1. query utama untuk mengambil data halaman saat ini,
  2. query count untuk menghitung total record.

Untuk tabel besar dengan filter kompleks, count(*) juga bisa mahal. Ini terutama terasa jika query melibatkan join, kondisi dinamis, atau database tidak bisa memanfaatkan index secara efektif.

Jika UI sebenarnya tidak butuh total halaman secara presisi, memaksa count di setiap request adalah biaya yang sering tidak perlu.

3. Sorting tanpa index yang sesuai

Sorting pada kolom yang tidak diindex sering memicu operasi sort di database yang mahal. Jika sorting digabung dengan filter, index tunggal pada satu kolom kadang masih belum cukup. Di sinilah compound index atau index gabungan menjadi penting.

Contoh umum: query memfilter status = 'PAID' lalu mengurutkan created_at desc. Index hanya pada created_at atau hanya pada status belum tentu optimal. Database sering lebih terbantu oleh index gabungan yang mengikuti pola query sebenarnya.

4. Fetch berlebih dari JPA/Hibernate

Masalah lain yang sering luput adalah aplikasi mengambil terlalu banyak data:

  • mengambil entity lengkap padahal hanya butuh 4-5 kolom,
  • melakukan join ke relasi yang tidak diperlukan,
  • terkena N+1 query saat serialisasi response,
  • menggunakan select * secara implisit lewat entity penuh.

Pada endpoint list, biaya transfer data dan materialisasi entity di sisi Hibernate bisa menjadi bottleneck tambahan, bahkan jika query database sudah cukup cepat.

Cara Melihat SQL yang Dihasilkan JPA/Hibernate

Sebelum mengubah desain query atau menambah index, lihat dulu SQL yang benar-benar dijalankan. Query JPQL atau method repository yang terlihat sederhana belum tentu menghasilkan SQL yang efisien.

Secara umum, yang perlu diperiksa adalah:

  • apakah ada query count tambahan,
  • kolom apa saja yang di-select,
  • apakah ada join yang tidak perlu,
  • urutan where dan order by,
  • apakah pagination diterjemahkan menjadi limit/offset.

Anda bisa mengaktifkan logging SQL dan parameter binding agar lebih mudah dianalisis. Detail konfigurasi logging bisa berbeda tergantung stack logging dan versi, jadi yang penting adalah memastikan dua hal terlihat jelas: SQL final dan nilai parameternya.

Jangan hanya melihat kode repository. Selalu validasi SQL yang benar-benar dikirim ke database, karena di situlah bottleneck performa nyata terlihat.

Contoh entitas untuk kasus list data besar

@Entity
@Table(name = "orders", indexes = {
    @Index(name = "idx_orders_status_created_id", columnList = "status, createdAt, id"),
    @Index(name = "idx_orders_created_id", columnList = "createdAt, id")
})
public class OrderEntity {

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

    @Column(nullable = false, length = 30)
    private String status;

    @Column(nullable = false)
    private BigDecimal totalAmount;

    @Column(nullable = false)
    private Instant createdAt;

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

    // getter/setter
}

Contoh di atas menunjukkan dua hal penting:

  • index disesuaikan dengan pola filter dan sort yang nyata,
  • relasi ManyToOne dibuat LAZY agar tidak otomatis ikut dimuat di endpoint list.

Offset Pagination vs Keyset/Cursor Pagination

Offset pagination: sederhana, tetapi makin mahal

Implementasi offset pagination di Spring Data JPA sangat mudah karena sudah terintegrasi dengan Pageable:

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    Page<OrderEntity> findByStatus(String status, Pageable pageable);
}

Pemakaiannya:

Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<OrderEntity> result = orderRepository.findByStatus("PAID", pageable);

Kelebihan offset pagination:

  • mudah diimplementasikan,
  • cocok untuk UI dengan nomor halaman,
  • mudah dipahami oleh client.

Kekurangannya:

  • makin lambat pada page besar,
  • butuh query count jika memakai Page<T>,
  • hasil bisa bergeser jika data baru masuk di tengah navigasi halaman.

Keyset pagination: lebih cepat untuk data besar

Keyset pagination, sering juga disebut cursor pagination, tidak memakai OFFSET. Sebagai gantinya, query mengambil data setelah nilai kunci terakhir yang sudah dilihat, misalnya created_at dan id.

Untuk sorting ORDER BY created_at DESC, id DESC, pola query-nya kira-kira seperti ini:

SELECT id, status, total_amount, created_at
FROM orders
WHERE status = :status
  AND (
    created_at < :lastCreatedAt
    OR (created_at = :lastCreatedAt AND id < :lastId)
  )
ORDER BY created_at DESC, id DESC
LIMIT :size

Di Spring Data JPA, implementasi praktisnya sering menggunakan @Query atau native query:

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    @Query(value = """
        select *
        from orders o
        where o.status = :status
          and (
            o.created_at < :lastCreatedAt
            or (o.created_at = :lastCreatedAt and o.id < :lastId)
          )
        order by o.created_at desc, o.id desc
        limit :size
        """, nativeQuery = true)
    List<OrderEntity> findNextPage(
        @Param("status") String status,
        @Param("lastCreatedAt") Instant lastCreatedAt,
        @Param("lastId") Long lastId,
        @Param("size") int size
    );
}

Untuk halaman pertama, query biasanya dijalankan tanpa kondisi cursor, atau memakai endpoint/service yang berbeda.

Kelebihan keyset pagination:

  • lebih stabil performanya pada tabel besar,
  • tidak perlu melompati banyak baris seperti offset,
  • cocok untuk infinite scroll atau API feed.

Kekurangannya:

  • implementasi lebih kompleks,
  • tidak natural untuk UI nomor halaman acak,
  • perlu sort yang deterministik, biasanya dengan tie-breaker seperti id.

Kapan memilih offset, kapan keyset?

  • Pilih offset pagination jika dataset belum besar, user butuh lompat ke halaman tertentu, dan performa masih memadai.
  • Pilih keyset pagination jika endpoint list sering diakses, volume data besar, page dalam sering lambat, atau UI lebih cocok dengan pola next/previous atau infinite scroll.

Jika masalah utama Anda adalah page 1 cepat tetapi page 1000 lambat, keyset pagination hampir selalu layak dipertimbangkan.

Mengurangi Biaya Count Query

Jika Anda tidak butuh total data secara presisi di setiap request, hindari Page<T> dan pertimbangkan Slice<T>. Dengan Slice, aplikasi cukup tahu apakah masih ada halaman berikutnya tanpa menjalankan query count penuh.

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    Slice<OrderEntity> findByStatus(String status, Pageable pageable);
}

Keuntungan pendekatan ini:

  • menghilangkan query count yang mahal,
  • cukup untuk banyak use case API list,
  • lebih ringan untuk endpoint yang fokus pada navigasi maju.

Namun, jika UI memang butuh total halaman atau total item, maka Page<T> masih relevan. Yang penting adalah sadar bahwa total count punya harga, terutama pada query besar atau kompleks.

Gunakan Proyeksi, Bukan Entity Penuh, untuk Endpoint List

Pada endpoint list, sering kali client hanya membutuhkan sebagian field. Mengambil entity penuh membuat Hibernate memuat lebih banyak kolom dari yang dibutuhkan, dan berisiko memicu lazy loading tambahan ketika objek diserialisasi.

Lebih efisien jika Anda memakai DTO atau interface projection:

public record OrderListItem(
    Long id,
    String status,
    BigDecimal totalAmount,
    Instant createdAt
) {}
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    @Query("""
        select new com.example.api.OrderListItem(o.id, o.status, o.totalAmount, o.createdAt)
        from OrderEntity o
        where o.status = :status
        order by o.createdAt desc, o.id desc
        """)
    List<OrderListItem> findOrderListItems(@Param("status") String status, Pageable pageable);
}

Pendekatan ini membantu karena:

  • kolom yang diambil lebih sedikit,
  • memory lebih hemat,
  • serialisasi response lebih aman dan terkontrol,
  • mengurangi risiko fetch relasi yang tidak sengaja.

Untuk endpoint list, ini sering memberi dampak yang lebih nyata daripada optimasi kecil di layer Java.

Desain Index yang Relevan untuk Filter dan Sort

Kenapa compound index penting

Index tunggal belum tentu cukup saat query melakukan filter dan sort sekaligus. Misalnya query:

select id, status, total_amount, created_at
from orders
where status = 'PAID'
order by created_at desc, id desc
limit 20

Index yang relevan adalah yang mengikuti pola pencarian tersebut, misalnya:

(status, created_at, id)

Dengan index seperti ini, database punya peluang lebih baik untuk:

  • menyaring berdasarkan status,
  • menghasilkan urutan berdasarkan created_at dan id,
  • mengambil baris teratas tanpa sort tambahan yang mahal.

Urutan kolom index matters

Urutan kolom dalam compound index tidak boleh asal. Query yang memfilter status lalu mengurutkan created_at, id umumnya lebih cocok dengan index (status, created_at, id) daripada (created_at, status, id).

Alasannya sederhana: database membaca index dari kiri ke kanan. Jika pola query tidak sejalan dengan susunan index, manfaatnya bisa jauh berkurang.

Jangan menambah index tanpa melihat query nyata

Index memang mempercepat baca, tetapi ada biaya:

  • insert/update/delete menjadi lebih mahal,
  • storage bertambah,
  • optimizer bisa punya terlalu banyak pilihan,
  • maintenance index ikut bertambah.

Karena itu, index harus dibuat berdasarkan query yang benar-benar sering dipakai dan benar-benar lambat, bukan berdasarkan dugaan.

Membaca EXPLAIN dan Kapan Memakai EXPLAIN ANALYZE

Setelah melihat SQL yang dihasilkan, langkah berikutnya adalah memeriksa rencana eksekusi database. Di sinilah Anda bisa mengetahui apakah query memakai index, melakukan full scan, atau melakukan sort besar di memori/disk.

Apa yang perlu dicari di rencana eksekusi

  • apakah database memakai index yang diharapkan,
  • apakah terjadi full table scan atau sequential scan pada tabel besar,
  • apakah ada sort mahal setelah filtering,
  • berapa banyak baris yang diperkirakan dan yang benar-benar diproses,
  • apakah join menghasilkan row explosion.

Secara umum, EXPLAIN menunjukkan rencana eksekusi, sedangkan EXPLAIN ANALYZE menjalankan query dan menunjukkan eksekusi aktual. Gunakan EXPLAIN ANALYZE ketika Anda perlu memverifikasi bottleneck nyata, bukan hanya estimasi planner.

Pakai EXPLAIN ANALYZE pada query yang memang sedang Anda investigasi, terutama jika hasil nyata berbeda dari dugaan. Hindari menjalankannya sembarangan pada query berat di lingkungan produksi yang sensitif.

Jika Anda menemukan bahwa query masih melakukan scan besar atau sort mahal meski index sudah ditambahkan, biasanya masalahnya ada pada salah satu hal berikut:

  • susunan kolom index tidak cocok,
  • query tidak deterministik atau kondisi filter terlalu luas,
  • optimizer memilih rencana lain karena statistik tidak representatif,
  • kolom yang dipakai untuk sorting tidak sesuai dengan index.

Trade-off Konsistensi Hasil, UX, dan Kompleksitas Implementasi

Konsistensi hasil

Baik offset maupun keyset punya trade-off terhadap perubahan data yang terjadi selama user melakukan navigasi.

  • Offset pagination bisa menghasilkan duplikasi atau data terlewat jika ada insert/delete di antara dua request.
  • Keyset pagination umumnya lebih stabil untuk navigasi maju, tetapi tetap bergantung pada cursor dan urutan sort yang konsisten.

Jika konsistensi absolut sangat penting, Anda perlu memikirkan strategi tambahan, misalnya snapshot data, filter berbasis waktu, atau aturan bisnis yang lebih ketat. Namun ini menambah kompleksitas sistem.

UX client

Dari sisi pengalaman pengguna:

  • Offset lebih cocok untuk tabel admin dengan nomor halaman dan fitur lompat ke page tertentu.
  • Keyset lebih cocok untuk feed, log, transaksi terbaru, dan infinite scroll.

Keputusan teknis yang benar sering mengikuti kebutuhan UX, bukan sekadar teori performa.

Kompleksitas implementasi

Offset pagination unggul dalam kesederhanaan. Keyset membutuhkan cursor, penanganan halaman pertama, dan sort yang deterministik. Biasanya Anda juga perlu menyandikan cursor agar aman dipakai client, misalnya berisi pasangan createdAt dan id.

Karena itu, tidak semua endpoint harus dipaksa memakai keyset. Terapkan di endpoint yang benar-benar menjadi hotspot performa.

Contoh Pendekatan Praktis di Service Layer

Untuk endpoint list besar, pola yang cukup aman adalah:

  1. gunakan DTO/projection,
  2. hindari relasi yang tidak perlu,
  3. pakai Slice jika total count tidak penting,
  4. gunakan keyset pada endpoint dengan akses page dalam atau volume tinggi,
  5. sesuaikan index dengan filter + sort.
@Service
@RequiredArgsConstructor
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public Slice<OrderListItem> getLatestPaidOrders(int size) {
        Pageable pageable = PageRequest.of(0, size, Sort.by(
            Sort.Order.desc("createdAt"),
            Sort.Order.desc("id")
        ));

        Slice<OrderEntity> slice = orderRepository.findByStatus("PAID", pageable);

        return slice.map(o -> new OrderListItem(
            o.getId(),
            o.getStatus(),
            o.getTotalAmount(),
            o.getCreatedAt()
        ));
    }
}

Contoh di atas belum sempurna untuk semua kasus, tetapi menunjukkan arah optimasi yang benar: batasi data yang diambil, hindari count jika tidak diperlukan, dan gunakan sort deterministik.

Kesalahan Umum yang Sering Membuat Endpoint Tetap Lambat

  • Mengandalkan offset pagination untuk dataset yang sudah sangat besar.
  • Memakai Page<T> di semua endpoint padahal total count tidak dibutuhkan.
  • Sorting di kolom tanpa index atau dengan index yang urutannya tidak sesuai query.
  • Mengambil entity penuh padahal response hanya butuh beberapa kolom.
  • Melakukan fetch relasi yang tidak perlu di endpoint list.
  • Menambah terlalu banyak index tanpa validasi query yang benar-benar lambat.
  • Tidak melihat SQL nyata dan langsung menebak bottleneck dari kode Java.
  • Memakai select * pada query native untuk list besar ketika hanya sebagian kolom dibutuhkan.

Checklist Optimasi Pagination dan Index JPA

Gunakan checklist berikut saat endpoint list mulai melambat:

  1. Lihat SQL yang dihasilkan JPA/Hibernate, bukan hanya kode repository.
  2. Identifikasi apakah ada query count dan nilai apakah benar-benar dibutuhkan.
  3. Cek pola filter + sort yang paling sering dipakai client.
  4. Pastikan ada index yang sesuai, terutama compound index untuk filter dan sort.
  5. Gunakan EXPLAIN untuk membaca rencana eksekusi.
  6. Gunakan EXPLAIN ANALYZE saat perlu melihat biaya aktual query yang lambat.
  7. Pertimbangkan Slice jika total count tidak penting.
  8. Pertimbangkan keyset/cursor pagination jika offset mulai menjadi bottleneck.
  9. Gunakan projection/DTO untuk endpoint list agar tidak mengambil data berlebih.
  10. Hindari fetch relasi yang tidak dibutuhkan dan waspadai N+1 query.

Penutup

Optimasi endpoint list pada Spring Boot untuk data besar hampir selalu melibatkan dua lapisan sekaligus: query database dan cara JPA/Hibernate memuat data. Mempercepat endpoint bukan sekadar menambah index atau mengganti repository method, tetapi memahami apakah masalah utamanya ada di offset pagination, count query, sorting, atau fetch yang berlebihan.

Jika Anda mulai dari SQL nyata, cek rencana eksekusi, lalu sesuaikan pagination dan index dengan pola akses sebenarnya, hasil optimasi biasanya jauh lebih konsisten. Untuk banyak kasus, kombinasi projection + index yang tepat + mengurangi count query + beralih ke keyset pagination pada endpoint kritis sudah cukup untuk menghilangkan bottleneck terbesar.