Pagination ve Sorting
Giriş
Bir Instagram feed'i düşünün. Milyonlarca gönderi var ama siz açtığınızda 20 tanesi yükleniyor. Aşağı kaydırdıkça 20'şer 20'şer daha yükleniyor. Tüm gönderileri tek seferde yükleseydi ne olurdu? Telefonunuz donardı, internet kotanız biterdi, sunucu çökerdi. İşte Pagination (sayfalama) tam olarak bu sorunu çözer.
Bir tabloda yüz binlerce veya milyonlarca kayıt olduğunda hepsini tek seferde çekmek:
Bellek tüketir — 1 milyon satır × 10 sütun = gigabyte'larca RAM
Ağ bant genişliğini doldurur — JSON olarak serialize edilmiş veri devasa olur
Yanıt süresini uzatır — Kullanıcı 10 saniye beklemek istemez
Veritabanını zorlar — Full table scan performans kabusu
Pagination, veriyi küçük parçalara (sayfalara) bölerek hem sunucu performansını artırır hem de kullanıcı deneyimini iyileştirir. Sorting (sıralama) ise bu sayfalardaki verilerin anlamlı bir sırada sunulmasını sağlar.
Gerçek Dünya Analojisi
Bir kütüphaneye gittiğinizi düşünün. 100.000 kitap var. Kütüphaneci size "İşte tüm kitaplarımız" deyip hepsini masanıza yığmaz. Bunun yerine:
Bir katalog verir (sayfalama): sayfa 1'de 50 kitap, sayfa 2'de sonraki 50...
Sıralama seçeneği sunar: yazara göre, yayın tarihine göre, konuya göre...
İstediğiniz sayfayı açarsınız, gerisini merak etmezsiniz
Spring Data JPA'daki Pageable, Page ve Sort yapıları tam olarak bu kütüphane kataloğudur.
Temel Kavramlar
Pageable — Sayfa İsteği
Pageable bir interface'dir ve "kaçıncı sayfayı, kaç öğeyle, hangi sırayla istiyorum" bilgisini taşır:
// Sayfa 0, 20 öğe (varsayılan sıralama)
Pageable pageable = PageRequest.of(0, 20);
// Sayfa 2, 10 öğe, fiyata göre artan sıralama
Pageable pageable = PageRequest.of(2, 10, Sort.by("price").ascending());
// Sayfa 0, 15 öğe, önce featured DESC sonra price ASC
Sort sort = Sort.by(
Sort.Order.desc("featured"),
Sort.Order.asc("price")
);
Pageable pageable = PageRequest.of(0, 15, sort);⚠️ Dikkat: Sayfa numaraları 0-based'tir (0'dan başlar). Sayfa 1 istiyorsanız PageRequest.of(0, size) yazarsınız. Bu en sık yapılan hatadır!
Page vs Slice — İki Farklı Dönüş Tipi
Spring Data JPA iki farklı sayfalama sonuç tipi sunar:
| Özellik | Page<T> | Slice<T> |
|---|---|---|
| Toplam kayıt sayısı | ✅ Var (getTotalElements()) | ❌ Yok |
| Toplam sayfa sayısı | ✅ Var (getTotalPages()) | ❌ Yok |
| COUNT sorgusu | ✅ Çalıştırır | ❌ Çalıştırmaz |
| Performans | Daha yavaş (büyük tablolarda) | Daha hızlı |
| Kullanım senaryosu | Sayfa navigator (1, 2, 3...) | Infinite scroll |
Repository Tanımları
JpaRepository ile Sayfalama
public interface ProductRepository extends JpaRepository<Product, Long> {
// Temel sayfalama — findAll zaten JpaRepository'den gelir
// Page<Product> findAll(Pageable pageable); // Zaten var!
// Filtrelemeli sayfalama
Page<Product> findByCategory(String category, Pageable pageable);
// Slice döndüren versiyon
Slice<Product> findByPriceGreaterThan(BigDecimal price, Pageable pageable);
// Aktif ürünleri sayfalı getir
Page<Product> findByActiveTrue(Pageable pageable);
// İsimde arama yaparak sayfalı getir
Page<Product> findByNameContainingIgnoreCase(String keyword, Pageable pageable);
// JPQL ile sayfalama
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
Page<Product> findByPriceRange(
@Param("min") BigDecimal min,
@Param("max") BigDecimal max,
Pageable pageable
);
// Native SQL ile sayfalama
@Query(
value = "SELECT * FROM products WHERE category = :category",
countQuery = "SELECT COUNT(*) FROM products WHERE category = :category",
nativeQuery = true
)
Page<Product> findByCategoryNative(@Param("category") String category, Pageable pageable);
}⚠️ Dikkat: Native SQL ile Page dönerken countQuery belirtmeniz gerekir. JPQL'de Spring bunu otomatik üretir, ama native SQL'de veritabanına özel olduğu için otomatik üretim çalışmaz.
Service Katmanında Sayfalama
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
// Temel sayfalama
public Page<Product> getProducts(int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return productRepository.findAll(pageable);
}
// Sıralama ile sayfalama
public Page<Product> getProductsSorted(int page, int size, String sortBy, String direction) {
Sort sort = direction.equalsIgnoreCase("desc")
? Sort.by(sortBy).descending()
: Sort.by(sortBy).ascending();
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(pageable);
}
// Çoklu sıralama
public Page<Product> getProductsMultiSort(int page, int size) {
Sort sort = Sort.by(
Sort.Order.desc("featured"), // Önce öne çıkanlar
Sort.Order.asc("price"), // Sonra fiyata göre ucuzdan pahalıya
Sort.Order.desc("createdAt") // Aynı fiyatta yeniden eskiye
);
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(pageable);
}
// Kategori bazlı sayfalama
public Page<Product> getProductsByCategory(String category, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("price").ascending());
return productRepository.findByCategory(category, pageable);
}
// Infinite scroll için Slice kullanımı
public Slice<Product> getExpensiveProducts(BigDecimal minPrice, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("price").descending());
return productRepository.findByPriceGreaterThan(minPrice, pageable);
}
}Page Nesnesinin Anatomisi
Page<T> nesnesi içinde çok zengin bilgiler barındırır:
Page<Product> page = productRepository.findAll(PageRequest.of(2, 20));
// İçerik
List<Product> products = page.getContent(); // Bu sayfadaki ürünler
int numberOfElements = page.getNumberOfElements(); // Bu sayfadaki öğe sayısı (≤ size)
// Sayfa bilgileri
int pageNumber = page.getNumber(); // Mevcut sayfa numarası (0-based): 2
int pageSize = page.getSize(); // Sayfa boyutu: 20
boolean hasContent = page.hasContent(); // İçerik var mı?
// Toplam bilgiler (COUNT sorgusu ile)
long totalElements = page.getTotalElements(); // Toplam öğe sayısı (ör: 1543)
int totalPages = page.getTotalPages(); // Toplam sayfa sayısı (ör: 78)
// Navigasyon
boolean isFirst = page.isFirst(); // İlk sayfa mı?
boolean isLast = page.isLast(); // Son sayfa mı?
boolean hasNext = page.hasNext(); // Sonraki sayfa var mı?
boolean hasPrevious = page.hasPrevious(); // Önceki sayfa var mı?
// Pageable bilgisi
Pageable nextPageable = page.nextPageable(); // Sonraki sayfanın Pageable'ı
Pageable previousPageable = page.previousPageable(); // Önceki sayfanın Pageable'ı
Sort sort = page.getSort(); // Kullanılan sıralamaSlice Nesnesinin Anatomisi
Slice<Product> slice = productRepository.findByPriceGreaterThan(
BigDecimal.valueOf(100), PageRequest.of(0, 20)
);
List<Product> products = slice.getContent();
int number = slice.getNumber();
int size = slice.getSize();
boolean hasNext = slice.hasNext();
boolean hasPrevious = slice.hasPrevious();
// ⛔ BU METOTLAR SLICE'DA MEVCUT DEĞİL:
// slice.getTotalElements() → Derleme hatası!
// slice.getTotalPages() → Derleme hatası!Page vs Slice — Performans Karşılaştırması
Page kullanıldığında veritabanına iki sorgu gider:
-- Sorgu 1: Veriyi getir
SELECT * FROM products WHERE category = 'electronics'
ORDER BY price ASC LIMIT 20 OFFSET 40;
-- Sorgu 2: COUNT (Page için)
SELECT COUNT(*) FROM products WHERE category = 'electronics';Slice kullanıldığında sadece bir sorgu gider, ama size + 1 kayıt ister:
-- Tek sorgu: size+1 = 21 kayıt iste
SELECT * FROM products WHERE price > 100
ORDER BY price DESC LIMIT 21 OFFSET 0;
-- 21 gelirse → hasNext = true (21. kayıt gösterilmez)
-- 20 veya az gelirse → hasNext = false💡 İpucu: Milyonlarca kayıtlı tablolarda COUNT(*) sorgusu saniyeler sürebilir. Toplam sayıya ihtiyacınız yoksa Slice kullanarak bu maliyeti tamamen ortadan kaldırabilirsiniz.
REST Controller'da Sayfalama
Temel Yaklaşım
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public Page<ProductDto> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
// Güvenlik: Maximum page size limiti
int safeSize = Math.min(size, 100);
Page<Product> productPage = productService.getProductsSorted(page, safeSize, sortBy, direction);
// Entity → DTO dönüşümü
return productPage.map(this::toDto);
}
private ProductDto toDto(Product p) {
return new ProductDto(p.getId(), p.getName(), p.getPrice(), p.getCategory());
}
}API Çağrı Örnekleri:
# İlk sayfa, 20 öğe, varsayılan sıralama
GET /api/products?page=0&size=20
# 3. sayfa, 10 öğe, fiyata göre artan
GET /api/products?page=2&size=10&sortBy=price&direction=asc
# İlk sayfa, isme göre sıralı
GET /api/products?page=0&size=20&sortBy=name&direction=ascSpring'in Otomatik Pageable Çözümlemesi
Spring MVC, Pageable parametresini doğrudan controller'da çözümleyebilir:
@GetMapping
public Page<ProductDto> getProducts(Pageable pageable) {
// Spring otomatik olarak query param'lardan Pageable oluşturur
// ?page=0&size=20&sort=price,asc&sort=name,desc
return productService.getProducts(pageable).map(this::toDto);
}Bu endpoint'i şöyle çağırırsınız:
# Sayfa 0, 20 öğe
GET /api/products?page=0&size=20
# Fiyata göre artan sıralama
GET /api/products?page=0&size=20&sort=price,asc
# Çoklu sıralama
GET /api/products?page=0&size=20&sort=price,asc&sort=name,descPageable Varsayılan Ayarları
# application.yml
spring:
data:
web:
pageable:
default-page-size: 20 # Varsayılan sayfa boyutu
max-page-size: 100 # Maksimum sayfa boyutu (güvenlik)
one-indexed-parameters: false # true yaparsanız sayfa 1'den başlar
page-parameter: page # Query param adı
size-parameter: size # Query param adı
sort:
sort-parameter: sort # Sort param adı💡 İpucu: max-page-size ayarını mutlaka yapılandırın! Aksi halde kötü niyetli bir kullanıcı ?size=1000000 diyerek sunucuyu çökertebilir.
Page Nesnesini DTO'ya Dönüştürme
Page.map() metodu ile entity'leri DTO'ya dönüştürürken sayfalama bilgilerini koruyabilirsiniz:
@GetMapping
public Page<ProductDto> getProducts(Pageable pageable) {
Page<Product> productPage = productRepository.findAll(pageable);
// map() → Entity → DTO dönüşümü yapılır, sayfalama bilgileri korunur
return productPage.map(product -> new ProductDto(
product.getId(),
product.getName(),
product.getPrice(),
product.getCategory()
));
}Özel Response Wrapper
REST API'lerde Page nesnesinin JSON çıktısı oldukça detaylıdır. Daha kontrollü bir response istiyorsanız:
public record PagedResponse<T>(
List<T> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {
public static <T> PagedResponse<T> from(Page<T> page) {
return new PagedResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}
// Controller'da kullanım
@GetMapping
public PagedResponse<ProductDto> getProducts(Pageable pageable) {
Page<ProductDto> page = productRepository.findAll(pageable).map(this::toDto);
return PagedResponse.from(page);
}JSON Response Örneği:
{
"content": [
{ "id": 1, "name": "Laptop", "price": 15000 },
{ "id": 2, "name": "Mouse", "price": 250 }
],
"page": 0,
"size": 20,
"totalElements": 156,
"totalPages": 8,
"first": true,
"last": false
}Sorting Derinlemesine
Tek Alana Göre Sıralama
// Ascending (A→Z, 0→9, eski→yeni)
Sort sort = Sort.by("name").ascending();
// veya kısaca
Sort sort = Sort.by(Sort.Direction.ASC, "name");
// Descending (Z→A, 9→0, yeni→eski)
Sort sort = Sort.by("price").descending();Çoklu Alana Göre Sıralama
// Yöntem 1: Zincirleme
Sort sort = Sort.by("category").ascending()
.and(Sort.by("price").descending());
// Yöntem 2: Order listesi (daha okunabilir)
Sort sort = Sort.by(
Sort.Order.asc("category"), // Önce kategori A→Z
Sort.Order.desc("price"), // Aynı kategoride fiyat yüksek→düşük
Sort.Order.asc("name") // Aynı fiyatta isim A→Z
);
// Yöntem 3: Basit çoklu alan (hepsi aynı yönde)
Sort sort = Sort.by(Sort.Direction.ASC, "category", "name", "price");Null Handling
// NULL değerler başa mı sona mı gelsin?
Sort sort = Sort.by(Sort.Order.asc("discount").nullsLast()); // NULL'lar sona
Sort sort = Sort.by(Sort.Order.asc("discount").nullsFirst()); // NULL'lar başaType-Safe Sorting (JPA Metamodel)
String-based field adları yazım hatalarına açıktır. JPA Metamodel ile type-safe sıralama yapabilirsiniz:
// Hibernate Metamodel Generator gerekir
// pom.xml'e hibernate-jpamodelgen ekleyin
Sort sort = Sort.by(Product_.PRICE).ascending()
.and(Sort.by(Product_.NAME).ascending());Keyset Pagination (Cursor-Based) — İleri Seviye
Offset-based pagination (standart yöntem) büyük offset'lerde yavaşlar:
-- Sayfa 10.000: Veritabanı 200.000 kayıt okuyup 199.980'ini atlar!
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 199980;Keyset pagination bu sorunu çözer:
public interface ProductRepository extends JpaRepository<Product, Long> {
// Cursor-based: "Bu ID'den sonraki 20 ürünü getir"
@Query("SELECT p FROM Product p WHERE p.id > :lastId ORDER BY p.id ASC")
List<Product> findNextPage(@Param("lastId") Long lastId, Pageable pageable);
// Tarih bazlı cursor
@Query("""
SELECT p FROM Product p
WHERE p.createdAt < :lastDate
OR (p.createdAt = :lastDate AND p.id < :lastId)
ORDER BY p.createdAt DESC, p.id DESC
""")
List<Product> findNextPageByCursor(
@Param("lastDate") LocalDateTime lastDate,
@Param("lastId") Long lastId,
Pageable pageable
);
}@Service
public class ProductService {
// Keyset pagination
public List<Product> getNextPage(Long lastSeenId, int size) {
if (lastSeenId == null) {
// İlk sayfa
return productRepository.findAll(
PageRequest.of(0, size, Sort.by("id").ascending())
).getContent();
}
return productRepository.findNextPage(lastSeenId, PageRequest.ofSize(size));
}
}| Özellik | Offset-Based | Keyset (Cursor) |
|---|---|---|
| Performans (büyük offset) | ❌ Yavaşlar | ✅ Sabit hız |
| Rastgele sayfaya gitme | ✅ Kolay (sayfa 50'ye git) | ❌ Zor |
| Toplam sayfa sayısı | ✅ Bilinir | ❌ Bilinmez |
| Veri tutarlılığı | ⚠️ Kayıp/tekrar olabilir | ✅ Tutarlı |
| Kullanım | Sayfa navigator | Infinite scroll |
Yaygın Hatalar ve Çözümleri
Hata 1: Sayfa Numarası 1'den Başlıyor Sanmak
// ❌ YANLIŞ — "İlk sayfayı getir" derken
Pageable pageable = PageRequest.of(1, 20); // Bu 2. sayfadır!
// ✅ DOĞRU
Pageable pageable = PageRequest.of(0, 20); // İlk sayfaHata 2: Sayfa Boyutuna Limit Koymamak
// ❌ TEHLİKELİ — Kullanıcı ?size=1000000 gönderebilir
@GetMapping
public Page<Product> getProducts(@RequestParam int size) {
return productRepository.findAll(PageRequest.of(0, size));
}
// ✅ GÜVENLİ — Maximum limit
@GetMapping
public Page<Product> getProducts(@RequestParam(defaultValue = "20") int size) {
int safeSize = Math.min(Math.max(size, 1), 100); // 1-100 arası
return productRepository.findAll(PageRequest.of(0, safeSize));
}Hata 3: Var Olmayan Field Adıyla Sıralama
// ❌ "productName" diye field yok, "name" olmalı
Sort sort = Sort.by("productName"); // PropertyReferenceException!
// ✅ Entity'deki field adını kullanın
Sort sort = Sort.by("name");Hata 4: Sort ile Null Pointer
// ❌ sort parametresi null olabilir
Sort sort = Sort.by(sortField); // sortField null ise NullPointerException!
// ✅ Güvenli yaklaşım
Sort sort = (sortField != null && !sortField.isBlank())
? Sort.by(sortField)
: Sort.unsorted();Hata 5: Native Query'de countQuery Unutmak
// ❌ countQuery olmadan Page dönemez
@Query(value = "SELECT * FROM products WHERE status = :status", nativeQuery = true)
Page<Product> findByStatus(@Param("status") String status, Pageable pageable);
// Hata: Cannot create count query for native query!
// ✅ countQuery ekleyin
@Query(
value = "SELECT * FROM products WHERE status = :status",
countQuery = "SELECT COUNT(*) FROM products WHERE status = :status",
nativeQuery = true
)
Page<Product> findByStatus(@Param("status") String status, Pageable pageable);Bütünleşik Gerçek Dünya Örneği: Ürün Arama API'si
// ===== Controller =====
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/search")
public PagedResponse<ProductDto> searchProducts(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
var searchRequest = new ProductSearchRequest(
keyword, category, minPrice, maxPrice, page, size, sortBy, direction
);
return productService.searchProducts(searchRequest);
}
}
// ===== Service =====
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public PagedResponse<ProductDto> searchProducts(ProductSearchRequest request) {
// 1. Güvenli pageable oluştur
int safeSize = Math.min(Math.max(request.size(), 1), 100);
Sort sort = createSort(request.sortBy(), request.direction());
Pageable pageable = PageRequest.of(request.page(), safeSize, sort);
// 2. Veritabanından sorgula
Page<Product> productPage = productRepository.searchProducts(
request.keyword(), request.category(),
request.minPrice(), request.maxPrice(),
pageable
);
// 3. DTO'ya dönüştür
Page<ProductDto> dtoPage = productPage.map(this::toDto);
return PagedResponse.from(dtoPage);
}
private Sort createSort(String sortBy, String direction) {
// Whitelist ile güvenli sıralama
Set<String> allowedFields = Set.of("name", "price", "createdAt", "category");
String safeSortBy = allowedFields.contains(sortBy) ? sortBy : "createdAt";
return direction.equalsIgnoreCase("asc")
? Sort.by(safeSortBy).ascending()
: Sort.by(safeSortBy).descending();
}
private ProductDto toDto(Product p) {
return new ProductDto(p.getId(), p.getName(), p.getPrice(), p.getCategory());
}
}
// ===== Repository =====
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("""
SELECT p FROM Product p
WHERE (:keyword IS NULL OR LOWER(p.name) LIKE LOWER(CONCAT('%', :keyword, '%')))
AND (:category IS NULL OR p.category = :category)
AND (:minPrice IS NULL OR p.price >= :minPrice)
AND (:maxPrice IS NULL OR p.price <= :maxPrice)
AND p.active = true
""")
Page<Product> searchProducts(
@Param("keyword") String keyword,
@Param("category") String category,
@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice,
Pageable pageable
);
}Performans İpuçları
Toplam sayıya ihtiyacınız yoksa `Slice` kullanın — COUNT sorgusu büyük tablolarda yavaştır
Sort için index oluşturun — Sıralanan sütunlarda index yoksa full table scan olur
Çok büyük offset'lerde keyset pagination düşünün —
OFFSET 100000performansı öldürürSort field'larını whitelist ile sınırlayın — Kullanıcıdan gelen sıralama alanını doğrulamadan kullanmayın
`max-page-size` konfigüre edin — DoS saldırılarını önleyin
Özet
`PageRequest.of(page, size, sort)` ile Pageable oluşturun — sayfa numarası 0-based'tir
`Page<T>` toplam sayı bilgisi dahil → sayfa navigator için ideal, COUNT sorgusu çalıştırır
`Slice<T>` toplam sayı bilgisi yok → infinite scroll için ideal, daha performanslı
`Sort.by()` ile tekli/çoklu sıralama yapın — null handling ile
nullsFirst()/nullsLast()desteklenirREST API'lerde sayfa boyutunu sınırlayın (
max-page-size) ve sort alanlarını whitelist'leyinBüyük offset'lerde keyset pagination tercih edin — sabit performans sağlar
AI Asistan
Sorularını yanıtlamaya hazır