MySQL

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")]
LevelAbstraksiProduktivitasKontrolCocok untuk
JDBC murniRendahRendahPenuhBelajar, kasus khusus, performa kritis
Spring JDBCSedangSedangTinggiQuery kompleks, stored procedure
Spring Data JPATinggiTinggiTerbatasCRUD 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"| C1

Konfigurasi 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. PreparedStatement menghindari 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-resources memastikan penutupan bahkan saat exception terjadi.
  • Gunakan BigDecimal untuk nilai uangdouble dan float memiliki ketidakakuratan floating point yang tidak dapat diterima untuk nilai finansial. Gunakan DECIMAL(15,2) di MySQL dan BigDecimal di 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 deleteUPDATE aktif = FALSE alih-alih DELETE. Data masih bisa dipulihkan dan relasi data tidak rusak.
  • Spring Data JPA menghasilkan query dari nama metodefindByKategoriAndAktifTrue(String kategori) cukup sebagai deklarasi interface; Spring mengimplementasikannya otomatis.
  • @Query untuk query kompleks — gunakan JPQL untuk query yang tidak bisa diekspresikan dengan nama metode. Gunakan nativeQuery = true hanya jika JPQL tidak memadai.
  • Batch processing untuk data massaladdBatch() + executeBatch() mengirim ratusan insert dalam satu round-trip ke database, jauh lebih cepat dari eksekusi satu per satu.

← Sebelumnya: YAML   Berikutnya: MSSQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact