"NoSQL bukan pengganti SQL. NoSQL adalah jawaban untuk pertanyaan yang berbeda: bukan 'bagaimana menyimpan data ini dengan benar', tapi 'bagaimana bentuk data ini sebenarnya'."
Tentang Artikel Ini
Kalau kamu sudah baca artikel #03 (Desain Database), kamu tahu normalisasi, foreign key, dan JOIN adalah cara PostgreSQL menjaga data tetap konsisten. MongoDB membuang sebagian besar aturan itu — dan itu bukan kekurangan, itu keputusan desain untuk bentuk masalah yang berbeda.
Artikel ini tidak akan bilang "MongoDB lebih cepat" atau "SQL sudah ketinggalan zaman" — keduanya klaim yang menyesatkan tergantung konteks. Sebaliknya, kita akan bangun pemahaman yang jujur: kapan bentuk data kamu cocok dengan model document, kapan tidak, dan bagaimana membuat keputusan itu berdasarkan bukti, bukan tren.
Setelah selesai, kamu akan bisa:
- Memahami perbedaan fundamental antara model relasional dan document-oriented
- Mendesain schema MongoDB — kapan embed, kapan reference
- Melakukan CRUD dan query dengan Mongoose di Node.js
- Menulis aggregation pipeline untuk kebutuhan reporting
- Membuat index yang efektif di MongoDB
- Mengambil keputusan MongoDB vs PostgreSQL berdasarkan bentuk data, bukan hype
Prasyarat: Sudah baca artikel #03 (Desain Database) — artikel ini banyak membandingkan langsung dengan konsep normalisasi dan JOIN yang dibahas di sana. Familiar dengan JavaScript async/await.
Daftar Isi
- Kenapa NoSQL? Paradigma yang Berbeda, Bukan yang Lebih Baik
- Konsep Dasar MongoDB
- Schema Design: Embedding vs Referencing
- CRUD dengan Mongoose
- Query dan Aggregation Pipeline
- Indexing di MongoDB
- Studi Kasus: Katalog Produk dengan Atribut Bervariasi
- Kapan MongoDB, Kapan PostgreSQL
Bab 1: Kenapa NoSQL? Paradigma yang Berbeda, Bukan yang Lebih Baik
Masalah yang Coba Dipecahkan
Model relasional (dibahas di artikel #03) unggul saat data punya struktur konsisten dan hubungan yang jelas antar entity — order punya customer, customer punya banyak order. Tapi ada kelas masalah lain: data yang bentuknya berbeda-beda antar record, berubah-ubah seiring waktu, atau secara alami berbentuk pohon/dokumen daripada tabel.
Bayangkan sistem katalog produk marketplace: laptop punya ram, cpu, storage; baju punya size, material, warna; buku punya penulis, penerbit, jumlah_halaman. Di artikel #03 kita sudah bahas ini bisa diselesaikan dengan JSONB di PostgreSQL — dan itu solusi valid. MongoDB mengambil pendekatan yang lebih jauh: seluruh database dirancang di sekitar dokumen fleksibel ini, bukan hanya satu kolom.
Empat Perbedaan Fundamental
| Aspek | Relasional (PostgreSQL) | Document (MongoDB) |
|---|---|---|
| Unit penyimpanan | Baris di tabel dengan schema tetap | Dokumen JSON/BSON dengan schema fleksibel |
| Relasi | JOIN antar tabel | Embed (nested) atau reference manual |
| Konsistensi | ACID penuh, multi-tabel | ACID per-dokumen kuat; multi-dokumen butuh transaction eksplisit |
| Scaling | Vertikal (server lebih besar), horizontal butuh effort (sharding, read replica) | Horizontal by design (sharding native) |
Bukan "NoSQL Menggantikan SQL"
Klaim generasi pertama NoSQL (2010-an awal) bahwa NoSQL akan menggantikan database relasional sudah terbukti salah — mayoritas sistem production serius (termasuk yang pakai MongoDB) tetap punya PostgreSQL/MySQL untuk data transaksional inti (user, order, payment), dan MongoDB untuk bagian spesifik yang memang cocok bentuknya: katalog produk, log aktivitas, konten CMS, data analytics event.
Aturan praktis yang dipakai kebanyakan tim production: default ke PostgreSQL untuk data bisnis inti kamu (transaksi, keuangan, apa pun yang butuh integritas relasional ketat). Pertimbangkan MongoDB hanya ketika bentuk data spesifik — bukan seluruh sistem — memang document-shaped.
Bab 2: Konsep Dasar MongoDB
Terminologi: Padanan dari Dunia SQL
| PostgreSQL | MongoDB |
|---|---|
| Database | Database |
| Table | Collection |
| Row | Document |
| Column | Field |
Primary Key (id) | _id (otomatis, tipe ObjectId) |
| JOIN | $lookup (aggregation) atau embedding |
| Schema (fixed) | Schema fleksibel (opsional, biasanya di-enforce di level aplikasi) |
Instalasi & Koneksi
# macOS
brew tap mongodb/brew
brew install mongodb-community
# Jalankan sebagai service
brew services start mongodb-community
# Atau pakai MongoDB Atlas (cloud, free tier) — tidak perlu install apapunnpm install mongoose// src/db/mongo.js
import mongoose from 'mongoose';
export async function connectMongo() {
await mongoose.connect(process.env.MONGODB_URI);
console.log('MongoDB connected');
}
mongoose.connection.on('error', (err) => {
console.error('MongoDB connection error:', err);
});Bentuk Dokumen
// Satu dokumen di collection "products"
{
_id: ObjectId("65f1a2b3c4d5e6f7a8b9c0d1"),
name: "Laptop Gaming X1",
price: 15000000,
category: "electronics",
specs: {
ram: "16GB",
cpu: "Intel i7",
storage: "512GB SSD",
},
tags: ["gaming", "laptop", "intel"],
createdAt: ISODate("2024-01-15T10:00:00Z"),
}Perhatikan: specs adalah object bersarang (nested), tags adalah array — keduanya representasi native, bukan trik seperti JSONB atau tabel junction.
Bab 3: Schema Design: Embedding vs Referencing
Ini keputusan desain paling penting di MongoDB — setara pentingnya dengan normalisasi di dunia SQL, tapi arahnya sering berlawanan.
Embedding: Simpan Bersarang di Dokumen yang Sama
// Order dengan item embedded
{
_id: ObjectId("..."),
customerName: "Budi Santoso",
items: [
{ productName: "Kopi Arabika", quantity: 2, unitPrice: 85000 },
{ productName: "Teh Hijau", quantity: 1, unitPrice: 35000 },
],
total: 205000,
createdAt: ISODate("2024-01-15"),
}Kapan embed:
- Data anak selalu diakses bersama data induk (order selalu ditampilkan dengan item-nya)
- Relasi "one-to-few" — jumlah item terbatas dan tidak akan tumbuh tak terbatas
- Data anak tidak perlu di-query independen dari induknya
Referencing: Simpan ID, Query Terpisah
// Product sebagai collection terpisah, order hanya simpan reference
// orders collection
{
_id: ObjectId("..."),
customerId: ObjectId("65f1..."), // reference ke customers
items: [
{ productId: ObjectId("65f2..."), quantity: 2 },
],
}Kapan reference:
- Relasi "one-to-many" tak terbatas (satu customer bisa punya ribuan order — jangan embed semua order ke dalam dokumen customer)
- Data anak perlu di-query dan diupdate independen (update harga produk tanpa menyentuh semua order lama)
- Data anak dipakai bersama oleh banyak dokumen induk (many-to-many)
Perbandingan Langsung dengan Normalisasi SQL
Di artikel #03, kita normalisasi order_items sebagai tabel terpisah supaya tidak ada data redundant dan integritas terjaga lewat foreign key. Di MongoDB, pola yang setara untuk item transaksi justru sering di-embed, bukan direferensi — karena performanya lebih baik untuk kasus "selalu dibaca bersama induknya" dan MongoDB tidak punya JOIN semurah PostgreSQL.
SQL: normalisasi = pisahkan, JOIN saat butuh
MongoDB: denormalisasi terkontrol = gabungkan yang selalu dipakai bersama,
reference hanya yang benar-benar butuh query independen
Jebakan paling umum untuk developer yang baru pindah dari SQL: mencoba mereplikasi skema ternormalisasi penuh di MongoDB — semua di-reference, tidak ada yang di-embed. Hasilnya adalah aplikasi yang butuh berkali-kali round-trip query untuk menampilkan satu halaman, kehilangan justru keunggulan utama MongoDB.
Snapshot Pattern: Solusi untuk Item Transaksi
Sama seperti di artikel #03, item transaksi butuh snapshot harga saat transaksi terjadi, bukan reference ke harga produk saat ini:
{
items: [
{
productId: ObjectId("..."), // reference untuk lacak balik ke produk asli
productName: "Kopi Arabika", // snapshot — nama saat transaksi
unitPrice: 85000, // snapshot — harga saat transaksi
quantity: 2,
},
],
}Pola ini identik dengan yang kita pakai di tabel transaction_items PostgreSQL — prinsipnya sama, cuma representasinya berbeda.
Bab 4: CRUD dengan Mongoose
Kenapa Mongoose, Bukan Native Driver
MongoDB native driver Node.js bisa dipakai langsung, tapi tidak punya schema enforcement — setiap dokumen bisa berbentuk apapun, termasuk yang salah karena typo. Mongoose menambahkan schema, validasi, dan model di atasnya — mirip peran Zod di artikel #02, tapi terintegrasi langsung ke layer data.
Mendefinisikan Schema
// src/models/product.model.js
import mongoose from 'mongoose';
const productSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Nama produk wajib diisi'],
trim: true,
minlength: 3,
},
price: {
type: Number,
required: true,
min: [0, 'Harga tidak boleh negatif'],
},
category: {
type: String,
required: true,
enum: ['electronics', 'fashion', 'food', 'books'], // sama seperti ENUM di SQL
},
specs: {
type: mongoose.Schema.Types.Mixed, // fleksibel — beda field per kategori
default: {},
},
tags: [String],
isActive: {
type: Boolean,
default: true,
},
},
{
timestamps: true, // otomatis tambahkan createdAt & updatedAt
}
);
export const Product = mongoose.model('Product', productSchema);Create
const product = await Product.create({
name: 'Laptop Gaming X1',
price: 15000000,
category: 'electronics',
specs: { ram: '16GB', cpu: 'Intel i7', storage: '512GB SSD' },
tags: ['gaming', 'laptop'],
});Read
// Cari satu
const product = await Product.findById('65f1a2b3c4d5e6f7a8b9c0d1');
const product2 = await Product.findOne({ name: 'Laptop Gaming X1' });
// Cari banyak dengan filter, mirip WHERE
const laptops = await Product.find({
category: 'electronics',
price: { $gte: 5000000, $lte: 20000000 }, // setara WHERE price BETWEEN
isActive: true,
});
// Pagination
const page = 2, limit = 10;
const products = await Product.find({ isActive: true })
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit);Update
// Update satu field
await Product.findByIdAndUpdate(id, { price: 14500000 });
// Update dengan operator — increment stok tanpa race condition
await Product.findByIdAndUpdate(id, { $inc: { stock: -1 } });
// Update banyak dokumen sekaligus
await Product.updateMany(
{ category: 'electronics' },
{ $set: { taxRate: 0.11 } }
);Delete
// Hard delete
await Product.findByIdAndDelete(id);
// Soft delete — pola yang sama seperti di artikel #03
await Product.findByIdAndUpdate(id, { isActive: false });Validasi Otomatis dari Schema
try {
await Product.create({ name: 'AB', price: -100, category: 'invalid' });
} catch (err) {
// err.errors berisi detail per-field, mirip result.error dari Zod safeParse
console.log(err.errors.name.message); // "Nama produk wajib diisi" (kurang dari minlength)
console.log(err.errors.price.message); // "Harga tidak boleh negatif"
console.log(err.errors.category.message); // bukan salah satu ENUM
}Bab 5: Query dan Aggregation Pipeline
Query Sederhana vs Aggregation
Untuk filter dan sort sederhana, find() sudah cukup. Untuk kebutuhan seperti JOIN, GROUP BY, atau perhitungan bertingkat — pakai aggregation pipeline, rangkaian tahap (stage) yang memproses dokumen secara berurutan.
$match, $group: Setara WHERE + GROUP BY
// Setara: SELECT category, COUNT(*), SUM(price) FROM products
// WHERE isActive = true GROUP BY category
const summary = await Product.aggregate([
{ $match: { isActive: true } },
{
$group: {
_id: '$category',
totalProducts: { $sum: 1 },
totalValue: { $sum: '$price' },
avgPrice: { $avg: '$price' },
},
},
{ $sort: { totalValue: -1 } },
]);
// Hasil:
// [
// { _id: 'electronics', totalProducts: 45, totalValue: 675000000, avgPrice: 15000000 },
// { _id: 'fashion', totalProducts: 120, totalValue: 36000000, avgPrice: 300000 },
// ]$lookup: Setara JOIN
// orders collection punya customerId (reference) — "join" ke customers
const ordersWithCustomer = await Order.aggregate([
{
$lookup: {
from: 'customers', // collection yang di-join
localField: 'customerId',
foreignField: '_id',
as: 'customer', // hasil disimpan sebagai array di field ini
},
},
{ $unwind: '$customer' }, // ubah array 1-elemen jadi object langsung
{
$project: { // pilih field yang ditampilkan, setara SELECT kolom tertentu
total: 1,
createdAt: 1,
'customer.name': 1,
'customer.email': 1,
},
},
]);
$lookupbukan JOIN yang seefisien PostgreSQL. Tidak ada query planner sekuat PostgreSQL untuk optimasi otomatis, dan$lookupantar collection besar bisa mahal. Kalau aplikasimu butuh JOIN kompleks berkali-kali di jalur kritis, itu sinyal kuat data-mu lebih cocok di PostgreSQL — bukan alasan untuk memaksakan$lookup.
Pipeline Bertingkat: Laporan Penjualan Bulanan
const monthlySales = await Order.aggregate([
{ $match: { status: 'completed' } },
{
$group: {
_id: {
year: { $year: '$createdAt' },
month: { $month: '$createdAt' },
},
totalRevenue: { $sum: '$total' },
orderCount: { $sum: 1 },
},
},
{ $sort: { '_id.year': -1, '_id.month': -1 } },
{ $limit: 12 },
]);Bab 6: Indexing di MongoDB
Prinsip yang Sama, Sintaks Berbeda
Konsep index di MongoDB paralel dengan PostgreSQL (dibahas di artikel #03 Bab 5) — mempercepat query dengan mengorbankan sedikit kecepatan write. Tanpa index, MongoDB melakukan collection scan (setara sequential scan).
// Index sederhana — setara CREATE INDEX di SQL
await Product.collection.createIndex({ category: 1 }); // 1 = ascending
await Product.collection.createIndex({ createdAt: -1 }); // -1 = descending
// Compound index — urutan field penting, sama seperti composite index SQL
await Product.collection.createIndex({ category: 1, price: -1 });
// Unique index — setara UNIQUE constraint
await Product.collection.createIndex({ sku: 1 }, { unique: true });
// Text index — untuk full-text search sederhana
await Product.collection.createIndex({ name: 'text', description: 'text' });// Definisikan langsung di schema — lebih idiomatik
const productSchema = new mongoose.Schema({
sku: { type: String, unique: true, index: true },
category: { type: String, index: true },
});
productSchema.index({ category: 1, price: -1 }); // compound indexAnalisa Query dengan explain()
const result = await Product.find({ category: 'electronics' }).explain('executionStats');
console.log(result.executionStats.executionStages.stage);
// "COLLSCAN" = collection scan (tanpa index, seperti Seq Scan di PostgreSQL)
// "IXSCAN" = index scan (pakai index, bagus)Bab 7: Studi Kasus: Katalog Produk dengan Atribut Bervariasi
Ini kasus yang secara eksplisit kita sebut di artikel #03 sebagai alasan valid pakai JSONB — sekarang bandingkan langsung dengan pendekatan MongoDB.
Schema
// src/models/product.model.js
const productSchema = new mongoose.Schema(
{
name: { type: String, required: true },
category: {
type: String,
required: true,
enum: ['laptop', 'fashion', 'book'],
},
price: { type: Number, required: true, min: 0 },
stock: { type: Number, required: true, default: 0, min: 0 },
// Bentuk field ini berbeda total tergantung category —
// ini justru kasus yang paling natural untuk MongoDB
attributes: {
type: mongoose.Schema.Types.Mixed,
default: {},
},
},
{ timestamps: true }
);
productSchema.index({ category: 1, price: 1 });
productSchema.index({ name: 'text' });
export const Product = mongoose.model('Product', productSchema);// Insert laptop
await Product.create({
name: 'Laptop Gaming X1',
category: 'laptop',
price: 15000000,
stock: 10,
attributes: { ram: '16GB', cpu: 'Intel i7', storage: '512GB SSD' },
});
// Insert baju — struktur attributes TOTAL berbeda, tidak perlu migration
await Product.create({
name: 'Kemeja Flanel',
category: 'fashion',
price: 250000,
stock: 50,
attributes: { size: 'L', material: 'Cotton', warna: 'Navy' },
});Query Berdasarkan Attribute Dinamis
// Cari laptop dengan RAM tertentu
const laptops = await Product.find({
category: 'laptop',
'attributes.ram': '16GB',
});
// Range harga + kategori — kombinasi filter tetap mudah
const results = await Product.find({
category: 'fashion',
price: { $gte: 100000, $lte: 500000 },
'attributes.size': { $in: ['M', 'L'] },
});MongoDB vs JSONB untuk Kasus Ini: Perbandingan Jujur
| Aspek | PostgreSQL + JSONB | MongoDB |
|---|---|---|
| Query field dinamis | WHERE attributes->>'ram' = '16GB' + GIN index | find({ 'attributes.ram': '16GB' }) + index biasa |
| Data transaksional lain (order, payment) | Sama-sama di database, JOIN native | Butuh $lookup atau database terpisah |
| Skema tetap sebagian besar tabel lain | Tetap ternormalisasi seperti biasa | Semua collection ikut model document |
| Tim sudah kenal SQL | Tidak ada tool/paradigma baru | Perlu belajar aggregation pipeline |
Kesimpulan jujur untuk kasus ini: kalau hanya satu jenis data yang butuh fleksibilitas skema (seperti katalog produk) sementara sisanya (order, user, payment) tetap solid relasional, JSONB di PostgreSQL yang sudah kamu pakai biasanya cukup — tidak perlu menambah database kedua. MongoDB baru lebih masuk akal kalau sebagian besar sistem kamu memang document-shaped, atau kamu butuh scaling horizontal yang MongoDB sediakan out-of-the-box.
Bab 8: Kapan MongoDB, Kapan PostgreSQL
Kerangka Keputusan
Apakah data punya relasi kompleks dan butuh JOIN sering?
├── Ya → PostgreSQL (JOIN native jauh lebih efisien)
└── Tidak, lanjut ↓
Apakah butuh transaksi multi-record yang ketat (ACID penuh)?
├── Ya → PostgreSQL (transaction adalah kekuatan utamanya)
└── Tidak, lanjut ↓
Apakah struktur data bervariasi signifikan antar record,
atau berubah bentuk sering seiring waktu?
├── Ya → MongoDB (schema fleksibel = tidak perlu migration tiap perubahan bentuk)
└── Tidak → PostgreSQL (schema tetap = lebih aman dari typo dan inkonsistensi)
Apakah butuh scaling horizontal (data > yang muat di satu server)?
├── Ya → MongoDB (sharding native, dirancang untuk ini)
└── Tidak → keduanya bisa, pilih berdasarkan poin di atas
Kasus Nyata yang Umum Ditemui
| Jenis data | Pilihan yang masuk akal | Alasan |
|---|---|---|
| User, auth, order, payment | PostgreSQL | Butuh ACID ketat, relasi jelas, integritas kritikal |
| Katalog produk (single business) | PostgreSQL + JSONB | Fleksibilitas terbatas ke satu kolom sudah cukup |
| Katalog produk (marketplace besar, ribuan kategori berbeda) | MongoDB | Skala fleksibilitas dan volume lebih besar |
| Log aktivitas, event tracking | MongoDB | Volume tinggi, struktur bisa berubah, jarang butuh JOIN |
| Konten CMS (artikel, halaman dinamis) | MongoDB | Struktur konten antar tipe halaman bisa sangat berbeda |
| Data analytics/reporting | Tergantung — sering keduanya (PostgreSQL untuk sumber, MongoDB/data warehouse untuk agregat) | Kebutuhan query berbeda dari data operasional |
Yang Sering Terlewat: Kamu Boleh Pakai Keduanya
Banyak sistem production nyata bukan "PostgreSQL vs MongoDB" tapi "PostgreSQL dan MongoDB", masing-masing untuk bagian yang cocok. toko-api yang kita bangun di artikel-artikel sebelumnya bisa saja tetap pakai PostgreSQL untuk orders, customers, payments — dan menambahkan MongoDB khusus untuk activity_logs yang volumenya tinggi dan strukturnya beragam. Tidak ada aturan yang memaksa satu database untuk seluruh sistem.
Yang harus dihindari: memilih MongoDB karena "startup besar pakai MongoDB" atau "katanya lebih scalable", padahal data kamu justru sangat relasional. Keputusan database yang baik lahir dari bentuk data kamu, bukan dari nama besar yang memakainya.
Penutup
Referensi Keputusan Cepat
| Situasi | Pilihan |
|---|---|
| Data transaksional dengan relasi ketat | PostgreSQL |
| Satu jenis entity butuh field fleksibel, sisanya solid | PostgreSQL + JSONB |
| Mayoritas sistem document-shaped, skema sering berubah | MongoDB |
| Butuh sharding/scaling horizontal native | MongoDB |
| Item transaksi (order items, cart items) | Embed di dokumen induk (MongoDB) — setara tabel terpisah di SQL |
| Relasi one-to-many tak terbatas | Reference, jangan embed |
| Field per-kategori berbeda (product attributes) | attributes: Mixed + index pada field yang sering di-query |
| Butuh JOIN kompleks berkali-kali | Sinyal kuat: pakai PostgreSQL |
Langkah selanjutnya:
- Eksplorasi Elasticsearch untuk kebutuhan full-text search yang lebih canggih dari
$textMongoDB atauGINindex PostgreSQL (artikel elasticsearch-search) - Pelajari Redis untuk caching dan session — database NoSQL lain dengan bentuk masalah yang lagi-lagi berbeda (artikel redis-caching)