← Kursa Dön
📄 Text · 15 min

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:

  1. Bellek tüketir — 1 milyon satır × 10 sütun = gigabyte'larca RAM

  2. Ağ bant genişliğini doldurur — JSON olarak serialize edilmiş veri devasa olur

  3. Yanıt süresini uzatır — Kullanıcı 10 saniye beklemek istemez

  4. 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:

ÖzellikPage<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
PerformansDaha yavaş (büyük tablolarda)Daha hızlı
Kullanım senaryosuSayfa 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ıralama

Slice 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=asc

Spring'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,desc

Pageable 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şa

Type-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));
    }
}
ÖzellikOffset-BasedKeyset (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ımSayfa navigatorInfinite 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 sayfa

Hata 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ı

  1. Toplam sayıya ihtiyacınız yoksa `Slice` kullanın — COUNT sorgusu büyük tablolarda yavaştır

  2. Sort için index oluşturun — Sıralanan sütunlarda index yoksa full table scan olur

  3. Çok büyük offset'lerde keyset pagination düşününOFFSET 100000 performansı öldürür

  4. Sort field'larını whitelist ile sınırlayın — Kullanıcıdan gelen sıralama alanını doğrulamadan kullanmayın

  5. `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() desteklenir

  • REST API'lerde sayfa boyutunu sınırlayın (max-page-size) ve sort alanlarını whitelist'leyin

  • Büyük offset'lerde keyset pagination tercih edin — sabit performans sağlar