MySQL #
MySQL adalah database relasional yang paling banyak dipakai di dunia — dari blog WordPress hingga platform e-commerce skala besar. Di Java, ada dua lapisan untuk berinteraksi dengan MySQL: JDBC (Java Database Connectivity) yang merupakan API standar tingkat rendah, dan JPA/Hibernate yang menyediakan abstraksi objek-relasional tingkat tinggi. Untuk aplikasi produksi, keduanya hampir selalu dilengkapi dengan HikariCP — connection pool tercepat di ekosistem Java. Artikel ini membahas cara koneksi dari awal dengan JDBC murni, mengelola koneksi secara efisien dengan HikariCP, menjalankan operasi CRUD yang aman dari SQL injection, mengelola transaksi, batch processing untuk data massal, hingga cara integrasi Spring Boot dengan Spring Data JPA untuk produktivitas maksimal.
Gambaran Umum #
Ada tiga level abstraksi untuk berinteraksi dengan MySQL di Java:
flowchart TB
A["Aplikasi Java"] --> B["Spring Data JPA\n(Repository, @Entity)"]
A --> C["JDBC Template\n(Spring)"]
A --> D["JDBC Murni\n(Connection, Statement)"]
B --> E["Hibernate / JPA"]
C --> D
E --> D
D --> F["HikariCP\n(Connection Pool)"]
F --> G["MySQL JDBC Driver\n(mysql-connector-j)"]
G --> H[("MySQL Server")]| Level | Abstraksi | Produktivitas | Kontrol | Cocok untuk |
|---|---|---|---|---|
| JDBC murni | Rendah | Rendah | Penuh | Belajar, kasus khusus, performa kritis |
| Spring JDBC | Sedang | Sedang | Tinggi | Query kompleks, stored procedure |
| Spring Data JPA | Tinggi | Tinggi | Terbatas | CRUD standar, rapid development |
Persiapan — Driver dan Database #
Dependensi #
<!-- Maven -->
<!-- Driver MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<!-- HikariCP — connection pool (sudah termasuk di Spring Boot) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>
// Gradle
implementation 'com.mysql:mysql-connector-j:8.3.0'
implementation 'com.zaxxer:HikariCP:5.1.0'
Menyiapkan Database #
-- Jalankan di MySQL client atau Workbench
CREATE DATABASE IF NOT EXISTS tokodb
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE tokodb;
CREATE TABLE produk (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(255) NOT NULL,
harga DECIMAL(15,2) NOT NULL,
stok INT NOT NULL DEFAULT 0,
kategori VARCHAR(100),
aktif BOOLEAN NOT NULL DEFAULT TRUE,
dibuat_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
diubah_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO produk (nama, harga, stok, kategori) VALUES
('Laptop ProBook', 12000000.00, 5, 'Elektronik'),
('Mouse Wireless', 150000.00, 20, 'Aksesori'),
('Keyboard Mekanikal', 450000.00, 15, 'Aksesori'),
('Monitor 27"', 3500000.00, 8, 'Elektronik');
JDBC Murni #
JDBC adalah API standar Java untuk berkomunikasi dengan database. Setiap operasi database melalui tiga objek: Connection (koneksi ke DB), PreparedStatement (query yang akan dieksekusi), dan ResultSet (hasil query).
Koneksi Langsung (Tanpa Pool) #
import java.sql.*;
// URL koneksi MySQL
// Format: jdbc:mysql://host:port/database?parameter=nilai
String url = "jdbc:mysql://localhost:3306/tokodb"
+ "?useSSL=false" // nonaktifkan SSL (development)
+ "&serverTimezone=Asia/Jakarta"
+ "&characterEncoding=utf8mb4"
+ "&allowPublicKeyRetrieval=true";
String username = "root";
String password = "rahasiaku";
// DriverManager.getConnection() membuat koneksi baru setiap kali
// Gunakan HANYA untuk testing — di produksi selalu pakai connection pool
try (Connection conn = DriverManager.getConnection(url, username, password)) {
System.out.println("Terhubung ke MySQL: " + conn.getMetaData().getDatabaseProductVersion());
} catch (SQLException e) {
System.err.println("Koneksi gagal: " + e.getMessage());
}
SELECT — Membaca Data #
String url = "jdbc:mysql://localhost:3306/tokodb?serverTimezone=Asia/Jakarta";
try (Connection conn = DriverManager.getConnection(url, "root", "rahasiaku")) {
// ANTI-PATTERN: String concatenation → rentan SQL injection
String kategori = "Elektronik";
// String sql = "SELECT * FROM produk WHERE kategori = '" + kategori + "'"; // ✗ JANGAN!
// BENAR: PreparedStatement dengan parameter binding
String sql = "SELECT id, nama, harga, stok FROM produk WHERE kategori = ? AND aktif = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, kategori); // parameter pertama (?)
ps.setBoolean(2, true); // parameter kedua (?)
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String nama = rs.getString("nama");
double harga = rs.getDouble("harga");
int stok = rs.getInt("stok");
System.out.printf("%-5d %-25s Rp%,.2f (%d unit)%n",
id, nama, harga, stok);
}
}
}
} catch (SQLException e) {
System.err.println("Query gagal: " + e.getMessage());
System.err.println("SQL State: " + e.getSQLState());
System.err.println("Error Code: " + e.getErrorCode());
}
INSERT — Menyimpan Data #
String sql = "INSERT INTO produk (nama, harga, stok, kategori) VALUES (?, ?, ?, ?)";
try (Connection conn = DriverManager.getConnection(url, username, password);
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, "SSD 1TB");
ps.setBigDecimal(2, new java.math.BigDecimal("750000.00")); // gunakan BigDecimal untuk uang
ps.setInt(3, 30);
ps.setString(4, "Penyimpanan");
int rowsAffected = ps.executeUpdate();
System.out.println("Baris terpengaruh: " + rowsAffected); // 1
// Ambil ID yang digenerate auto-increment
try (ResultSet generatedKeys = ps.getGeneratedKeys()) {
if (generatedKeys.next()) {
long idBaru = generatedKeys.getLong(1);
System.out.println("ID baru: " + idBaru);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
UPDATE dan DELETE #
// UPDATE
String sqlUpdate = "UPDATE produk SET harga = ?, stok = stok + ? WHERE id = ?";
try (Connection conn = DriverManager.getConnection(url, username, password);
PreparedStatement ps = conn.prepareStatement(sqlUpdate)) {
ps.setBigDecimal(1, new java.math.BigDecimal("11500000.00"));
ps.setInt(2, 3); // tambah stok 3
ps.setLong(3, 1L); // id produk
int rowsUpdated = ps.executeUpdate();
System.out.println("Produk diperbarui: " + rowsUpdated);
} catch (SQLException e) {
e.printStackTrace();
}
// Soft DELETE — ubah aktif menjadi false (lebih aman dari hard delete)
String sqlDelete = "UPDATE produk SET aktif = FALSE WHERE id = ?";
try (Connection conn = DriverManager.getConnection(url, username, password);
PreparedStatement ps = conn.prepareStatement(sqlDelete)) {
ps.setLong(1, 5L);
ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
Transaksi #
Transaksi memastikan serangkaian operasi berhasil semuanya atau tidak ada yang berhasil — prinsip atomicity. Secara default, JDBC berjalan dalam auto-commit mode (setiap statement langsung di-commit).
Connection conn = null;
try {
conn = DriverManager.getConnection(url, username, password);
// Nonaktifkan auto-commit untuk mulai transaksi manual
conn.setAutoCommit(false);
// Skenario: transfer stok antara dua gudang
String kurangiStok = "UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?";
String tambahStok = "UPDATE produk SET stok = stok + ? WHERE id = ?";
try (PreparedStatement ps1 = conn.prepareStatement(kurangiStok);
PreparedStatement ps2 = conn.prepareStatement(tambahStok)) {
// Kurangi stok gudang asal
ps1.setInt(1, 5); // kurangi 5
ps1.setLong(2, 1L);
ps1.setInt(3, 5); // pastikan stok mencukupi
int berkurang = ps1.executeUpdate();
if (berkurang == 0) {
throw new SQLException("Stok tidak mencukupi untuk transfer");
}
// Tambah stok gudang tujuan (simulasi: produk ID berbeda)
ps2.setInt(1, 5);
ps2.setLong(2, 2L);
ps2.executeUpdate();
// Commit: semua berhasil
conn.commit();
System.out.println("Transfer stok berhasil.");
} catch (SQLException e) {
// Rollback: batalkan semua perubahan
conn.rollback();
System.err.println("Transfer gagal, rollback: " + e.getMessage());
}
} catch (SQLException e) {
e.printStackTrace();
} finally {
if (conn != null) {
try {
conn.setAutoCommit(true); // kembalikan ke mode default
conn.close();
} catch (SQLException e) { e.printStackTrace(); }
}
}
Batch Processing — Insert Data Massal #
Batch processing memungkinkan kamu mengirim banyak statement sekaligus ke database dalam satu round-trip jaringan — jauh lebih cepat dari satu per satu.
String sql = "INSERT INTO produk (nama, harga, stok, kategori) VALUES (?, ?, ?, ?)";
try (Connection conn = DriverManager.getConnection(url, username, password);
PreparedStatement ps = conn.prepareStatement(sql)) {
conn.setAutoCommit(false); // transaksi untuk batch
List<String[]> dataProduk = List.of(
new String[]{"Item A", "100000", "10", "Umum"},
new String[]{"Item B", "200000", "5", "Umum"},
new String[]{"Item C", "300000", "8", "Premium"}
// ... bisa ribuan item
);
int batchSize = 500; // commit setiap 500 baris
int count = 0;
for (String[] data : dataProduk) {
ps.setString(1, data[0]);
ps.setBigDecimal(2, new java.math.BigDecimal(data[1]));
ps.setInt(3, Integer.parseInt(data[2]));
ps.setString(4, data[3]);
ps.addBatch(); // tambahkan ke batch, belum dieksekusi
if (++count % batchSize == 0) {
ps.executeBatch(); // kirim batch ke database
conn.commit();
System.out.println("Batch " + (count / batchSize) + " di-commit");
}
}
// Eksekusi sisa batch
ps.executeBatch();
conn.commit();
System.out.println("Semua " + count + " baris berhasil disimpan.");
} catch (SQLException e) {
e.printStackTrace();
}
HikariCP — Connection Pool #
Membuat koneksi database baru adalah operasi yang mahal — butuh TCP handshake, autentikasi, dan alokasi resource di server. Connection pool menyimpan sejumlah koneksi yang siap pakai dan meminjamkannya ke kode yang meminta, lalu mengembalikannya ke pool setelah selesai.
flowchart LR
subgraph "HikariCP Pool (max=10)"
C1["Koneksi 1"]
C2["Koneksi 2"]
C3["Koneksi 3"]
C4["... dst"]
end
A["Request 1"] -->|"getConnection()"| C1
B["Request 2"] -->|"getConnection()"| C2
D["Request 3"] -->|"getConnection()"| C3
C1 -->|"close() → kembali ke pool"| C1Konfigurasi HikariCP #
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
public class DatabasePool {
private static final HikariDataSource dataSource;
static {
HikariConfig config = new HikariConfig();
// Koneksi dasar
config.setJdbcUrl("jdbc:mysql://localhost:3306/tokodb"
+ "?serverTimezone=Asia/Jakarta"
+ "&characterEncoding=utf8mb4"
+ "&useSSL=false");
config.setUsername("root");
config.setPassword("rahasiaku");
config.setDriverClassName("com.mysql.cj.jdbc.Driver");
// Pool sizing — aturan praktis: (jumlah_core * 2) + jumlah_disk
config.setMaximumPoolSize(10); // maksimal koneksi aktif
config.setMinimumIdle(2); // minimal koneksi idle
config.setConnectionTimeout(30_000); // timeout tunggu koneksi (ms)
config.setIdleTimeout(600_000); // hapus koneksi idle > 10 menit
config.setMaxLifetime(1_800_000); // daur ulang koneksi setiap 30 menit
// Validasi koneksi masih hidup
config.setConnectionTestQuery("SELECT 1");
config.setKeepaliveTime(60_000); // kirim keepalive setiap 1 menit
// Nama pool untuk monitoring
config.setPoolName("TokoDB-Pool");
dataSource = new HikariDataSource(config);
}
public static java.sql.Connection getConnection() throws java.sql.SQLException {
return dataSource.getConnection();
}
public static void tutup() {
if (!dataSource.isClosed()) {
dataSource.close();
}
}
}
Menggunakan Pool #
// Dengan pool, pola penggunaannya sama persis
// try-with-resources memastikan koneksi DIKEMBALIKAN ke pool, bukan ditutup sungguhan
try (Connection conn = DatabasePool.getConnection()) {
String sql = "SELECT COUNT(*) FROM produk WHERE aktif = true";
try (PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
System.out.println("Produk aktif: " + rs.getInt(1));
}
}
} catch (SQLException e) {
e.printStackTrace();
}
DAO Pattern — Pemisahan Logika Database #
Data Access Object (DAO) adalah pola desain yang memisahkan logika akses database dari logika bisnis. Setiap tabel punya kelas DAO-nya sendiri.
Model dan DAO #
// Model
public class Produk {
private Long id;
private String nama;
private java.math.BigDecimal harga;
private int stok;
private String kategori;
private boolean aktif;
// Konstruktor, getter, setter
public Produk() {}
public Produk(String nama, java.math.BigDecimal harga, int stok, String kategori) {
this.nama = nama; this.harga = harga; this.stok = stok; this.kategori = kategori;
}
// ... getter dan setter
}
// Interface DAO
public interface ProdukDao {
Optional<Produk> findById(Long id);
List<Produk> findAll();
List<Produk> findByKategori(String kategori);
Produk save(Produk produk); // insert jika id null, update jika ada
boolean delete(Long id);
int hitungStokTotal();
}
// Implementasi DAO
public class ProdukDaoImpl implements ProdukDao {
private static final String SELECT_BASE =
"SELECT id, nama, harga, stok, kategori, aktif FROM produk WHERE aktif = true";
@Override
public Optional<Produk> findById(Long id) {
String sql = SELECT_BASE + " AND id = ?";
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return Optional.of(mapRow(rs));
}
} catch (SQLException e) {
throw new RuntimeException("Gagal mencari produk ID: " + id, e);
}
return Optional.empty();
}
@Override
public List<Produk> findByKategori(String kategori) {
String sql = SELECT_BASE + " AND kategori = ? ORDER BY nama";
List<Produk> hasil = new ArrayList<>();
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, kategori);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) hasil.add(mapRow(rs));
}
} catch (SQLException e) {
throw new RuntimeException("Gagal mencari produk kategori: " + kategori, e);
}
return hasil;
}
@Override
public Produk save(Produk produk) {
if (produk.getId() == null) {
return insert(produk);
} else {
return update(produk);
}
}
private Produk insert(Produk p) {
String sql = "INSERT INTO produk (nama, harga, stok, kategori) VALUES (?, ?, ?, ?)";
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, p.getNama());
ps.setBigDecimal(2, p.getHarga());
ps.setInt(3, p.getStok());
ps.setString(4, p.getKategori());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) p.setId(keys.getLong(1));
}
return p;
} catch (SQLException e) {
throw new RuntimeException("Gagal menyimpan produk", e);
}
}
private Produk update(Produk p) {
String sql = "UPDATE produk SET nama=?, harga=?, stok=?, kategori=? WHERE id=?";
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, p.getNama());
ps.setBigDecimal(2, p.getHarga());
ps.setInt(3, p.getStok());
ps.setString(4, p.getKategori());
ps.setLong(5, p.getId());
ps.executeUpdate();
return p;
} catch (SQLException e) {
throw new RuntimeException("Gagal memperbarui produk ID: " + p.getId(), e);
}
}
// Helper: mapping ResultSet ke objek Produk
private Produk mapRow(ResultSet rs) throws SQLException {
Produk p = new Produk();
p.setId(rs.getLong("id"));
p.setNama(rs.getString("nama"));
p.setHarga(rs.getBigDecimal("harga"));
p.setStok(rs.getInt("stok"));
p.setKategori(rs.getString("kategori"));
p.setAktif(rs.getBoolean("aktif"));
return p;
}
@Override
public List<Produk> findAll() {
List<Produk> hasil = new ArrayList<>();
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(SELECT_BASE + " ORDER BY id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) hasil.add(mapRow(rs));
} catch (SQLException e) {
throw new RuntimeException("Gagal mengambil semua produk", e);
}
return hasil;
}
@Override
public boolean delete(Long id) {
String sql = "UPDATE produk SET aktif = FALSE WHERE id = ?";
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
return ps.executeUpdate() > 0;
} catch (SQLException e) {
throw new RuntimeException("Gagal menghapus produk ID: " + id, e);
}
}
@Override
public int hitungStokTotal() {
String sql = "SELECT SUM(stok) FROM produk WHERE aktif = true";
try (Connection conn = DatabasePool.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getInt(1) : 0;
} catch (SQLException e) {
throw new RuntimeException("Gagal menghitung stok", e);
}
}
}
Spring Boot + Spring Data JPA #
Untuk aplikasi Spring Boot, Spring Data JPA menyederhanakan akses database secara drastis. Kamu mendefinisikan entity dan interface repository — Spring menghasilkan implementasinya secara otomatis.
Dependensi Spring Boot #
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
Konfigurasi application.yml #
spring:
datasource:
url: jdbc:mysql://localhost:3306/tokodb?serverTimezone=Asia/Jakarta&characterEncoding=utf8mb4
username: root
password: rahasiaku
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
pool-name: TokoDB-Pool
jpa:
hibernate:
ddl-auto: validate # validate: cek skema, jangan ubah
# update: update skema otomatis (dev)
# create-drop: hapus dan buat ulang (test)
show-sql: false # true untuk debug (log semua SQL)
open-in-view: false # nonaktifkan untuk performa lebih baik
properties:
hibernate:
dialect: org.hibernate.dialect.MySQLDialect
format_sql: true
jdbc:
batch_size: 50 # aktifkan batch insert
fetch_size: 100
Entity #
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "produk")
public class Produk {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String nama;
@Column(nullable = false, precision = 15, scale = 2)
private BigDecimal harga;
@Column(nullable = false)
private Integer stok = 0;
@Column(length = 100)
private String kategori;
@Column(nullable = false)
private Boolean aktif = true;
@Column(name = "dibuat_pada", updatable = false)
private LocalDateTime dibuatPada;
@Column(name = "diubah_pada")
private LocalDateTime diubahPada;
@PrePersist
protected void onCreate() {
dibuatPada = LocalDateTime.now();
diubahPada = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
diubahPada = LocalDateTime.now();
}
// Konstruktor, getter, setter
public Produk() {}
public Produk(String nama, BigDecimal harga, Integer stok, String kategori) {
this.nama = nama; this.harga = harga; this.stok = stok; this.kategori = kategori;
}
// ... getter dan setter
}
Repository #
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
@Repository
public interface ProdukRepository extends JpaRepository<Produk, Long> {
// Spring Data JPA menghasilkan implementasi dari nama metode
List<Produk> findByAktifTrue();
List<Produk> findByKategoriAndAktifTrue(String kategori);
List<Produk> findByNamaContainingIgnoreCaseAndAktifTrue(String kata);
Optional<Produk> findByIdAndAktifTrue(Long id);
// Cari berdasarkan rentang harga
List<Produk> findByHargaBetweenAndAktifTrue(BigDecimal min, BigDecimal max);
// Query kustom dengan JPQL (bekerja dengan nama entity dan field, bukan tabel)
@Query("SELECT p FROM Produk p WHERE p.aktif = true ORDER BY p.harga DESC")
List<Produk> findSemuaUrutharga();
@Query("SELECT p FROM Produk p WHERE p.aktif = true AND p.harga > :minHarga AND p.kategori = :kategori")
List<Produk> findMahalDiKategori(@Param("minHarga") BigDecimal minHarga,
@Param("kategori") String kategori);
// Query native (SQL langsung)
@Query(value = """
SELECT kategori, COUNT(*) as jumlah, SUM(stok) as total_stok
FROM produk
WHERE aktif = true
GROUP BY kategori
ORDER BY jumlah DESC
""", nativeQuery = true)
List<Object[]> statistikPerKategori();
// Update langsung tanpa load entity terlebih dahulu
@Modifying
@Query("UPDATE Produk p SET p.aktif = false WHERE p.id = :id")
int softDelete(@Param("id") Long id);
// Hitung
long countByKategoriAndAktifTrue(String kategori);
boolean existsByNamaAndAktifTrue(String nama);
}
Service Layer #
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@Transactional(readOnly = true) // default: semua metode read-only
public class ProdukService {
private final ProdukRepository repository;
public ProdukService(ProdukRepository repository) {
this.repository = repository;
}
public List<Produk> getSemuaProduk() {
return repository.findByAktifTrue();
}
public Produk getProdukById(Long id) {
return repository.findByIdAndAktifTrue(id)
.orElseThrow(() -> new RuntimeException("Produk tidak ditemukan: " + id));
}
public List<Produk> cariProduk(String kata) {
return repository.findByNamaContainingIgnoreCaseAndAktifTrue(kata);
}
@Transactional // metode ini perlu write access
public Produk buatProduk(String nama, BigDecimal harga, int stok, String kategori) {
if (repository.existsByNamaAndAktifTrue(nama)) {
throw new IllegalArgumentException("Produk dengan nama ini sudah ada: " + nama);
}
Produk baru = new Produk(nama, harga, stok, kategori);
return repository.save(baru);
}
@Transactional
public Produk updateHarga(Long id, BigDecimal hargaBaru) {
Produk produk = getProdukById(id);
produk.setHarga(hargaBaru);
return repository.save(produk); // JPA otomatis UPDATE saat commit
}
@Transactional
public void hapusProduk(Long id) {
int affected = repository.softDelete(id);
if (affected == 0) throw new RuntimeException("Produk tidak ditemukan: " + id);
}
}
Keamanan SQL — Mencegah SQL Injection #
SQL injection adalah celah keamanan paling berbahaya dalam aplikasi database. Selalu gunakan PreparedStatement atau ORM — jangan pernah concatenate input pengguna ke dalam string SQL.
// ANTI-PATTERN: SQL injection! Input: nama = "'; DROP TABLE produk; --"
String input = request.getParameter("nama");
String sql = "SELECT * FROM produk WHERE nama = '" + input + "'";
// Query yang dieksekusi: SELECT * FROM produk WHERE nama = ''; DROP TABLE produk; --'
// BENAR: PreparedStatement — input diescaping secara otomatis
String sql = "SELECT * FROM produk WHERE nama = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, input); // driver menangani escaping
// ...
}
// BENAR: Spring Data JPA / JPQL — parameter binding otomatis
@Query("SELECT p FROM Produk p WHERE p.nama = :nama")
List<Produk> findByNama(@Param("nama") String nama);
// HATI-HATI: JPQL dengan LIKE masih perlu sanitasi manual untuk wildcard
@Query("SELECT p FROM Produk p WHERE p.nama LIKE :pattern")
List<Produk> cari(@Param("pattern") String pattern);
// Di service:
public List<Produk> cariProduk(String kata) {
// Escape karakter wildcard SQL agar tidak disalahgunakan
String pattern = "%" + kata.replace("%", "\\%").replace("_", "\\_") + "%";
return repository.cari(pattern);
}
Kapan Menggunakan JDBC vs JPA #
Gunakan JDBC MURNI jika:
✓ Perlu performa maksimal (bulk insert, query sangat spesifik)
✓ Query terlalu kompleks untuk diekspresikan dengan JPQL
✓ Stored procedure dengan output parameter
✓ Belajar cara kerja database di level rendah
Gunakan SPRING DATA JPA jika:
✓ CRUD standar — Repository dengan method name convention sangat produktif
✓ Tim tidak perlu menulis SQL untuk operasi umum
✓ Butuh fitur JPA (lazy loading, caching, lifecycle events)
✓ Aplikasi Spring Boot baru — ini pilihan default yang tepat
Praktik terbaik:
✓ Selalu gunakan HikariCP atau connection pool lainnya
✓ Selalu gunakan PreparedStatement — tidak pernah concatenate input ke SQL
✓ Gunakan @Transactional(readOnly = true) untuk query agar lebih efisien
✓ Gunakan soft delete (UPDATE aktif = false) daripada hard delete
✓ Gunakan BigDecimal untuk nilai uang, bukan double (presisi floating point!)
✓ Tutup Connection, Statement, ResultSet di blok finally atau try-with-resources
Ringkasan #
- Selalu gunakan
PreparedStatement— tidak pernah concatenate input pengguna ke string SQL.PreparedStatementmenghindari SQL injection dan lebih efisien karena query bisa di-cache oleh database.- HikariCP adalah connection pool wajib — membuat koneksi baru setiap request sangat lambat. HikariCP mengelola pool koneksi yang siap pakai. Spring Boot menyertakannya secara default.
- try-with-resources untuk
Connection,Statement,ResultSet— ketiganya harus selalu ditutup.try-with-resourcesmemastikan penutupan bahkan saat exception terjadi.- Gunakan
BigDecimaluntuk nilai uang —doubledanfloatmemiliki ketidakakuratan floating point yang tidak dapat diterima untuk nilai finansial. GunakanDECIMAL(15,2)di MySQL danBigDecimaldi Java.@Transactional(readOnly = true)untuk query di Spring — memberikan hint ke JPA untuk tidak melacak perubahan entity (dirty checking), yang menghemat memori dan waktu.- Soft delete lebih aman dari hard delete —
UPDATE aktif = FALSEalih-alihDELETE. Data masih bisa dipulihkan dan relasi data tidak rusak.- Spring Data JPA menghasilkan query dari nama metode —
findByKategoriAndAktifTrue(String kategori)cukup sebagai deklarasi interface; Spring mengimplementasikannya otomatis.@Queryuntuk query kompleks — gunakan JPQL untuk query yang tidak bisa diekspresikan dengan nama metode. GunakannativeQuery = truehanya jika JPQL tidak memadai.- Batch processing untuk data massal —
addBatch()+executeBatch()mengirim ratusan insert dalam satu round-trip ke database, jauh lebih cepat dari eksekusi satu per satu.