Query, Pagination, Index & SQL Injection
Dua masalah backend yang paling sering menghantui produksi adalah query yang lambat dan query yang bocor. Query lambat bikin aplikasi terasa berat dan database jebol saat trafik ramai; query bocor membuat data pelanggan jatuh ke tangan orang yang tidak berhak. Keduanya berakar pada hal yang sama: cara kita menulis query ke database. Di artikel ini kita bahas empat topik yang saling terkait — derived query, pagination, index, dan SQL injection — persis seperti yang kita pakai sehari-hari di Spring Data JPA.
Kenapa Topik Ini Penting
Bayangkan Anda punya tabel users berisi 5 juta baris. Sekarang bandingkan dua skenario:
| Skenario | Akibat |
|---|---|
| Query tanpa pagination, tanpa index | Database memindai jutaan baris setiap request → response lambat, CPU tinggi |
Query dengan String yang disusun dari input user | Attacker menyuntikkan SQL → data pelanggan bocor |
Kedua skenario itu adalah penyebab umum production incident di tim backend. Kabar baiknya, keduanya bisa dicegah dengan kebiasaan yang sederhana dan konsisten: biarkan framework yang menyusun SQL untuk Anda, batasi jumlah data yang diambil, dan siapkan index di kolom yang sering difilter.
Aturan emas backend: jangan pernah menulis
SELECT *tanpa filter di aplikasi produksi, dan jangan pernah menggabungkan string input user ke dalam query.
Derived Query: Menurunkan SQL dari Nama Method
Spring Data JPA punya fitur yang rapi: Anda cukup menamai method repository dengan pola tertentu, dan Spring menurunkan SQL-nya secara otomatis. Fitur ini disebut derived query — SQL-nya "diturunkan" dari nama method. Dokumentasi resminya menjelaskan aturan penamaan lengkap di reference repositories/query-methods.
Keyword yang Sering Dipakai
| Nama method | SQL yang dihasilkan (kira-kira) | Kegunaan |
|---|---|---|
findByEmail(String email) | SELECT ... WHERE email = ? | Cari satu baris berdasar email |
findAllByStatus(Status s) | SELECT ... WHERE status = ? | Ambil semua baris dengan status tertentu |
findByNameContainingIgnoreCase(String n) | WHERE LOWER(name) LIKE LOWER(?) | Pencarian sebagian nama, tidak peka huruf besar/kecil |
existsByEmail(String email) | SELECT EXISTS(...) | Cek keberadaan data, hasil boolean |
countByStatus(Status s) | SELECT COUNT(*) WHERE status = ? | Hitung jumlah baris |
findFirst10ByStatusOrderByCreatedAtDesc(Status s) | WHERE status = ? ORDER BY created_at DESC LIMIT 10 | Ambil 10 data terbaru |
Contoh repository lengkap:
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByNameContainingIgnoreCase(String name);
boolean existsByEmail(String email);
long countByStatus(UserStatus status);
List<User> findFirst10ByStatusOrderByCreatedAtDesc(UserStatus status);
}
Perhatikan: findByEmail mengembalikan Optional<User> karena secara bisnis email itu unik. Metode existsByEmail dan countByStatus tidak men-download seluruh data ke aplikasi — mereka menghasilkan query EXISTS dan COUNT yang cepat.
Keterbatasan Derived Query
Derived query sangat nyaman, tapi ada batasnya. Semakin panjang nama method, semakin sulit dibaca dan semakin terbatas ekspresinya. Misalnya:
- Query dengan
GROUP BY,HAVING, atau subquery tidak bisa dinyatakan lewat nama method. - Kondisi
ORyang rumit membuat nama method jadi tidak masuk akal, misalnyafindByStatusOrRoleOr.... - Menggabungkan beberapa tabel sekaligus lebih jelas ditulis manual.
Saat nama method sudah lebih rumit daripada kalimat, saat itu beralihlah ke @Query.
@Query: JPQL vs Native SQL
@Query memberi Anda kendali penuh atas query. Ada dua bahasa yang bisa ditulis di dalamnya:
| Aspek | JPQL | Native SQL |
|---|---|---|
| Target | Entity JPA, bukan tabel | Tabel database langsung |
| Portabilitas | Bekerja di semua database | Khas satu database (mis. MySQL vs PostgreSQL) |
| Pelepasan | Otomatis ke tipe entity | Perlu mapping manual (nativeQuery = true) |
| Kapan dipakai | Kebutuhan normal, tetap JPA-friendly | Fitur khusus database, performa tingkat lanjut |
Contoh @Query dengan JPQL:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.customer.email = :email AND o.status = :status")
List<Order> findByCustomerEmailAndStatus(
@Param("email") String email,
@Param("status") OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.customerId IN (SELECT c.id FROM Customer c WHERE c.tier = :tier)")
long countOrdersForTier(@Param("tier") CustomerTier tier);
}
Binding Parameter: :email vs ?1
Di JPQL Anda bisa memakai named parameter (:email) atau positional parameter (?1):
| Bentuk | Contoh | Catatan |
|---|---|---|
| Named | :email, lalu @Param("email") String email | Lebih terbaca, urutan tidak penting |
| Positional | ?1, ?2 | Singkat, tapi mudah salah saat query berubah |
@Query("SELECT u FROM User u WHERE u.email = ?1")
Optional<User> findByEmailPositional(String email);
Poin penting: baik JPQL, native SQL, maupun derived query — Spring selalu menggunakan parameter binding, bukan menempelkan nilai ke dalam string SQL. Ini kunci dari amannya JPA terhadap SQL injection.
Jangan Pernah Konkatenasi String
Yang sangat dilarang adalah menyusun query dengan tanda + atau String.format:
// CONTOH RENTAN — jangan dilakukan!
@Query("SELECT u FROM User u WHERE u.email = '" + email + "'")
Optional<User> findByEmailRentan(String email);
Aturan Hibernate sendiri menegaskan pemakaian parameter binding di Hibernate User Guide. Intinya: jika nilai dimasukkan langsung ke string SQL, maka string itu "percaya" pada input user — dan input user tidak boleh dipercaya.
Pagination: Jangan Load 1 Juta Baris Sekaligus
Bayangkan frontend meminta GET /api/users dan backend menjawab dengan 1 juta objek user. Response-nya gede, memori server penuh, dan ponsel user ikut lemot. Solusinya adalah pagination: membagi data besar menjadi halaman-halaman kecil.
Spring Data menyediakan Pageable dan Page<T>. Penjelasan resminya ada di reference repositories/paging-and-sorting.
Pageable & Page
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findAll(pageable);
Objek Page<T> berisi lebih dari sekadar data halaman:
| Properti | Isi | Contoh nilai |
|---|---|---|
content | Baris pada halaman ini | 20 objek user |
totalElements | Total semua data (semua halaman) | 1.234.567 |
totalPages | Total halaman | 61.729 |
number | Nomor halaman saat ini (mulai 0) | 0 |
size | Ukuran halaman | 20 |
Offset vs Cursor Pagination
Ada dua strategi umum untuk melompat antar halaman:
| Aspek | Offset (page + size) | Cursor (LIMIT + WHERE id > ?) |
|---|---|---|
| Cara kerja | Lewati offset baris | Lanjutkan dari baris terakhir yang sudah dilihat |
| Cocok untuk | Data yang jarang berubah, UI klasik | Feed yang terus bertambah (mis. notifikasi, timeline) |
| Kelemahan | Lambat saat offset besar, hasil bisa dobel saat data berubah | Tidak bisa lompat ke halaman tertentu dengan mudah |
Untuk sebagian besar aplikasi internal dan dashboard, offset pagination sudah cukup dan paling sederhana. Jika data Anda real-time dan terus bertambah (feed berita, log), pertimbangkan cursor pagination.
Implementasi di Controller & Service
@RestController
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// GET /api/products?page=0&size=20&sort=-createdAt
@GetMapping("/api/products")
public Page<ProductDto> listProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "-createdAt") String sort) {
Sort.Order order = sort.startsWith("-")
? Sort.Order.desc(sort.substring(1))
: Sort.Order.asc(sort);
Pageable pageable = PageRequest.of(page, size, Sort.by(order));
return productService.findPublishedProducts(pageable);
}
}
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Page<ProductDto> findPublishedProducts(Pageable pageable) {
return productRepository.findByPublishedTrue(pageable)
.map(ProductDto::from);
}
}
public interface ProductRepository extends JpaRepository<Product, Long> {
// Page<Product> memberitahu JPA agar menambahkan COUNT dan LIMIT otomatis
Page<Product> findByPublishedTrue(Pageable pageable);
}
Kontrak JSON yang dikembalikan Spring secara otomatis:
{
"content": [
{ "id": 1, "name": "Kopi Gayo", "price": 75000 }
],
"pageable": { "page": 0, "size": 20 },
"totalElements": 120,
"totalPages": 6,
"number": 0,
"size": 20,
"last": false,
"first": true,
"empty": false
}
Kebiasaan baik: jangan biarkan ukuran halaman tanpa batas. Batasi
sizemaksimal (misal 100) di lapisan service, sehingga user tidak bisa memaksa database men-download semua baris sekaligus.
Index: Membuat Query Cepat
Index pada database bisa dianalogikan dengan indeks di belakang buku: tanpa indeks, untuk mencari kata "JPA" Anda harus membuka halaman demi halaman (ini namanya full table scan). Dengan indeks, Anda langsung menuju daftar kata lalu ke halamannya. Database bekerja persis seperti itu.
Index Mempercepat Membaca, Memperlambat Menulis
| Operasi | Tanpa index | Dengan index |
|---|---|---|
WHERE kolom = ? | Scan semua baris | Langsung ke baris yang cocok |
ORDER BY kolom | Urutkan dulu di memori | Baca sudah terurut dari index |
JOIN antar tabel | O(n×m) yang mahal | Langsung mencocokkan kunci |
INSERT / UPDATE | Menulis baris saja | Menulis baris plus memperbarui index |
Perhatikan baris terakhir: setiap index menambah pekerjaan saat menulis data. Inilah tradeoff — semakin banyak index, semakin cepat baca tapi semakin berat tulis.
Kapan Database Memakai Index vs Full Scan
Database (melalui query planner) tidak selalu memakai index. Secara sederhana:
| Kondisi | Pilihan database |
|---|---|
| Ada index yang cocok, tabel besar | Memakai index (Index Scan) |
| Tabel sangat kecil (mis. 50 baris) | Full scan justru lebih cepat |
| Index tidak cocok dengan pola query | Jatuh ke full scan |
Cara memastikannya adalah EXPLAIN — sudah dibahas mendalam di artikel Postgres. Di JPA, index dideklarasikan dengan anotasi pada entity:
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_users_email", columnList = "email"),
@Index(name = "idx_users_status_created", columnList = "status, created_at DESC")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String status;
private LocalDateTime createdAt;
}
Setara dengan SQL:
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_users_status_created ON users (status, created_at DESC);
Wildcard di Awal: Musuh Index
Satu hal yang wajib diingat: index tidak dipakai untuk pola LIKE yang diawali wildcard, misalnya WHERE name LIKE '%ijo%'. Karena karakter % di awal, database tidak tahu harus mulai dari mana mencari di index, sehingga kembali ke scan penuh. Bandingkan:
| Pola | Memakai index? |
|---|---|
LIKE 'kopi%' | Ya — awalan tetap, bisa lewat index |
LIKE '%kopi%' | Tidak — awalan wildcard, full scan |
Untuk pencarian teks penuh yang serius, jangan andalkan LIKE '%...%'; gunakan strategi seperti pg_trgm atau full-text search yang dibahas di artikel advanced.
SQL Injection: Query yang Bocor
SQL injection adalah teknik serangan di mana penyerang memasukkan perintah SQL lewat input yang seharusnya hanya berisi data. Karena input itu digabung mentah-mentah ke query, perintahnya ikut dieksekusi.
Contoh Serangan
Formulir login menerima email. Attacker mengetik:
admin@example.com' OR '1'='1
Jika query disusun dengan konkatenasi:
SELECT * FROM users WHERE email = 'admin@example.com' OR '1'='1'
Maka '1'='1' selalu benar, dan query mengembalikan semua user — attacker masuk tanpa tahu password. Ini serangan paling klasik. Referensi lengkapnya ada di OWASP SQL Injection Prevention Cheat Sheet.
Kenapa Konkatenasi Berbahaya
Saat Anda menulis "WHERE email = '" + email + "'", Anda memberi tahu database: "string ini adalah query, dan nilai di tengahnya juga saya anggap query." Padahal yang di tengah itu data. Akibatnya:
| Masalah | Akibat |
|---|---|
| Input tidak dibedakan dari perintah | Attacker bisa mengubah logika query |
Contoh ' OR '1'='1 | Melewati autentikasi, membaca data orang lain |
Variasi '; DROP TABLE users; -- | Merusak atau menghapus data |
Pencegahan: Parameter Binding
Solusinya sederhana: pisahkan query dari data. Nilai dikirim sebagai parameter, bukan ditempel sebagai teks. Database lalu memperlakukan nilai sebagai data murni — tidak peduli apa isinya.
// RENTAN — input disusun jadi string query
public List<User> findByEmailRentan(String email) {
String sql = "SELECT * FROM users WHERE email = '" + email + "'";
return entityManager.createNativeQuery(sql, User.class).getResultList();
}
// AMAN — nilai dikirim sebagai parameter
public Optional<User> findByEmailAman(String email) {
TypedQuery<User> query = entityManager
.createQuery("SELECT u FROM User u WHERE u.email = :email", User.class)
.setParameter("email", email);
return query.getResultStream().findFirst();
}
Perhatikan perbedaannya: di versi rentan, email menjadi bagian dari string SQL; di versi aman, email diikat dengan .setParameter("email", email) sebagai data. Dengan pendekatan ini, admin@example.com' OR '1'='1 hanyalah sebuah string biasa yang tidak pernah dieksekusi sebagai perintah.
Pola yang sama berlaku di semua lapisan — derived query, @Query, PreparedStatement, dan native query: selalu serahkan nilai lewat parameter, jangan lewat tekstual.
Validasi Input Tetap Perlu (Defense in Depth)
Parameter binding menangkal SQL injection, tapi bukan alasan untuk berhenti memvalidasi input. Prinsip defense in depth = berlapis:
public void registerUser(String email) {
if (email == null || !email.matches("[\\w.+-]+@[\\w.-]+\\.[a-zA-Z]{2,}")) {
throw new IllegalArgumentException("Email tidak valid");
}
if (userRepository.existsByEmail(email)) {
throw new ConflictException("Email sudah terdaftar");
}
// simpan user...
}
Parameter binding mencegah SQL bocor; validasi mencegah data sampah masuk dan menjaga aturan bisnis. Keduanya berjalan beriringan, bukan pengganti satu sama lain.
Studi Kasus: Endpoint Pencarian Produk
Semua konsep di atas menyatu dalam satu contoh: GET /api/products?search=&page=&size=. Endpoint ini mencari produk berdasar kata kunci, membatasi hasil per halaman, dan aman dari SQL injection — semuanya sekaligus.
public interface ProductRepository extends JpaRepository<Product, Long> {
// derived query + pagination + parameter binding, sekali jalan
Page<Product> findByNameContainingIgnoreCase(String search, Pageable pageable);
}
@Service
public class ProductService {
private static final int MAX_PAGE_SIZE = 100;
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Page<Product> search(String search, int page, int size, String sort) {
int boundedSize = Math.min(size, MAX_PAGE_SIZE);
Sort sortable = sort.startsWith("-")
? Sort.by(Sort.Order.desc(sort.substring(1)))
: Sort.by(Sort.Order.asc(sort));
Pageable pageable = PageRequest.of(Math.max(page, 0), boundedSize, sortable);
if (search == null || search.isBlank()) {
return productRepository.findAll(pageable);
}
return productRepository.findByNameContainingIgnoreCase(search.trim(), pageable);
}
}
@RestController
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// GET /api/products?search=kopi&page=0&size=20&sort=-price
@GetMapping("/api/products")
public Page<Product> searchProducts(
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "-createdAt") String sort) {
return productService.search(search, page, size, sort);
}
}
Yang perlu diperhatikan dari contoh ini:
- Derived query
findByNameContainingIgnoreCasemenghasilkanWHERE LOWER(name) LIKE LOWER(?)— pencarian tidak peka huruf besar/kecil. - Pagination lewat
Page<Product>memastikan database hanya mengembalikan satu halaman +COUNT, bukan semua baris. - Ukuran halaman dibatasi di
productServicedenganMath.min(size, MAX_PAGE_SIZE). - Parameter binding terjadi otomatis karena nilai
searchdikirim sebagai parameter, bukan disisipkan ke string — input"'; DROP TABLE products; --"tetap hanya teks.
Ringkasan
Query yang benar dan aman adalah fondasi backend yang sehat. Biarkan Spring Data JPA menurunkan SQL dari nama method (derived query) selama masih terbaca; gunakan @Query untuk kebutuhan yang lebih rumit. Selalu batasi pengambilan data dengan Pageable dan Page<T> agar aplikasi tidak men-download jutaan baris. Siapkan index di kolom yang sering difilter — tapi ingat tradeoff-nya terhadap operasi tulis. Dan yang terpenting: jangan pernah menyusun query dari string input user; pakai parameter binding, lalu pertebal pertahanan dengan validasi input. Empat kebiasaan ini mencegah dua insiden paling umum di produksi: aplikasi yang lemot dan data yang bocor.
Lanjut membaca
- Spring Data JPA — Query Methods — aturan penamaan derived query yang lengkap.
- Spring Data JPA — Paging and Sorting —
Pageable,Sort, dan kontrakPage. - OWASP SQL Injection Prevention Cheat Sheet — pencegahan SQL injection dari perspektif keamanan.
- Hibernate User Guide — Query Language — detail JPQL/HQL, parameter, dan query hints.
- Dasar Database Relasional & SQL — pemahaman dasar SQL sebelum masuk ke Spring Data JPA.
- PostgreSQL Indexing Strategies —
EXPLAIN, composite index, partial index, dan kapan berhenti menambah index.