Tampilan
Kenapa penting
Hampir setiap aplikasi backend punya endpoint daftar: riwayat pesanan, katalog produk, mutasi rekening, log audit. Di hari pertama tabelnya berisi seratus baris dan findAll() terasa cukup. Setahun kemudian tabel yang sama berisi jutaan baris, dan satu request tanpa batas bisa menghabiskan memori aplikasi, membuat koneksi database tertahan lama, dan mengirim response puluhan megabyte ke ponsel pengguna. Paginasi bukan fitur kosmetik; ia adalah pagar pengaman performa dan bagian dari kontrak API yang dipakai frontend. Di bab ini kamu belajar paginasi dan sorting gaya Spring Data, cara mengeksposnya dengan aman lewat controller, dan kapan harus beralih dari offset ke keyset pagination.
Konsep inti
Pageable, PageRequest, dan Sort
Pageable adalah interface yang membawa tiga informasi: nomor halaman (0-based), ukuran halaman, dan Sort. Implementasi yang paling sering kamu buat sendiri adalah PageRequest. Cukup tambahkan parameter Pageable di method repository, dan Spring Data akan menambahkan LIMIT/OFFSET serta ORDER BY ke query.
java
// Potongan
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
Page<PurchaseOrder> findByStatus(OrderStatus status, Pageable pageable);
Slice<OrderSummary> findByCustomerEmail(String customerEmail, Pageable pageable);
}java
// Potongan — di service
Sort newestFirst = Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id"));
Pageable pageable = PageRequest.of(0, 20, newestFirst);
Page<PurchaseOrder> page = orders.findByStatus(OrderStatus.PAID, pageable);
page.getContent(); // List<PurchaseOrder> berisi maksimal 20 elemen
page.getTotalElements(); // jumlah semua baris yang cocok
page.getTotalPages();
page.hasNext();Nama properti di Sort adalah nama field entity (createdAt), bukan nama kolom tabel (created_at). Untuk @Query JPQL, Pageable juga bisa dipakai; untuk native query dengan Page, kamu biasanya perlu menulis countQuery sendiri karena Spring Data tidak selalu bisa menurunkan query hitungnya dari SQL mentah.
Page vs Slice
Perbedaannya ada di query tambahan yang dijalankan.
| Tipe kembalian | Query yang dijalankan | Informasi yang tersedia | Cocok untuk |
|---|---|---|---|
Page<T> | SELECT ... LIMIT n OFFSET m + SELECT count(*) ... | total elemen, total halaman, hasNext() | tabel admin dengan nomor halaman |
Slice<T> | SELECT ... LIMIT n+1 OFFSET m | hasNext() saja | infinite scroll, tombol "muat lagi" |
List<T> + Pageable | SELECT ... LIMIT n OFFSET m | tidak ada metadata | ekspor batch internal |
Query count(*) di PostgreSQL harus memindai baris yang cocok, jadi di tabel besar dengan filter kompleks biayanya bisa melebihi query datanya sendiri. Kalau UI tidak menampilkan "Halaman 3 dari 1.204", pakai Slice.
Parameter halaman di controller
Spring Data web support (aktif otomatis di Spring Boot) bisa mengisi Pageable langsung dari query string: ?page=0&size=20&sort=createdAt,desc&sort=id,desc. Parameter sort boleh diulang; formatnya properti,arah.
java
// Potongan
@GetMapping("/api/orders")
OrderPageResponse list(@PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return orderService.listOrders(pageable);
}Ada dua bahaya di sini. Pertama, ukuran halaman: resolver memotong size ke batas maksimum yang defaultnya 2000 — terlalu besar untuk API publik. Turunkan lewat konfigurasi:
yaml
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100Kedua, sorting dari klien: sort=customerEmail atau sort=passwordHash akan diterjemahkan ke ORDER BY apa adanya. Properti yang tidak ada di entity membuat query gagal (error 500 bila tidak ditangani), dan sorting di kolom tanpa index bisa sangat lambat. Praktik yang aman adalah allowlist: hanya izinkan properti tertentu, tolak sisanya dengan 400.
Bentuk response: jangan expose Page mentah
Mengembalikan Page<PurchaseOrder> langsung dari controller punya dua masalah. Entity ikut terserialisasi (bab sebelumnya sudah membahas kenapa itu berbahaya), dan struktur JSON PageImpl bukan kontrak yang dijamin stabil — Spring Data mencatat peringatan di log bila kamu melakukannya. Ada dua jalan keluar resmi:
- DTO sendiri — paling eksplisit, kamu mengontrol penuh nama field.
PagedModeldari paketorg.springframework.data.web— bungkusnew PagedModel<>(page)dan JSON-nya berbentukcontent+ objekpage(size,number,totalElements,totalPages). Alternatifnya, aktifkan@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)(atau propertispring.data.web.pageable.serialization-mode=via-dto) agar setiapPageyang dikembalikan otomatis dirender dalam bentuk itu.
TIP
Apa pun pilihannya, pastikan isi content adalah DTO, bukan entity. PagedModel hanya merapikan metadata halaman; ia tidak menyembunyikan field entity.
Offset vs keyset (cursor) pagination
Offset pagination (LIMIT 20 OFFSET 40000) punya dua kelemahan. Database tetap harus melewati 40.000 baris sebelum mengembalikan 20, jadi halaman yang dalam makin lambat. Selain itu, bila ada baris baru masuk di antara dua request, semua baris bergeser dan pengguna melihat duplikat. Simulasi berikut memakai List biasa untuk menunjukkannya:
java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class OffsetVsKeyset {
record Order(long id, String number) {}
// Urutan terbaru dulu: id besar di atas
static final Comparator<Order> NEWEST_FIRST = Comparator.comparingLong(Order::id).reversed();
static List<Order> offsetPage(List<Order> all, int page, int size) {
return all.stream().sorted(NEWEST_FIRST).skip((long) page * size).limit(size).toList();
}
static List<Order> keysetPage(List<Order> all, Long lastSeenId, int size) {
return all.stream()
.filter(o -> lastSeenId == null || o.id() < lastSeenId)
.sorted(NEWEST_FIRST)
.limit(size)
.toList();
}
public static void main(String[] args) {
List<Order> orders = new ArrayList<>();
for (long id = 1; id <= 6; id++) {
orders.add(new Order(id, "INV-" + id));
}
List<Order> page0 = offsetPage(orders, 0, 3);
List<Order> first = keysetPage(orders, null, 3);
System.out.println("Halaman pertama : " + page0.stream().map(Order::number).toList());
// Pesanan baru masuk saat pengguna masih membaca halaman pertama
orders.add(new Order(7, "INV-7"));
List<Order> page1 = offsetPage(orders, 1, 3);
long lastSeen = first.getLast().id();
List<Order> next = keysetPage(orders, lastSeen, 3);
System.out.println("Offset page=1 : " + page1.stream().map(Order::number).toList());
System.out.println("Keyset id<" + lastSeen + " : " + next.stream().map(Order::number).toList());
}
}text
Halaman pertama : [INV-6, INV-5, INV-4]
Offset page=1 : [INV-4, INV-3, INV-2]
Keyset id<4 : [INV-3, INV-2, INV-1]INV-4 muncul dua kali di versi offset. Keyset pagination tidak bertanya "lewati berapa baris", melainkan "ambil baris setelah baris terakhir yang sudah kulihat" (WHERE id < 4). Dengan index di kolom urutan, query ini sama cepatnya di halaman pertama maupun ke-10.000. Gantinya, kamu kehilangan kemampuan lompat ke "halaman 37".
Spring Data menyediakan API scrolling untuk ini: method repository menerima ScrollPosition dan mengembalikan Window<T>.
java
// Potongan
public interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {
Window<OrderSummary> findFirst20ByStatusOrderByCreatedAtDescIdDesc(OrderStatus status, ScrollPosition position);
}
// Halaman pertama
Window<OrderSummary> window = orders.findFirst20ByStatusOrderByCreatedAtDescIdDesc(
OrderStatus.PAID, ScrollPosition.keyset());
// Halaman berikutnya: posisi diambil dari elemen terakhir window sebelumnya
if (window.hasNext()) {
ScrollPosition next = window.positionAt(window.size() - 1);
window = orders.findFirst20ByStatusOrderByCreatedAtDescIdDesc(OrderStatus.PAID, next);
}Di API REST, posisi keyset biasanya dikirim ke klien sebagai cursor buram (misalnya Base64 dari createdAt + id baris terakhir) dan dikirim balik di request berikutnya: ?cursor=eyJpZCI6....
Sorting yang deterministik
SQL hanya menjamin urutan untuk kolom yang ada di ORDER BY. Jika 300 produk berharga Rp50.000 dan kamu hanya mengurutkan berdasarkan price, PostgreSQL bebas mengembalikan ke-300 baris itu dalam urutan berbeda di setiap query. Dengan offset pagination, akibatnya produk bisa muncul di dua halaman atau tidak muncul sama sekali. Aturannya sederhana: selalu akhiri Sort dengan kolom unik, biasanya id. Untuk keyset pagination ini wajib, karena cursor hanya bermakna bila setiap baris punya posisi yang unik.
Contoh di dunia backend
Endpoint riwayat pesanan pelanggan: sorting di-allowlist, kolom pemecah seri ditambahkan otomatis, dan response memakai DTO sendiri.
java
// Potongan
public record OrderPageResponse(List<OrderSummary> content, int page, int size,
long totalElements, int totalPages, boolean hasNext) {
static OrderPageResponse from(Page<OrderSummary> page) {
return new OrderPageResponse(page.getContent(), page.getNumber(), page.getSize(),
page.getTotalElements(), page.getTotalPages(), page.hasNext());
}
}
@Service
public class OrderQueryService {
private static final Set<String> SORTABLE = Set.of("createdAt", "totalAmount", "orderNumber");
private final PurchaseOrderRepository orders;
public OrderQueryService(PurchaseOrderRepository orders) {
this.orders = orders;
}
@Transactional(readOnly = true)
public OrderPageResponse history(String customerEmail, Pageable requested) {
for (Sort.Order order : requested.getSort()) {
if (!SORTABLE.contains(order.getProperty())) {
throw new InvalidSortException(order.getProperty()); // dipetakan ke 400 oleh @RestControllerAdvice
}
}
// Pemecah seri unik agar urutan stabil antar halaman
Sort stable = requested.getSort().and(Sort.by(Sort.Direction.DESC, "id"));
Pageable pageable = PageRequest.of(requested.getPageNumber(), requested.getPageSize(), stable);
return OrderPageResponse.from(orders.findSummariesByCustomerEmail(customerEmail, pageable));
}
}java
// Potongan — query projection dengan Page (count diturunkan otomatis dari JPQL)
@Query("""
select new com.example.shop.order.OrderSummary(o.orderNumber, o.status, o.totalAmount)
from PurchaseOrder o
where o.customerEmail = :email
""")
Page<OrderSummary> findSummariesByCustomerEmail(@Param("email") String email, Pageable pageable);Index komposit (customer_email, created_at DESC, id DESC) — yang kamu tambahkan lewat migrasi Flyway — membuat filter dan urutan default dilayani langsung oleh index, sesuai yang kamu pelajari di bab performa query.
Pertanyaan interview
1. Apa beda Page dan Slice di Spring Data, dan kapan memilih masing-masing?
Page berisi data halaman plus total elemen dan total halaman, sehingga Spring Data menjalankan query count tambahan. Slice hanya tahu apakah ada halaman berikutnya, dengan cara mengambil satu baris ekstra. Pilih Page bila UI benar-benar menampilkan jumlah total atau nomor halaman; pilih Slice untuk infinite scroll atau tabel besar di mana count terlalu mahal.
2. Kenapa offset pagination melambat di halaman dalam, dan apa alternatifnya?
Dengan OFFSET 100000, database tetap membaca dan membuang 100.000 baris sebelum mengembalikan hasil, jadi biayanya tumbuh linear terhadap offset. Offset juga rawan duplikat/terlewat saat data berubah. Alternatifnya keyset (cursor) pagination: filter WHERE (created_at, id) < (:lastCreatedAt, :lastId) dengan index yang sesuai, sehingga biayanya konstan. Kekurangannya, klien tidak bisa melompat ke nomor halaman sembarang.
3. Bagaimana kamu mengamankan parameter size dan sort yang datang dari klien?
Batasi ukuran halaman dengan max-page-size yang kecil (misalnya 100) dan default yang wajar. Untuk sort, gunakan allowlist properti yang boleh diurutkan dan sudah didukung index; properti lain ditolak dengan 400. Lalu tambahkan kolom unik sebagai pemecah seri agar hasilnya deterministik.
Jebakan umum
- Mengira nomor halaman 1-based.
PageRequest.of(1, 20)adalah halaman kedua. Bila API publikmu ingin 1-based, aktifkanspring.data.web.pageable.one-indexed-parameters=truedan dokumentasikan. - Memakai
Pageuntuk semua kasus, lalu heran kenapa querycount(*)mendominasi waktu response di tabel besar. - Sorting tanpa kolom unik, sehingga baris berpindah halaman secara acak.
- Mengembalikan
Page<Entity>mentah: membocorkan field internal, bentuk JSON tidak stabil, dan rawanLazyInitializationExceptionsaat serialisasi. - Membiarkan
sortdari klien langsung masuk ke query tanpa allowlist — properti tak dikenal memicu error 500 dan kolom tanpa index memicu full table scan. JOIN FETCHkoleksi digabung denganPageable: Hibernate tidak bisa membatasi baris di SQL dan melakukan paginasi di memori (dengan peringatan di log). Ambil ID per halaman dulu, lalu muat relasinya.
Ringkasan
- Tambahkan parameter
Pageabledi method repository; buat denganPageRequest.of(page, size, sort)— halaman 0-based. Pagemenjalankan querycountuntuk metadata total;Slicehanya mengambil satu baris ekstra untukhasNext().- Di controller,
Pageablediisi dari?page=&size=&sort=; turunkanmax-page-sizedan allowlist properti sort. - Jangan expose
Pagementah: petakan ke DTO sendiri atauPagedModel(VIA_DTO), dengan isi berupa DTO. - Keyset pagination (
ScrollPosition/Window) cepat dan stabil untuk data besar, tetapi tanpa lompat halaman. - Selalu akhiri urutan dengan kolom unik seperti
idagar sorting deterministik.