Kenapa ini penting
Bayangkan warung kopi yang mencatat pesanan di papan tulis. Selama warung buka, catatannya terbaca. Begitu ada yang menghapus papan (server restart), semua catatan lenyap. Itulah yang terjadi pada array const todos = [] di bagian 1. Aplikasi sungguhan butuh tempat menyimpan data yang tetap ada walaupun server mati, bisa dicari dengan cepat walaupun isinya jutaan baris, dan tetap rapi walaupun ratusan orang menulis di saat yang sama.
Tempat itu namanya database, dan program yang mengelolanya disebut DBMS (Database Management System), misalnya PostgreSQL, MySQL, SQLite, atau MongoDB. Hampir semua pekerjaan backend pada akhirnya berputar di sekitar database. Login adalah mencocokkan data di tabel user, checkout adalah menulis ke tabel pesanan sambil mengurangi stok, dan laporan penjualan adalah menjumlahkan isi tabel. Kalau kamu paham database, setengah pekerjaan backend sudah kamu kuasai.
Konsep inti
SQL vs NoSQL
Ada dua keluarga besar database:
| SQL (relasional) | NoSQL (dokumen, key-value, dll.) | |
|---|---|---|
| Bentuk data | Tabel dengan kolom tetap, seperti spreadsheet yang ketat | Dokumen JSON bebas bentuk, atau pasangan kunci-nilai |
| Contoh | PostgreSQL, MySQL, SQLite, SQL Server | MongoDB, Redis, DynamoDB, Firestore |
| Relasi antar data | Kuat, lewat foreign key dan JOIN | Biasanya data ditanam (embed) atau di-join manual di kode |
| Skema | Wajib didefinisikan dulu | Fleksibel, bisa berubah per dokumen |
| Cocok untuk | Toko online, keuangan, sistem dengan data saling terhubung | Cache, data log besar, data yang bentuknya sangat bervariasi |
Untuk pemula dan untuk sebagian besar aplikasi bisnis, mulailah dengan SQL. Data toko online (user, produk, pesanan, item pesanan) sangat saling terhubung, dan database relasional memang dirancang untuk itu. Database relasional juga memberi jaminan ACID: transaksi terjadi utuh atau tidak sama sekali (Atomic), data selalu memenuhi aturan (Consistent), transaksi yang berjalan bersamaan tidak saling merusak (Isolated), dan yang sudah tersimpan tidak hilang walau listrik padam (Durable). NoSQL bukan "lebih modern", hanya alat untuk masalah yang berbeda. Redis, misalnya, sering dipakai di samping PostgreSQL sebagai cache.
Tabel, baris, kolom, dan primary key
Database relasional menyimpan data dalam tabel. Satu tabel untuk satu jenis "benda": pelanggan, produk, pesanan. Setiap baris (row) adalah satu benda, setiap kolom (column) adalah satu atribut dengan tipe tertentu (INTEGER, TEXT, dan sebagainya).
Setiap baris butuh tanda pengenal unik yang tidak pernah berubah, namanya primary key. Biasanya berupa kolom id berisi angka yang naik otomatis (auto increment). Analoginya seperti nomor KTP: nama bisa sama, alamat bisa pindah, tapi nomornya tetap.
Relasi dan foreign key
Pesanan selalu milik seorang pelanggan. Daripada menyalin nama dan kota pelanggan ke setiap pesanan, kita cukup menyimpan id pelanggannya di kolom pelanggan_id. Kolom yang menunjuk ke primary key tabel lain disebut foreign key. Database lalu bisa menjaga aturan "tidak boleh ada pesanan untuk pelanggan yang tidak ada".
Ada tiga pola relasi:
- One-to-many (satu ke banyak): satu pelanggan punya banyak pesanan. Foreign key ada di sisi "banyak" (
pesanan.pelanggan_id). - Many-to-many (banyak ke banyak): satu pesanan berisi banyak produk, dan satu produk muncul di banyak pesanan. Butuh tabel penghubung, misalnya
order_itemsyang berisiorder_iddanproduct_id. - One-to-one (satu ke satu): satu user punya satu profil. Jarang dipakai, sering cukup digabung ke satu tabel.
Memecah data ke tabel-tabel yang saling menunjuk seperti ini disebut normalisasi. Tujuannya supaya setiap fakta disimpan di satu tempat saja. Kalau pelanggan pindah kota, kamu cukup mengubah satu baris, bukan ratusan pesanan.
Empat perintah dasar SQL (CRUD)
SQL (Structured Query Language) adalah bahasa untuk "bertanya" ke database relasional. Empat perintah ini mewakili operasi CRUD (Create, Read, Update, Delete):
| Operasi | SQL | Contoh |
|---|---|---|
| Create | INSERT |
INSERT INTO pelanggan (nama, kota) VALUES ('Andi', 'Bandung') |
| Read | SELECT |
SELECT nama FROM pelanggan WHERE kota = 'Bandung' ORDER BY nama |
| Update | UPDATE |
UPDATE pesanan SET total = 200000 WHERE id = 2 |
| Delete | DELETE |
DELETE FROM pesanan WHERE id = 1 |
Perhatikan WHERE di UPDATE dan DELETE. Tanpa WHERE, perintah itu berlaku untuk semua baris di tabel. Ini salah satu kesalahan paling mahal di dunia backend.
JOIN: menggabungkan tabel
Karena data dipecah ke beberapa tabel, kita butuh cara menyatukannya lagi saat dibaca. Itulah JOIN:
JOIN(atauINNER JOIN) hanya mengambil baris yang punya pasangan di kedua tabel. Pelanggan yang belum pernah belanja tidak ikut.LEFT JOINmengambil semua baris dari tabel kiri, dan mengisiNULLkalau tidak ada pasangannya di tabel kanan. Cocok untuk "tampilkan semua pelanggan beserta jumlah belanjanya, termasuk yang nol".
Ditambah GROUP BY dan fungsi agregat seperti COUNT() dan SUM(), kamu bisa membuat laporan seperti "total belanja per pelanggan" dalam satu query.
SQLite untuk belajar, PostgreSQL untuk produksi
SQLite adalah database yang hidup di dalam satu file (atau bahkan di RAM). Tidak ada server terpisah yang harus dipasang dan dijalankan. Ini membuatnya sempurna untuk belajar, untuk tes otomatis, dan bahkan untuk aplikasi produksi skala kecil-menengah dengan satu server. Kabar baiknya, Node.js sekarang punya modul node:sqlite bawaan. Di dokumentasi Node 24 dan 26, statusnya Stability 1.2 (Release candidate) sejak v24.15.0. Artinya API-nya sudah hampir final dan tidak lagi butuh flag --experimental-sqlite, tapi belum berlabel "Stable" penuh. Saat dicoba di Node 24.22.1 dan 26.0.0, modul ini berjalan tanpa peringatan apa pun.
PostgreSQL (sering disingkat Postgres) adalah server database penuh. Ia berjalan sebagai proses terpisah, menerima banyak koneksi sekaligus, punya tipe data kaya (JSONB, array, timestamp dengan zona waktu), dan bisa melayani banyak server aplikasi sekaligus. Hampir semua platform hosting (Railway, Render, Fly.io, Supabase, Neon) menyediakan Postgres siap pakai. Aturan praktisnya:
| Situasi | Pilihan |
|---|---|
| Belajar, prototipe, tes otomatis | SQLite |
| Satu server, trafik sedang, mau simpel | SQLite (dengan mode WAL dan backup rutin) |
| Banyak server aplikasi, banyak penulis bersamaan, butuh fitur lanjutan | PostgreSQL |
Karena SQL-nya hampir sama, pindah dari SQLite ke Postgres nanti tidak terlalu menyakitkan, apalagi kalau kamu memakai ORM (Modul 2).
Contoh kode
Contoh ini memakai node:sqlite bawaan, jadi tidak perlu npm install apa pun. Simpan sebagai sql-dasar.js di folder yang package.json-nya berisi "type": "module", lalu jalankan node sql-dasar.js.
// sql-dasar.js, latihan SQL pakai SQLite bawaan Node (tanpa install apa pun)
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:'); // database di RAM, hilang saat program selesai
db.exec('PRAGMA foreign_keys = ON'); // SQLite baru menegakkan foreign key kalau ini dinyalakan
db.exec(`
CREATE TABLE pelanggan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
kota TEXT NOT NULL
);
CREATE TABLE pesanan (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pelanggan_id INTEGER NOT NULL REFERENCES pelanggan(id),
produk TEXT NOT NULL,
total INTEGER NOT NULL
);
`);
// INSERT, pakai placeholder ? (di Modul 2 kita bahas kenapa ini WAJIB)
const tambahPelanggan = db.prepare('INSERT INTO pelanggan (nama, kota) VALUES (?, ?)');
tambahPelanggan.run('Andi', 'Bandung');
tambahPelanggan.run('Sari', 'Surabaya');
tambahPelanggan.run('Joko', 'Bandung');
const tambahPesanan = db.prepare('INSERT INTO pesanan (pelanggan_id, produk, total) VALUES (?, ?, ?)');
tambahPesanan.run(1, 'Kopi Gayo 250g', 85000);
tambahPesanan.run(1, 'French Press', 210000);
tambahPesanan.run(2, 'Kopi Toraja 250g', 95000);
// SELECT dengan WHERE dan ORDER BY
console.log(db.prepare('SELECT nama FROM pelanggan WHERE kota = ? ORDER BY nama').all('Bandung'));
// → [ [Object: null prototype] { nama: 'Andi' }, [Object: null prototype] { nama: 'Joko' } ]
// UPDATE, hasil .run() memberi tahu berapa baris yang berubah
console.log(db.prepare('UPDATE pesanan SET total = total - 10000 WHERE id = ?').run(2));
// → { changes: 1, lastInsertRowid: 3 }
// DELETE
console.log(db.prepare('DELETE FROM pesanan WHERE total < ?').run(90000).changes);
// → 1 (Kopi Gayo 85000 terhapus)
// JOIN, gabungkan dua tabel lewat kolom relasi
console.log(db.prepare(`
SELECT p.nama, o.produk, o.total
FROM pesanan o
JOIN pelanggan p ON p.id = o.pelanggan_id
ORDER BY o.total DESC
`).all());
// → [ { nama: 'Andi', produk: 'French Press', total: 200000 },
// { nama: 'Sari', produk: 'Kopi Toraja 250g', total: 95000 } ]
// LEFT JOIN + GROUP BY, semua pelanggan, termasuk yang belum pernah belanja
console.log(db.prepare(`
SELECT p.nama, COUNT(o.id) AS jumlah, COALESCE(SUM(o.total), 0) AS belanja
FROM pelanggan p
LEFT JOIN pesanan o ON o.pelanggan_id = p.id
GROUP BY p.id
ORDER BY belanja DESC
`).all());
// → [ { nama: 'Andi', jumlah: 1, belanja: 200000 },
// { nama: 'Sari', jumlah: 1, belanja: 95000 },
// { nama: 'Joko', jumlah: 0, belanja: 0 } ]
// foreign key menolak pesanan untuk pelanggan yang tidak ada
try {
tambahPesanan.run(99, 'Hantu', 1000);
} catch (e) {
console.log(e.message); // → FOREIGN KEY constraint failed
}
db.close();
Beberapa hal yang patut diperhatikan dari output asli di atas. [Object: null prototype] muncul karena node:sqlite mengembalikan objek tanpa prototype. Isinya tetap bisa dibaca biasa (baris.nama). Lalu UPDATE mengembalikan changes: 1, angka yang nanti sering kamu pakai untuk mendeteksi "data tidak ditemukan". Terakhir, COALESCE(SUM(o.total), 0) mengganti NULL (Joko belum belanja) menjadi 0.
Untuk Postgres, SQL-nya hampir sama. Perbedaan yang paling sering ditemui: auto increment ditulis id SERIAL PRIMARY KEY atau id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, dan placeholder di driver pg ditulis $1, $2 alih-alih ?.
// contoh-pg.js, query yang sama ke PostgreSQL dengan driver pg 8.23.1
import pg from 'pg';
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
// DATABASE_URL contoh: postgres://user:password@localhost:5432/toko
const { rows } = await pool.query('SELECT nama FROM pelanggan WHERE kota = $1', ['Bandung']);
console.log(rows); // → [ { nama: 'Andi' }, { nama: 'Joko' } ]
await pool.end();
Pool menyimpan beberapa koneksi yang siap dipakai ulang. Membuka koneksi baru ke Postgres untuk setiap request itu lambat, jadi server selalu memakai pool.
Kesalahan umum
UPDATEatauDELETEtanpaWHERE. Satu baris SQL bisa mengubah seluruh tabel. Biasakan menulisWHERElebih dulu, dan coba query-nya sebagaiSELECTdenganWHEREyang sama untuk melihat baris mana yang akan kena.- Lupa
PRAGMA foreign_keys = ONdi SQLite. Secara bawaan SQLite tidak menegakkan foreign key demi kompatibilitas lama. Tanpa pragma ini, pesanan untuk pelanggan id 99 akan diterima begitu saja. - Menyimpan uang sebagai angka desimal (float).
0.1 + 0.2di JavaScript menghasilkan0.30000000000000004. Simpan rupiah sebagaiINTEGER(dalam rupiah atau sen), atau pakai tipeNUMERICdi Postgres. - Menyalin data ke banyak tabel. Menyimpan
nama_pelanggandi tabel pesanan membuat data tidak sinkron saat nama berubah. Simpan id-nya, lalu JOIN saat membaca. Pengecualian yang disengaja: harga saat dibeli memang harus disalin ke item pesanan, karena harga produk bisa berubah besok. - Memilih NoSQL hanya karena "tidak perlu skema". Skema tetap ada, hanya pindah ke kode aplikasimu dan tidak dijaga database. Untuk data yang saling terhubung, database relasional jauh lebih aman.
Latihan
- Mudah, Dari contoh di atas, tulis query untuk menampilkan nama dan kota semua pelanggan yang bukan dari Bandung, urut berdasarkan nama.
- Sedang, Tambahkan tabel
produk (id, nama, harga), lalu ubah tabelpesanansupaya menyimpanproduk_id(foreign key), bukan nama produk. Tulis JOIN tiga tabel yang menampilkannama pelanggan, nama produk, harga. - Menantang, Buat query "pelanggan terbaik": nama pelanggan dan total belanjanya, hanya untuk pelanggan dengan total belanja di atas 100.000, urut dari yang terbesar. Petunjuk: filter hasil agregat pakai
HAVING, bukanWHERE.
Lihat solusi
1. <> atau != berarti "tidak sama dengan".
console.log(db.prepare('SELECT nama, kota FROM pelanggan WHERE kota <> ? ORDER BY nama').all('Bandung'));
// → [ { nama: 'Sari', kota: 'Surabaya' } ]
2. Tabel produk dibuat lebih dulu karena pesanan menunjuk ke sana.
db.exec(`
CREATE TABLE produk (id INTEGER PRIMARY KEY AUTOINCREMENT, nama TEXT NOT NULL, harga INTEGER NOT NULL);
CREATE TABLE pesanan2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pelanggan_id INTEGER NOT NULL REFERENCES pelanggan(id),
produk_id INTEGER NOT NULL REFERENCES produk(id)
);
INSERT INTO produk (nama, harga) VALUES ('Kopi Gayo', 85000), ('French Press', 210000);
INSERT INTO pesanan2 (pelanggan_id, produk_id) VALUES (1, 1), (1, 2), (2, 1);
`);
console.log(db.prepare(`
SELECT pl.nama AS pelanggan, pr.nama AS produk, pr.harga
FROM pesanan2 o
JOIN pelanggan pl ON pl.id = o.pelanggan_id
JOIN produk pr ON pr.id = o.produk_id
`).all());
// → Andi/Kopi Gayo/85000, Andi/French Press/210000, Sari/Kopi Gayo/85000
3. WHERE menyaring baris sebelum dikelompokkan, HAVING menyaring setelah agregat dihitung.
console.log(db.prepare(`
SELECT p.nama, SUM(o.total) AS belanja
FROM pelanggan p
JOIN pesanan o ON o.pelanggan_id = p.id
GROUP BY p.id
HAVING SUM(o.total) > ?
ORDER BY belanja DESC
`).all(100000));
// → [ { nama: 'Andi', belanja: 200000 } ]
Ringkasan
- Database menyimpan data secara permanen. Database relasional (SQL) menyimpannya dalam tabel berisi baris dan kolom, dan menjamin transaksi ACID.
- Primary key mengenali tiap baris secara unik, foreign key menghubungkan tabel. Relasi many-to-many butuh tabel penghubung.
- Empat perintah CRUD:
INSERT,SELECT,UPDATE,DELETE.UPDATE/DELETEtanpaWHEREberlaku ke semua baris. JOINmenggabungkan tabel,LEFT JOINmempertahankan baris yang tidak punya pasangan,GROUP BY+SUM/COUNTuntuk laporan.- SQLite (termasuk
node:sqlitebawaan, status Release candidate) cocok untuk belajar dan aplikasi satu server. PostgreSQL untuk produksi dengan banyak server atau banyak penulis.