Blog

  • Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Belajar Golang dari Nol #21: Mengirim Email dari Aplikasi Go

    Di Belajar Golang dari Nol #20 kita sudah bisa menerima upload file dari user. Aplikasi kita makin lengkap. User bisa daftar, login, kirim data, sampai unggah gambar. Tapi ada satu hal yang belum pernah benar-benar kita lakukan: menghubungi user balik. Kalau kamu baru bergabung di seri ini, cek dulu daftar lengkap seri supaya tidak lompat materi.

    Di bagian 19 kita sempat menyimulasikan pengiriman email lewat worker. Waktu itu emailnya pura-pura. Cuma time.Sleep dan sebaris log. Sekarang kita ganti simulasi itu dengan pengiriman sungguhan.

    Kenapa Aplikasi Perlu Mengirim Email

    Email sering dianggap kuno. Kenyataannya, email tetap tulang punggung notifikasi bisnis. Beberapa kasus nyata yang hampir pasti kamu temui:

    • Notifikasi pesanan. User checkout, sistem kirim konfirmasi berisi nomor order dan total belanja. Tanpa ini, user bertanya-tanya apakah pesanannya masuk.
    • Reset password. User lupa password, sistem kirim link reset. Ini standar keamanan yang tidak bisa ditawar.
    • Laporan harian ke owner. Setiap jam 6 pagi, owner terima rekap penjualan kemarin di inbox. Tidak perlu buka dashboard.

    WhatsApp dan push notification memang populer. Tapi email tidak butuh persetujuan platform, murah, dan punya jejak tertulis yang bisa diarsip. Untuk urusan transaksi, email masih juara.

    Kenalan Singkat dengan SMTP

    SMTP itu singkatan dari Simple Mail Transfer Protocol. Anggap saja dia kantor pos digital. Aplikasi kamu datang ke kantor pos (server SMTP), menyerahkan surat lengkap dengan alamat pengirim dan penerima, lalu kantor pos yang mengurus perjalanan surat itu sampai ke inbox tujuan. Kamu tidak perlu tahu rute detailnya. Yang kamu butuhkan cuma alamat kantor posnya (host dan port), plus identitas kamu (username dan password).

    Nah, di sini banyak pemula tergoda memakai SMTP Gmail pribadi untuk aplikasi production. Jangan. Gmail pribadi punya limit kirim harian yang kecil, sekitar 500 email per hari. Lewat dari itu akun bisa diblokir sementara. Reputasi pengirimnya juga bukan milik kamu, dan Gmail mewajibkan app password yang sewaktu-waktu bisa dicabut kebijakannya. Untuk belajar dan eksperimen, silakan pakai. Untuk production, pakai layanan transactional email seperti Mailgun, Postmark, Resend, atau Amazon SES. Mereka memang dibuat untuk aplikasi: limit besar, ada dashboard delivery, dan reputasi terjaga. Alternatif lain adalah SMTP dari hosting sendiri, tapi sadari risikonya: kalau IP server kamu pernah dipakai spammer lain, email kamu ikut masuk spam.

    Kabar baiknya, semua layanan itu bicara protokol yang sama. Kode Go yang kita tulis hari ini tinggal ganti host, port, dan kredensial.

    Kirim Email Pertama dengan net/smtp

    Go punya package bawaan net/smtp. Tidak perlu install apa-apa. Kredensial kita ambil dari environment variable, sesuai kebiasaan yang sudah kita bangun sejak bagian 15. Jangan pernah menulis password di kode.

    package main
    
    import (
    	"fmt"
    	"net/smtp"
    	"os"
    )
    
    func main() {
    	host := os.Getenv("SMTP_HOST") // contoh: smtp.gmail.com
    	port := os.Getenv("SMTP_PORT") // contoh: 587
    	user := os.Getenv("SMTP_USER")
    	pass := os.Getenv("SMTP_PASS")
    
    	from := "Toko Kita <noreply@tokokita.id>"
    	to := []string{"budi@example.com"}
    
    	msg := []byte("From: " + from + "\r\n" +
    		"To: budi@example.com\r\n" +
    		"Subject: Pesanan Kamu Sudah Kami Terima\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/plain; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		"Halo Budi,\r\n\r\n" +
    		"Pesanan kamu sudah masuk dan sedang kami proses.\r\n")
    
    	auth := smtp.PlainAuth("", user, pass, host)
    
    	err := smtp.SendMail(host+":"+port, auth, user, to, msg)
    	if err != nil {
    		fmt.Println("gagal kirim:", err)
    		return
    	}
    	fmt.Println("email terkirim")
    }

    Ada dua hal yang perlu kamu perhatikan. Pertama, smtp.PlainAuth membungkus username dan password untuk autentikasi ke server. Parameter pertamanya identitas, biasanya dikosongkan. Kedua, format pesan itu bukan sekadar teks. Bagian atas adalah header: From, To, Subject, MIME-Version, dan Content-Type. Setiap baris header dipisah \r\n. Setelah header ada satu baris kosong, baru isi email. Kalau baris kosong itu hilang, email kamu tampil berantakan atau subject-nya hilang.

    Jalankan dengan environment variable terpasang, lalu cek inbox tujuan. Kalau berhasil, selamat. Aplikasi kamu resmi bisa bicara ke dunia luar.

    Email HTML dengan html/template

    Email teks biasa cukup untuk notifikasi sederhana. Tapi email konfirmasi pesanan biasanya butuh format: nama tebal, tabel harga, tombol. Caranya, ganti Content-Type menjadi text/html dan isi body dengan HTML.

    Untuk menyusun HTML-nya, jangan sambung string manual. Pakai html/template, package yang sama dengan yang pernah kita pakai untuk render halaman. Alasannya penting: html/template melakukan escaping otomatis. Kalau ada user iseng mendaftar dengan nama <script>alert(1)</script>, template akan mengubahnya jadi teks biasa yang aman, bukan kode yang dieksekusi.

    package mailer
    
    import (
    	"bytes"
    	"html/template"
    )
    
    type OrderEmailData struct {
    	Nama       string
    	NomorOrder string
    	Total      string
    }
    
    var orderTpl = template.Must(template.New("order").Parse(`
    <h2>Terima kasih, {{.Nama}}</h2>
    <p>Pesanan <strong>{{.NomorOrder}}</strong> sudah kami terima.</p>
    <p>Total pembayaran: <strong>{{.Total}}</strong></p>
    <p>Kami kabari lagi begitu pesanan dikirim.</p>
    `))
    
    func RenderOrderEmail(data OrderEmailData) (string, error) {
    	var buf bytes.Buffer
    	if err := orderTpl.Execute(&buf, data); err != nil {
    		return "", err
    	}
    	return buf.String(), nil
    }

    Struct OrderEmailData jadi kontrak yang jelas. Siapa pun yang mau kirim email pesanan tahu persis data apa yang harus disiapkan. template.Must membuat program langsung berhenti saat start kalau template-nya salah tulis, bukan meledak diam-diam saat ada order masuk.

    Rapikan dengan Interface EmailSender

    Sekarang bagian yang membedakan kode belajar dengan kode production. Ingat pelajaran interface di bagian 9? Kita definisikan kontrak pengirim email, lalu buat dua implementasi.

    package mailer
    
    import (
    	"log"
    	"net/smtp"
    	"os"
    )
    
    type EmailSender interface {
    	Send(to, subject, htmlBody string) error
    }
    
    // SMTPSender mengirim email sungguhan.
    type SMTPSender struct {
    	Host string
    	Port string
    	User string
    	Pass string
    	From string
    }
    
    func (s SMTPSender) Send(to, subject, htmlBody string) error {
    	msg := []byte("From: " + s.From + "\r\n" +
    		"To: " + to + "\r\n" +
    		"Subject: " + subject + "\r\n" +
    		"MIME-Version: 1.0\r\n" +
    		"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
    		"\r\n" +
    		htmlBody)
    
    	auth := smtp.PlainAuth("", s.User, s.Pass, s.Host)
    	return smtp.SendMail(s.Host+":"+s.Port, auth, s.User, []string{to}, msg)
    }
    
    // LogSender cuma mencatat, tidak mengirim. Untuk development.
    type LogSender struct{}
    
    func (LogSender) Send(to, subject, htmlBody string) error {
    	log.Printf("[EMAIL-DEV] to=%s subject=%q panjang body=%d byte", to, subject, len(htmlBody))
    	return nil
    }
    
    // NewSenderFromEnv memilih implementasi lewat env EMAIL_DRIVER.
    func NewSenderFromEnv() EmailSender {
    	if os.Getenv("EMAIL_DRIVER") == "smtp" {
    		return SMTPSender{
    			Host: os.Getenv("SMTP_HOST"),
    			Port: os.Getenv("SMTP_PORT"),
    			User: os.Getenv("SMTP_USER"),
    			Pass: os.Getenv("SMTP_PASS"),
    			From: os.Getenv("SMTP_FROM"),
    		}
    	}
    	return LogSender{}
    }

    Kenapa repot begini? Karena saat development kamu tidak mau inbox pribadi kebanjiran email percobaan, dan tidak mau menghabiskan kuota layanan berbayar. Cukup set EMAIL_DRIVER selain smtp, semua email hanya tercatat di log. Di server production, set EMAIL_DRIVER=smtp dan email terkirim sungguhan. Kode aplikasi tidak berubah sama sekali.

    Bonusnya untuk testing, seperti yang kita bahas di bagian 14: fungsi yang menerima EmailSender bisa diuji dengan implementasi palsu. Test kamu tidak pernah menyentuh server SMTP sungguhan.

    Kirim Lewat Worker, Jangan Tahan Response

    Mengirim email itu lambat. Bisa satu sampai lima detik, kadang lebih kalau server SMTP sedang sibuk. Kalau kamu kirim email di tengah handler HTTP, user menatap loading selama itu. Lebih buruk lagi, kalau SMTP error, apakah order user ikut gagal? Tidak masuk akal. Order sudah tersimpan di database, email cuma pelengkap.

    Solusinya sudah kita bangun di bagian 19: worker. Handler cukup melempar job ke channel, lalu langsung membalas user. Worker di belakang layar yang mengurus pengiriman, lengkap dengan retry kalau gagal.

    type EmailJob struct {
    	To      string
    	Subject string
    	Body    string
    }
    
    func emailWorker(jobs <-chan EmailJob, sender mailer.EmailSender) {
    	for job := range jobs {
    		sendWithRetry(sender, job)
    	}
    }
    
    func sendWithRetry(sender mailer.EmailSender, job EmailJob) {
    	delays := []time.Duration{
    		2 * time.Second,
    		5 * time.Second,
    		15 * time.Second,
    	}
    
    	var err error
    	for percobaan := 1; percobaan <= len(delays)+1; percobaan++ {
    		err = sender.Send(job.To, job.Subject, job.Body)
    		if err == nil {
    			log.Printf("email ke %s terkirim (percobaan %d)", job.To, percobaan)
    			return
    		}
    		if percobaan <= len(delays) {
    			log.Printf("email ke %s gagal (percobaan %d): %v, coba lagi", job.To, percobaan, err)
    			time.Sleep(delays[percobaan-1])
    		}
    	}
    	log.Printf("MENYERAH: email ke %s gagal total setelah %d percobaan: %v", job.To, len(delays)+1, err)
    }

    Pola retry-nya sederhana tapi lengkap. Jeda antar percobaan naik bertingkat: 2 detik, 5 detik, lalu 15 detik. Istilahnya backoff. Kalau server SMTP cuma tersendat sebentar, percobaan kedua biasanya berhasil. Kalau setelah empat kali tetap gagal, kita berhenti dan catat kegagalan final di log dengan jelas. Log inilah yang nanti kamu periksa saat ada user komplain tidak menerima email.

    Jebakan yang Sering Menjebak Pemula

    Email masuk spam. Ini keluhan nomor satu. Penyebabnya biasanya bukan kode, tapi DNS. Ada dua record yang wajib dikenal namanya: SPF dan DKIM. SPF mendeklarasikan server mana saja yang boleh mengirim email atas nama domain kamu. DKIM menandatangani email secara digital supaya penerima yakin isinya tidak dipalsukan. Keduanya diatur di pengaturan DNS domain dan panel penyedia email, bukan di kode Go. Layanan seperti Mailgun atau Postmark akan memberi kamu daftar record yang tinggal disalin ke DNS.

    Alamat pengirim harus domain sendiri. Mengirim dari noreply@tokokita.id jauh lebih dipercaya daripada dari alamat Gmail gratisan. Selain soal reputasi, SPF dan DKIM memang hanya bisa dipasang di domain milik sendiri.

    Jangan hardcode daftar penerima. Alamat owner untuk laporan harian, alamat admin untuk alert, semuanya taruh di environment variable atau database. Alamat email itu data, bukan kode. Kalau owner ganti alamat, kamu tidak seharusnya perlu build ulang aplikasi.

    Latihan: Email Konfirmasi Order Lewat Worker

    Mari rangkai semuanya. Endpoint order yang setelah sukses menyimpan data langsung melempar job email ke worker, memakai LogSender di development.

    func main() {
    	sender := mailer.NewSenderFromEnv()
    	emailJobs := make(chan EmailJob, 100)
    	go emailWorker(emailJobs, sender)
    
    	http.HandleFunc("POST /orders", func(w http.ResponseWriter, r *http.Request) {
    		// ... validasi input dan simpan order ke database ...
    		order := OrderEmailData{
    			Nama:       "Budi",
    			NomorOrder: "ORD-2026-0421",
    			Total:      "Rp250.000",
    		}
    
    		body, err := mailer.RenderOrderEmail(order)
    		if err != nil {
    			log.Println("gagal render template:", err)
    		} else {
    			emailJobs <- EmailJob{
    				To:      "budi@example.com",
    				Subject: "Konfirmasi Pesanan " + order.NomorOrder,
    				Body:    body,
    			}
    		}
    
    		w.WriteHeader(http.StatusCreated)
    		w.Write([]byte(`{"status":"order diterima"}`))
    	})
    
    	log.Println("server jalan di :8080")
    	http.ListenAndServe(":8080", nil)
    }

    Perhatikan urutannya. Order disimpan dulu. Email dilempar ke channel. Response 201 Created langsung dikirim tanpa menunggu email selesai. Kalau render template gagal, kita catat dan tetap balas sukses, karena order-nya sendiri berhasil.

    Jalankan tanpa EMAIL_DRIVER, lalu tembak endpoint-nya. Log yang muncul kira-kira begini:

    2026/07/27 09.15.02 server jalan di :8080
    2026/07/27 09.15.40 [EMAIL-DEV] to=budi@example.com subject="Konfirmasi Pesanan ORD-2026-0421" panjang body=218 byte
    2026/07/27 09.15.40 email ke budi@example.com terkirim (percobaan 1)

    Response ke user tetap kilat, email tercatat rapi, dan tidak ada satu pun email sungguhan yang keluar dari laptop kamu. Saat deploy, tinggal set EMAIL_DRIVER=smtp beserta kredensialnya. Coba kembangkan sendiri: tambah email reset password dengan template kedua, atau jadwalkan laporan harian memakai pola ticker dari bagian 19.

    Penutup

    Aplikasi kita sekarang bisa berbicara ke user lewat inbox mereka. Kamu sudah paham cara kerja SMTP, format pesan email, template HTML yang aman, pola EmailSender yang enak dites, sampai retry lewat worker. Fondasi notifikasi bisnis kamu sudah berdiri.

    Di bagian 22 kita balik arah. Kalau hari ini aplikasi kita yang mengirim keluar, berikutnya kita belajar mengambil data dari layanan orang lain: “Terhubung ke API Pihak Ketiga: HTTP Client yang Benar”. Sampai ketemu di sana.

    Butuh sistem dengan notifikasi email otomatis untuk bisnis kamu, dari konfirmasi order sampai laporan harian? Tim Arrazy siap bantu lewat jasa pengembangan sistem aplikasi.

  • Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap

    Belajar Linux dari Nol #4: Struktur Direktori Linux Lengkap

    Struktur direktori Linux berbeda total dengan Windows. Tidak ada drive C: atau D:. Semua file, folder, bahkan perangkat keras, tersusun dalam satu pohon besar yang berawal dari satu titik: / alias root. Aturan penataannya diatur oleh standar bernama Filesystem Hierarchy Standard (FHS), jadi hampir semua distro Linux punya susunan folder yang mirip.

    Di bagian keempat seri Belajar Linux dari Nol ini kita akan tur keliling folder-folder penting itu satu per satu memakai terminal. Setelah selesai, kamu tahu di mana file config aplikasi disimpan, di mana log berada, dan kenapa folder /root menolak kamu masuk. Pengetahuan ini jadi bekal wajib sebelum nanti kita mengelola server sungguhan.

    Prasyarat Sebelum Mulai

    Tutorial ini memakai Ubuntu 24.04 LTS, sama seperti bagian-bagian sebelumnya. Kamu juga harus sudah nyaman berpindah folder dengan cd, melihat isi folder dengan ls, dan mengecek posisi dengan pwd. Kalau tiga perintah itu masih asing, selesaikan dulu Belajar Linux dari Nol #3: Perintah Dasar Linux Navigasi karena semua praktik di sini memakainya.

    Kabar baiknya, tur kali ini hampir seluruhnya hanya membaca. Kita cuma melihat-lihat isi folder, bukan mengubah apa pun. Jadi aman, tidak ada risiko merusak sistem.

    Filesystem Hierarchy Standard: Semua Berawal dari /

    Di Windows, setiap partisi atau disk muncul sebagai drive terpisah: C:, D:, E:. Linux memakai pendekatan lain. Hanya ada satu pohon direktori, dan puncaknya adalah / yang dibaca “root”. Disk kedua, flashdisk, atau partisi lain tidak muncul sebagai drive baru, melainkan “ditempelkan” (mount) ke salah satu folder di dalam pohon itu, biasanya di bawah /media atau /mnt.

    Susunan folder di bawah / tidak asal-asalan. Ada dokumen standar bernama Filesystem Hierarchy Standard yang dikelola Linux Foundation. Standar inilah alasan kamu bisa pindah dari Ubuntu ke Debian, Fedora, atau AlmaLinux dan tetap menemukan /etc, /var, dan /home di tempat yang sama.

    Mari lihat isinya langsung. Jalankan:

    ls /

    Di Ubuntu 24.04 outputnya kurang lebih seperti ini:

    bin    dev   home  lib64       media  opt   root  sbin  srv  tmp  var
    boot   etc   lib   lost+found  mnt    proc  run   snap  sys  usr

    Banyak, tapi tenang. Kamu tidak perlu hafal semuanya sekarang. Kita fokus ke delapan folder yang paling sering kamu sentuh sehari-hari.

    Tur Praktik Struktur Direktori Linux

    Buka terminal, lalu ikuti tur ini folder demi folder. Jalankan setiap perintahnya supaya nempel di ingatan.

    /home: Rumah untuk Setiap User

    Setiap user biasa punya satu folder pribadi di dalam /home. Kalau username kamu budi, maka folder pribadimu adalah /home/budi. Di sinilah dokumen, download, dan file config pribadimu tinggal.

    ls /home

    Outputnya daftar user yang terdaftar di komputer itu, misalnya:

    budi

    Simbol ~ yang sering kamu lihat di prompt terminal adalah singkatan untuk home milikmu sendiri. Jadi cd ~ dan cd /home/budi hasilnya sama.

    /etc: Pusat File Konfigurasi Sistem

    Hampir semua konfigurasi sistem dan aplikasi level sistem disimpan di /etc dalam bentuk file teks biasa. Nama hostname komputer, daftar user, konfigurasi jaringan, sampai setting web server, semuanya di sini.

    ls /etc | head -5
    adduser.conf
    alternatives
    apache2
    apparmor
    apparmor.d

    Contoh yang gampang dicek, file berisi nama komputermu:

    cat /etc/hostname

    Kalau nanti kamu install Nginx, confignya ada di /etc/nginx. Install PHP, confignya di /etc/php. Polanya konsisten, dan itulah enaknya struktur direktori Linux yang mengikuti FHS.

    /var: Data yang Terus Berubah, Termasuk Log

    Nama var berasal dari kata variable, artinya data yang ukurannya berubah-ubah selama sistem berjalan. Penghuni paling penting di sini adalah /var/log, tempat semua catatan aktivitas sistem disimpan.

    ls /var/log
    alternatives.log  bootstrap.log  dpkg.log  kern.log   syslog
    apt               btmp           faillog   lastlog    wtmp
    auth.log          dmesg          journal   private

    Saat ada aplikasi error atau server bermasalah, /var/log adalah tempat pertama yang dicek. Website yang di-hosting di server juga sering ditaruh di /var/www. Di tim Arrazy, kebiasaan pertama saat debugging server klien ya membuka folder ini, jauh sebelum menyentuh kode aplikasinya.

    /usr dan /bin: Tempat Program Terinstall

    Program yang kamu jalankan dari terminal, seperti ls, cat, atau nano, sebenarnya adalah file executable yang tersimpan di /usr/bin. Cek berapa banyak isinya:

    ls /usr/bin | wc -l

    Di Ubuntu 24.04 hasil fresh install biasanya di kisaran 1500 lebih, tergantung aplikasi yang terpasang. Kamu bahkan bisa menemukan lokasi persis sebuah perintah:

    which ls
    /usr/bin/ls

    Lalu apa bedanya dengan /bin? Di Ubuntu modern, /bin hanyalah symlink alias jalan pintas menuju /usr/bin. Buktikan sendiri:

    ls -ld /bin
    lrwxrwxrwx 1 root root 7 Apr 22  2024 /bin -> usr/bin

    Tanda panah itu artinya /bin menunjuk ke usr/bin. Dulu keduanya terpisah, sekarang sudah digabung. Selain program, /usr juga menampung library di /usr/lib dan dokumentasi di /usr/share.

    /tmp: Penampungan File Sementara

    Folder /tmp boleh dipakai siapa saja untuk menaruh file sementara. Aplikasi sering memakainya untuk file kerja yang tidak perlu disimpan lama.

    ls /tmp

    Isinya bervariasi, kadang kosong, kadang penuh file aneh milik aplikasi yang sedang jalan. Satu hal penting: jangan pernah menyimpan file berharga di sini. Ubuntu 24.04 membersihkan isi /tmp secara otomatis, dan file yang sudah lama tidak disentuh akan dihapus sistem. Kita bahas jebakannya di bagian troubleshooting.

    /opt: Aplikasi Pihak Ketiga

    Folder /opt disediakan untuk software opsional dari luar repositori resmi, biasanya aplikasi komersial atau software yang diinstall manual. Google Chrome misalnya, menaruh dirinya di /opt/google/chrome.

    ls /opt

    Di sistem yang masih segar outputnya kosong, dan itu normal. Folder ini baru terisi ketika kamu menginstall aplikasi yang memang memilih tinggal di sana.

    /root: Rumah Khusus Superuser

    Ini yang sering bikin bingung pemula. /root bukan direktori root /. /root adalah home folder milik user bernama root, si superuser. Coba intip:

    ls /root
    ls: cannot open directory '/root': Permission denied

    Ditolak, dan itu memang disengaja. Hanya root yang boleh masuk ke rumahnya sendiri. Kalau penasaran isinya, pinjam hak akses root dengan sudo:

    sudo ls /root

    Masukkan password kamu, dan barulah isinya terlihat, biasanya cuma ada folder snap di instalasi baru.

    Everything is a File: Mengintip /proc dan /dev

    Ada satu filosofi Linux yang terkenal: everything is a file, semuanya adalah file. Hardware, proses yang sedang berjalan, bahkan informasi kernel, semuanya bisa diakses seolah-olah file biasa. Dua folder ini buktinya.

    /proc berisi file virtual yang dibuat kernel secara langsung di memori. File-file ini bukan file sungguhan di disk, tapi jendela untuk melihat kondisi sistem saat itu juga. Mau tahu prosesor komputermu?

    grep "model name" /proc/cpuinfo | head -1
    model name	: AMD Ryzen 5 5500U with Radeon Graphics

    Output di komputermu menyesuaikan prosesor masing-masing. Ada juga /proc/meminfo untuk RAM dan /proc/uptime untuk lama komputer menyala.

    /dev berisi file yang mewakili perangkat keras. Disk pertama biasanya muncul sebagai /dev/sda atau /dev/nvme0n1, terminal yang sedang kamu pakai pun punya file di sini.

    ls /dev | head -5
    autofs
    block
    bsg
    btrfs-control
    bus

    Kamu belum perlu mendalami keduanya sekarang. Cukup tahu bahwa keduanya ada, isinya virtual, dan jangan diutak-atik sembarangan.

    Peta Cepat: Di Mana Mencari Apa

    Supaya gampang diingat, ini rangkuman lokasi yang paling sering dicari, terutama saat nanti kamu mulai mengurus server:

    Kamu mencari Lokasi umum Contoh
    File konfigurasi aplikasi /etc /etc/nginx/nginx.conf
    Log sistem dan aplikasi /var/log /var/log/syslog
    Program hasil install APT /usr/bin /usr/bin/nano
    File website di server /var/www /var/www/html
    Aplikasi install manual /opt /opt/google/chrome
    File pribadi user /home /home/budi/Documents

    Peta ini yang dipakai tim Arrazy setiap hari saat menyiapkan server untuk aplikasi klien, dari backend Go sampai Laravel. Deploy aplikasi, cek log, ubah config, semuanya berputar di folder-folder di tabel ini. Kalau kamu tertarik melihat seperti apa hasil akhirnya di dunia kerja, sistem yang kami bangun untuk klien ada gambarannya di halaman sistem aplikasi.

    Troubleshooting: Error yang Sering Dialami Pemula

    Permission denied saat masuk /root

    Kamu menjalankan cd /root atau ls /root dan ditolak dengan pesan Permission denied. Ini bukan error, ini fitur keamanan. Folder itu milik superuser dan permissionnya drwx------, artinya hanya root yang boleh membuka. Solusinya bukan memaksa masuk, tapi bertanya dulu ke diri sendiri: perlu apa di sana? Kalau memang perlu, awali perintah dengan sudo, misalnya sudo ls /root. Untuk file pribadimu, tempatnya di /home/username, bukan /root.

    File di /tmp hilang sendiri

    Kemarin menaruh file kerjaan di /tmp, hari ini sudah lenyap. Ini perilaku normal. Di Ubuntu 24.04, systemd membersihkan /tmp lewat mekanisme tmpfiles, dan file yang berumur lebih dari 10 hari dihapus otomatis. Reboot juga bisa mengosongkannya. Anggap /tmp seperti meja kasir: boleh dipakai sebentar, jangan ditinggali. Simpan file penting di home folder.

    No such file or directory padahal foldernya ada

    Kamu mengetik cd etc dari home folder dan muncul bash: cd: etc: No such file or directory. Penyebabnya path relatif. Tanpa garis miring di depan, shell mencari folder etc di dalam posisi kamu berdiri sekarang, bukan di puncak pohon. Tambahkan / di depan sehingga menjadi cd /etc. Aturan praktisnya: kalau tujuanmu folder sistem, selalu tulis path lengkap dari /.

    Mengubah file di /etc ditolak

    Membuka file config di /etc bisa, tapi saat menyimpan muncul pesan permission denied. Wajar, file di sana milik root dan user biasa hanya boleh membaca. Editnya perlu sudo, contohnya sudo nano /etc/hostname. Tapi hati-hati, salah edit file di /etc bisa membuat sistem bermasalah. Biasakan mencatat isi asli sebelum mengubah apa pun.

    Lanjut ke Bagian Berikutnya

    Sekarang kamu sudah pegang peta lengkap struktur direktori Linux: dari /home tempat file pribadimu, /etc untuk config, /var/log untuk log, sampai /usr/bin tempat program tinggal. Peta ini akan terus terpakai sampai seri ini selesai, apalagi saat masuk materi server.

    Di bagian berikutnya, Belajar Linux dari Nol #5: Cara Copy dan Hapus File di Linux, kita mulai benar-benar mengelola file: menyalin, memindahkan, mengganti nama, dan menghapus dengan aman lewat terminal. Artikelnya terbit menyusul, pantau daftar lengkapnya di halaman hub Belajar Linux.

    Referensi

  • Website Distributor Itu Beda dari Toko Online, Ini Alasannya

    Website Distributor Itu Beda dari Toko Online, Ini Alasannya

    Website distributor memang beda dari toko online, dan bedanya bukan sekadar tampilan. Pembeli toko online adalah konsumen akhir yang lihat produk, masukkan keranjang, bayar, selesai. Pembeli website distributor adalah bisnis lain: toko, apotek, kontraktor, rumah sakit, atau reseller. Mereka tidak checkout keranjang. Mereka cek katalog, menilai apakah perusahaan Anda bisa dipercaya, lalu menghubungi untuk nego harga dan volume.

    Karena alurnya beda, desain websitenya juga harus beda. Website distributor yang baik dirancang untuk satu tujuan: membuat calon mitra yakin, lalu mendorong mereka mengirim permintaan penawaran. Bukan mendorong klik tombol beli. Artikel ini membahas apa saja yang membedakan keduanya, dari struktur katalog, halaman kredibilitas, sampai contoh nyata dari proyek yang pernah kami kerjakan.

    Pembeli B2B tidak belanja seperti konsumen ritel

    Bayangkan seorang staf purchasing di sebuah rumah sakit atau pemilik toko bangunan yang sedang mencari pemasok baru. Prosesnya kira-kira begini. Dia cari beberapa kandidat distributor lewat Google atau rekomendasi. Dia buka website masing-masing, lihat produk apa saja yang tersedia, lalu menilai perusahaannya. Setelah itu dia kontak dua atau tiga distributor untuk minta penawaran, bandingkan harga dan syarat, baru terbit purchase order.

    Perhatikan satu hal: tidak ada checkout di alur itu. Harga B2B tergantung volume, frekuensi order, dan kadang termin pembayaran. Harga untuk pembeli 10 unit jelas beda dengan pembeli 500 unit. Kalau website distributor dipasangi keranjang belanja dengan harga fix seperti toko ritel, alurnya malah tabrakan dengan cara kerja pembeli bisnis.

    Ini juga alasan kenapa distributor tidak bisa hanya mengandalkan marketplace. Marketplace dirancang untuk transaksi ritel satuan, bukan untuk nego volume. Perbandingan lengkapnya pernah kami bahas di artikel jualan di marketplace vs website sendiri.

    Katalog tanpa harga publik, dan cara membuatnya tetap meyakinkan

    Banyak distributor sengaja tidak memajang harga di website. Alasannya masuk akal. Harga berubah mengikuti kurs dan harga prinsipal. Harga tiap klien bisa beda tergantung volume dan kontrak. Dan harga publik gampang diintip kompetitor. Jadi katalog tanpa harga itu bukan kelemahan, itu praktik normal di dunia distribusi.

    Masalahnya, katalog tanpa harga sering ditulis asal-asalan. Cuma nama produk dan satu foto dari internet. Calon pembeli tidak bisa menilai apa-apa dari halaman seperti itu. Supaya katalog tetap meyakinkan walau tanpa harga, setiap produk sebaiknya punya:

    • Spesifikasi lengkap: ukuran, bahan, kapasitas, varian, isi per kemasan atau karton
    • Merek dan kode produk yang jelas, supaya tim purchasing gampang mencocokkan dengan kebutuhan mereka
    • Sertifikat atau izin produk kalau ada, misalnya SNI atau izin edar sesuai kategori produknya
    • Foto asli produk, bukan foto stok. Foto gudang atau produk di rak justru menambah rasa percaya
    • Tombol tanya harga atau minta penawaran di setiap halaman produk

    Prinsipnya sederhana. Harga boleh disimpan untuk nego, tapi informasi produk harus dibuka selebar-lebarnya. Semakin lengkap datanya, semakin sedikit bolak-balik tanya jawab sebelum penawaran dikirim.

    Halaman kredibilitas: yang dicek calon mitra sebelum menghubungi Anda

    Sebelum kontak, calon mitra hampir pasti mengecek satu hal: perusahaan ini beneran atau tidak. Wajar saja. Order B2B nilainya besar, sering pakai termin pembayaran, dan kalau pemasoknya bermasalah, operasional pembeli ikut kacau. Website Anda harus menjawab keraguan itu sebelum mereka sempat bertanya.

    Beberapa hal yang biasanya dicari calon mitra di website distributor:

    • Legalitas perusahaan: nama PT atau CV, alamat kantor dan gudang yang jelas, NIB atau izin usaha
    • Brand atau prinsipal yang dipegang. Kalau Anda distributor resmi merek tertentu, tampilkan. Surat penunjukan dari prinsipal adalah salah satu bukti terkuat di bisnis distribusi
    • Jangkauan pengiriman: kota atau wilayah mana saja yang terlayani, dan lokasi gudang kalau lebih dari satu
    • Klien atau mitra yang sudah jalan, misalnya jaringan toko, instansi, atau proyek yang pernah disuplai, tentu dengan izin mereka
    • Tim atau kontak person yang bisa dihubungi, bukan cuma alamat email generik

    Halaman seperti ini jarang ada di template toko online. Padahal untuk distributor, halaman kredibilitas sering lebih menentukan daripada halaman produk itu sendiri.

    CTA yang tepat untuk B2B: minta penawaran, bukan beli sekarang

    Tombol beli sekarang cocok untuk ritel. Untuk distributor, ajakan yang pas adalah minta penawaran. Bentuknya bisa dua macam, dan sebaiknya keduanya ada.

    Pertama, form permintaan penawaran atau RFQ. Isinya cukup nama perusahaan, kontak, produk yang diminati, dan perkiraan volume. Form seperti ini menyaring pertanyaan iseng dan memberi tim sales Anda informasi awal sebelum menghubungi balik.

    Kedua, tombol WhatsApp dengan template pesan yang sudah terisi. Misalnya pesan otomatis berbunyi: Halo, saya ingin menanyakan harga untuk produk X. Calon mitra tinggal tekan kirim. Semakin kecil usahanya untuk memulai kontak, semakin banyak percakapan yang masuk.

    Kalau kebutuhan sudah berkembang, ada beberapa fitur lanjutan yang layak dipertimbangkan di tahap berikutnya:

    • Katalog PDF per kategori yang bisa diunduh. Tim sales Anda juga bisa memakainya saat kunjungan atau mengirimnya lewat WhatsApp ke calon klien
    • Area reseller dengan login, tempat mitra tetap bisa melihat harga khusus mereka dan mengajukan order berulang tanpa nego dari nol

    Dua fitur ini tidak wajib ada di versi pertama website. Mulai dari katalog dan jalur kontak yang rapi dulu, fitur lanjutan menyusul saat pola ordernya sudah terbentuk.

    Katalog yang rapi juga aset SEO

    Cara calon mitra mencari pemasok di Google cukup mudah ditebak. Polanya biasanya distributor plus nama produk plus kota. Misalnya distributor alat kesehatan Semarang, grosir sembako Purwokerto, atau supplier bahan bangunan Cilacap.

    Di sinilah struktur katalog berperan dua kali. Halaman kategori yang rapi, dengan nama kategori yang jelas dan deskripsi singkat, bukan cuma memudahkan pengunjung. Halaman itu juga jadi halaman pendaratan yang bisa muncul di hasil pencarian untuk kata kunci kategori tersebut. Satu kategori produk, satu halaman, satu peluang ditemukan. Website dengan katalog asal tumpuk kehilangan peluang ini.

    Efeknya juga terasa jangka panjang. Iklan berhenti saat anggaran habis, sedangkan halaman kategori yang sudah nangkring di hasil pencarian terus mendatangkan calon mitra tanpa biaya per klik. Untuk bisnis distribusi yang mengandalkan hubungan jangka panjang dengan pelanggan, aset seperti ini nilainya besar.

    Studi kasus: website distributor alat medis Mito Adhiguna

    Salah satu contoh dari pengalaman kami adalah website PT Mito Adhiguna Kencana, distributor alat medis yang kami kerjakan pada 2024. Kebutuhan mereka persis seperti yang dibahas di artikel ini: website yang mampu merepresentasikan kredibilitas distributor alat medis secara profesional. Tantangan utamanya ada di katalog yang luas. Produk alat medis itu banyak jenisnya, dan informasinya harus rapi supaya mudah dipahami calon buyer.

    Solusinya, kami membangun arsitektur katalog produk yang terstruktur, memperbaiki pengalaman pencarian dan navigasi antar kategori, lalu mendesain tampilan yang menonjolkan kredibilitas bisnis medis. Di sisi teknis, website dibangun dengan Vue.js dan dioptimasi SEO agar mudah ditemukan untuk pencarian produk yang relevan. Hasil akhirnya adalah website katalog yang menyeimbangkan tiga hal: profesionalisme brand, kemudahan pencarian produk, dan kesiapan konversi untuk alur bisnis B2B.

    Polanya bisa dipakai lintas industri. Ganti alat medis dengan sembako, bahan bangunan, ATK, atau suku cadang, kerangkanya tetap sama. Katalog terstruktur, sinyal kredibilitas yang kuat, dan jalur kontak yang jelas.

    Pertanyaan yang sering muncul

    Apakah website distributor wajib mencantumkan harga?

    Tidak. Sebagian besar distributor memang tidak memajang harga karena harga tergantung volume dan kontrak. Yang wajib justru informasi produknya: spesifikasi, merek, foto asli, dan tombol minta penawaran yang mudah ditemukan di setiap halaman produk.

    Kalau nanti mau jualan langsung ke konsumen, apakah websitenya harus dibangun ulang?

    Tidak harus, asal fondasinya direncanakan sejak awal. Katalog yang terstruktur bisa dikembangkan menjadi toko online atau area reseller dengan login di tahap berikutnya. Yang penting sampaikan rencana ini sejak awal ke pengembang website Anda supaya arsitekturnya disiapkan.

    Distributor skala kecil apakah sudah perlu website sendiri?

    Kalau target Anda pembeli bisnis, iya. Calon mitra B2B hampir selalu mengecek website sebelum memutuskan kontak, dan profil marketplace tidak bisa menampilkan legalitas, prinsipal, atau jangkauan kirim dengan layak. Website sederhana berisi katalog dan halaman kredibilitas sudah cukup untuk mulai.

    Mulai dari mana?

    Kalau Anda menjalankan usaha distribusi atau grosir dan websitenya masih berupa toko online biasa, atau malah belum ada sama sekali, mulailah dari tiga hal di artikel ini. Katalog dengan informasi produk yang lengkap, halaman kredibilitas yang menjawab keraguan calon mitra, dan jalur minta penawaran yang mudah.

    Tim Arrazy sudah beberapa kali mengerjakan website dengan pola seperti ini, termasuk untuk distributor alat medis di atas. Kalau mau berdiskusi dulu soal struktur katalog atau kebutuhan spesifik usaha Anda, silakan lihat layanan jasa pembuatan website kami. Konsultasi awalnya gratis, dan Anda bisa cerita dulu tanpa harus langsung order.

  • Apa Itu Payment Gateway dan Cara Kerjanya untuk Bisnis Online

    Apa Itu Payment Gateway dan Cara Kerjanya untuk Bisnis Online

    Payment gateway adalah perantara yang menerima pembayaran dari pelangganmu lewat berbagai metode, mulai dari virtual account bank, QRIS, e-wallet, kartu kredit, sampai gerai retail, lalu meneruskan dananya ke rekening bisnismu. Tugasnya bukan cuma memindahkan uang. Payment gateway juga memberi tahu sistemmu secara otomatis begitu pembayaran masuk, sehingga status pesanan langsung berubah jadi lunas tanpa ada orang yang mengecek mutasi rekening.

    Kalau bisnismu masih mengandalkan transfer manual dan kirim bukti transfer lewat WhatsApp, konsep ini layak kamu pahami sebelum memutuskan apa pun. Di artikel ini kita bahas pelan-pelan: bedanya dengan transfer manual, ke mana uang pembeli mengalir, metode pembayaran apa saja yang umum di Indonesia, berapa kira-kira biayanya, sampai kapan bisnismu memang sudah waktunya pakai.

    Beda Transfer Manual dan Payment Gateway

    Coba ingat alur transfer manual di bisnismu sekarang. Pembeli transfer ke rekeningmu, lalu kirim screenshot bukti transfer. Admin membuka mobile banking, mencocokkan nominal dan nama pengirim satu per satu, baru menandai pesanan sebagai lunas. Kalau ada dua pembeli transfer dengan nominal sama di jam yang berdekatan, admin harus menebak bukti mana milik siapa.

    Dengan payment gateway, semua langkah itu hilang. Pembeli memilih metode bayar, membayar, dan detik itu juga sistemmu tahu pembayaran sudah masuk. Status pesanan berubah jadi lunas secara otomatis. Tidak ada bukti transfer yang harus dikirim, tidak ada mutasi yang harus dicek.

    Dampaknya nyata di operasional harian:

    • Tidak ada orderan yang lolos gara-gara bukti transfer terselip atau lupa dicek.
    • Tidak perlu admin standby memantau mutasi rekening, termasuk saat weekend.
    • Pembeli yang checkout jam 2 pagi tetap terlayani. Bayar, lunas, selesai, tanpa menunggu tokomu buka.

    Contoh nyatanya bisa dilihat di sistem pendaftaran event. Peserta mendaftar, membayar, dan langsung mendapat e-ticket tanpa panitia menyentuh apa pun. Kami pernah menulis detailnya di artikel tentang sistem pendaftaran event lari online dengan pembayaran otomatis.

    Cara Kerja Payment Gateway: Alur Uang Langkah demi Langkah

    Supaya gampang, kita pakai satu contoh transaksi. Katakanlah pembeli checkout di tokomu senilai 100 ribu rupiah dan memilih bayar pakai QRIS.

    • Pembeli scan kode QR di halaman pembayaran, lalu konfirmasi di aplikasi banknya atau e-wallet.
    • Uang 100 ribu itu tidak langsung masuk ke rekeningmu. Dana mampir dulu di penyedia payment gateway.
    • Di detik yang sama, payment gateway mengirim notifikasi ke sistemmu bahwa pembayaran berhasil. Status pesanan otomatis jadi lunas dan pembeli menerima konfirmasi.
    • Penyedia gateway memotong biaya layanan dari nominal tersebut.
    • Sisanya dikirim ke rekening bisnismu sesuai jadwal, misalnya H+1 setelah transaksi.

    Proses pengiriman dana dari penyedia gateway ke rekeningmu itu namanya settlement. Jadwalnya beda-beda tiap penyedia dan tiap metode pembayaran. Ada yang H+1, ada yang beberapa hari kerja. Poin pentingnya: status lunas di sistemmu terjadi seketika, sedangkan uangnya menyusul sesuai jadwal settlement. Dua hal ini memang terpisah, dan justru karena itu operasionalmu bisa jalan cepat tanpa menunggu uang benar-benar mendarat.

    Metode Pembayaran yang Umum di Indonesia

    Satu integrasi payment gateway biasanya langsung membuka banyak pilihan bayar sekaligus. Ini yang paling umum dipakai di Indonesia beserta karakternya:

    • Virtual account (VA) bank. Pembeli mendapat nomor rekening unik dengan nominal yang sudah pas. Tidak mungkin salah jumlah, jadi cocok untuk tagihan seperti SPP, invoice, atau pesanan bernilai jelas.
    • QRIS. Tinggal scan dari aplikasi bank atau e-wallet mana pun. Cepat dan familiar, cocok untuk transaksi ritel bernilai kecil sampai menengah.
    • E-wallet. Pembayaran langsung dari saldo GoPay, OVO, DANA, dan sejenisnya. Nyaman untuk pembeli yang jarang buka mobile banking.
    • Kartu kredit. Membuka opsi cicilan dan memudahkan transaksi bernilai besar.
    • Gerai retail. Pembeli bayar tunai di minimarket. Ini pintu masuk untuk pelanggan yang tidak punya rekening bank sama sekali.

    Semakin banyak pilihan, semakin kecil kemungkinan pembeli batal karena metode favoritnya tidak tersedia.

    Berapa Biayanya dan Siapa yang Menanggung

    Payment gateway mengambil biaya per transaksi yang berhasil. Skemanya umumnya dua macam: persentase dari nominal transaksi, atau biaya flat dengan angka tetap berapa pun nilai transaksinya. Metode kartu biasanya pakai persentase, sementara VA sering pakai flat. Kami sengaja tidak menyebut angka spesifik di sini karena tarif tiap penyedia berubah dari waktu ke waktu. Cek langsung halaman harga penyedia yang kamu incar.

    Lalu siapa yang menanggung biaya itu? Ada dua pilihan. Pertama, kamu serap sendiri sebagai biaya operasional, jadi harga di pembeli tetap bersih. Kedua, biayanya dibebankan ke pembeli sebagai biaya layanan di halaman checkout. Dua-duanya lazim. Bisnis dengan margin tipis biasanya membebankan ke pembeli, sementara bisnis yang mengejar konversi memilih menyerapnya.

    Amankah? Justru Lebih Aman dari Transfer Manual

    Banyak pemilik bisnis ragu karena uangnya mampir dulu di pihak ketiga. Wajar. Tapi kalau dibandingkan dengan alur kirim-kirim bukti transfer, payment gateway justru lebih aman untuk dua sisi.

    Dari sisi kamu sebagai penjual, tidak ada lagi risiko bukti transfer palsu atau editan. Pembayaran diverifikasi otomatis oleh sistem, bukan oleh mata admin yang bisa kecolongan. Dari sisi pembeli, uang mengalir lewat jalur resmi yang berlisensi dan diawasi, bukan lewat rekening pribadi yang tidak jelas pertanggungjawabannya. Data kartu dan detail pembayaran juga diproses di infrastruktur penyedia gateway, bukan disimpan di websitemu.

    Satu catatan penting: pastikan penyedia yang kamu pilih mengantongi izin resmi dari Bank Indonesia. Penyedia besar yang umum dipakai di Indonesia semuanya berizin, jadi ini lebih ke soal teliti sebelum memilih.

    Apa yang Dibutuhkan untuk Mulai Pakai Payment Gateway

    Secara garis besar ada dua hal yang perlu disiapkan.

    Pertama, dokumen usaha. Penyedia payment gateway akan memverifikasi bisnismu sebelum akun aktif. Biasanya diminta identitas pemilik, data rekening tujuan settlement, dan dokumen badan usaha. Beberapa penyedia menerima usaha perorangan, sebagian layanan tertentu butuh badan usaha resmi. Detailnya beda-beda, jadi anggap ini gambaran umum saja.

    Kedua, website atau sistem yang bisa diintegrasikan. Payment gateway bekerja lewat koneksi antar sistem. Websitemu perlu bisa membuat tagihan, menerima notifikasi pembayaran, lalu mengubah status pesanan secara otomatis. Di sinilah peran developer. Integrasi yang benar memastikan notifikasi pembayaran tidak nyasar, tidak dobel, dan tidak bisa dipalsukan. Kalau kamu belum punya tim teknis, layanan seperti jasa pembuatan website dengan integrasi payment gateway menangani bagian ini dari awal sampai pembayaran benar-benar jalan.

    Kapan Belum Butuh, dan Kapan Sudah Waktunya

    Payment gateway bukan kewajiban untuk semua bisnis. Kalau transaksimu masih beberapa kali sehari, pembelinya pelanggan tetap yang sudah saling percaya, dan cek mutasi masih santai dikerjakan, transfer manual belum jadi masalah. Biaya per transaksi malah jadi beban yang belum perlu.

    Tanda-tanda kamu sudah waktunya pindah biasanya begini:

    • Admin mulai kewalahan mencocokkan bukti transfer, apalagi di jam ramai.
    • Pernah ada orderan terlewat atau salah tandai karena verifikasi manual.
    • Banyak pembeli checkout di luar jam kerja dan komplain karena konfirmasinya lama.
    • Kamu mau jualan berjalan otomatis penuh, dari order sampai lunas, tanpa campur tangan orang.

    Kalau dua atau tiga poin di atas sudah terasa, biaya per transaksi payment gateway hampir pasti lebih murah daripada gaji waktu admin dan orderan yang hilang.

    Pertanyaan yang Sering Muncul

    Uangnya nyangkut di penyedia gateway berapa lama?

    Tergantung jadwal settlement penyedia dan metode pembayarannya. Umumnya H+1 sampai beberapa hari kerja setelah transaksi. Selama masa itu dana tercatat atas nama transaksimu di penyedia, jadi bukan hilang, hanya menunggu jadwal pengiriman ke rekeningmu.

    Kalau pembeli salah bayar, bagaimana?

    Kemungkinan salah bayar justru jauh lebih kecil dibanding transfer manual. Nominal di VA dan QRIS sudah terkunci sesuai tagihan, jadi pembeli tidak bisa mengetik jumlah sendiri. Kalau pembayaran gagal di tengah jalan, dana otomatis kembali ke pembeli lewat mekanisme banknya. Untuk kasus khusus seperti pembeli minta refund setelah bayar, penyedia gateway punya prosedur pengembalian dana yang bisa dijalankan dari dashboard.

    Bisa dipasang di website yang sudah ada?

    Bisa, selama websitemu punya alur transaksi yang jelas. Developer akan menghubungkan halaman checkout dengan payment gateway, lalu menyiapkan penerimaan notifikasi supaya status pesanan berubah otomatis. Platform toko online populer umumnya sudah punya jalur integrasinya, sementara website custom butuh penyesuaian di sisi kode.

    Sampai sini mestinya sudah kebayang: payment gateway itu perantara yang membuat pembayaran online masuk lewat banyak metode, terverifikasi otomatis, lalu mengalir ke rekeningmu sesuai jadwal settlement. Kalau kamu sudah merasa waktunya pindah dari transfer manual tapi bingung mulai dari mana, tim Arrazy bisa bantu memetakan kebutuhanmu, dari memilih penyedia sampai integrasi ke website. Ceritakan dulu model bisnismu lewat halaman kontak kami, konsultasinya gratis.

  • Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Belajar Golang dari Nol #20: Upload dan Menyimpan File di API Go

    Di Belajar Golang dari Nol #19 kita sudah membuat worker dan job terjadwal. API kita sekarang bisa mengerjakan tugas berat di belakang layar tanpa membuat user menunggu. Kali ini kita masuk ke kemampuan yang hampir selalu diminta klien: upload file. Kita akan menerima foto produk lewat API, memvalidasinya dengan benar, menyimpannya dengan aman, lalu menyajikannya kembali. Kalau kamu baru bergabung di tengah seri, silakan mampir dulu ke daftar lengkap seri supaya alurnya nyambung.

    Kasus nyata: API produk butuh foto

    Sejak bagian 12 kita punya API produk yang tersambung ke database. Tabel produk berisi nama, harga, dan stok. Semua berjalan baik sampai satu permintaan datang: setiap produk harus punya foto.

    Foto bukan teks. Dia tidak bisa dikirim begitu saja lewat body JSON seperti request kita selama ini. Untuk mengirim file, browser dan aplikasi memakai format bernama multipart/form-data.

    Bayangkan multipart seperti paket kiriman. Di dalam satu paket ada beberapa bungkusan kecil. Tiap bungkusan punya label nama, misalnya nama_produk atau foto. Isinya bisa teks biasa, bisa juga file mentah lengkap dengan nama asli dan tipenya. Antar bungkusan dipisah oleh garis pembatas yang disebut boundary. Server tinggal membuka paket itu satu per satu dan mengambil bagian yang dia butuhkan.

    Kenapa tidak lewat JSON saja? Sebenarnya bisa, dengan mengubah file menjadi teks base64. Tapi ukurannya membengkak sekitar sepertiga dan server harus decode manual. Multipart lebih hemat, didukung semua bahasa dan tool, jadi dia menjadi standar untuk urusan upload.

    Kabar baiknya, paket net/http di Go sudah bisa membongkar paket ini tanpa library tambahan.

    Menerima file di handler

    Dua fungsi kuncinya adalah r.ParseMultipartForm untuk membongkar paket, dan r.FormFile untuk mengambil satu file berdasarkan nama field. Versi paling sederhana terlihat seperti ini.

    func uploadHandler(w http.ResponseWriter, r *http.Request) {
        // bongkar form, maksimal 2 MB ditahan di memori
        if err := r.ParseMultipartForm(2 << 20); err != nil {
            http.Error(w, "form tidak valid", http.StatusBadRequest)
            return
        }
    
        file, header, err := r.FormFile("foto")
        if err != nil {
            http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
            return
        }
        defer file.Close()
    
        fmt.Printf("nama: %s, ukuran: %d byte\n", header.Filename, header.Size)
    }

    FormFile mengembalikan tiga hal. Pertama isi file yang bisa dibaca seperti io.Reader biasa. Kedua header berisi nama asli file dan ukurannya. Ketiga error kalau field itu tidak ada di request.

    Sampai sini file sudah masuk. Tapi handler ini masih polos. Dia menerima file apa pun, sebesar apa pun. Itu berbahaya. Kita perbaiki satu per satu.

    Validasi pertama: batasi ukuran body

    Banyak tutorial berhenti di angka 2 << 20 pada ParseMultipartForm dan menganggap itu batas ukuran upload. Ini salah kaprah yang perlu diluruskan. Angka itu hanya mengatur berapa banyak data yang ditahan di memori. Sisanya tetap diterima, lalu ditulis ke file sementara di disk. Artinya orang masih bisa mengirim file 5 GB dan server kamu tetap sibuk menampungnya.

    Cara yang benar untuk membatasi ukuran adalah http.MaxBytesReader. Fungsi ini membungkus body request. Begitu jumlah byte yang dibaca melewati batas, pembacaan langsung dihentikan dan koneksi ditutup. Server tidak buang tenaga menampung sisa kiriman.

    const maksUkuran = 2 << 20 // 2 MB
    
    r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
    
    if err := r.ParseMultipartForm(maksUkuran); err != nil {
        http.Error(w, "file terlalu besar, maksimal 2 MB",
            http.StatusRequestEntityTooLarge)
        return
    }

    Pasang MaxBytesReader sebelum ParseMultipartForm dipanggil. Kalau body melebihi batas, ParseMultipartForm akan gagal dan kita balas dengan status 413, kode standar untuk body yang terlalu besar.

    Angka 2 MB di sini hanya contoh. Sesuaikan dengan kebutuhan aplikasimu. Untuk foto katalog produk, 2 sampai 5 MB biasanya lebih dari cukup. Semakin longgar batasnya, semakin besar ruang yang bisa dimanfaatkan orang iseng untuk membebani server.

    Validasi kedua: cek tipe file dari isinya

    Jangan pernah percaya ekstensi file. Mengganti virus.exe menjadi virus.jpg cuma butuh satu kali rename. Header Content-Type yang dikirim klien juga bisa dipalsukan dengan mudah.

    Yang tidak bisa dipalsukan adalah isi filenya sendiri. Setiap format punya tanda pengenal di byte awalnya, sering disebut magic number. File PNG selalu diawali deretan byte tertentu, begitu juga JPEG dan WebP. Go menyediakan http.DetectContentType yang membaca maksimal 512 byte pertama lalu menebak tipenya dari tanda itu.

    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    buf := make([]byte, 512)
    n, err := file.Read(buf)
    if err != nil && err != io.EOF {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }
    
    tipe := http.DetectContentType(buf[:n])
    ekstensi, ok := tipeDiizinkan[tipe]
    if !ok {
        http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
            http.StatusUnsupportedMediaType)
        return
    }
    
    // kembalikan posisi baca ke awal file
    if _, err := file.Seek(0, io.SeekStart); err != nil {
        http.Error(w, "gagal membaca file", http.StatusInternalServerError)
        return
    }

    Perhatikan dua hal. Pertama, kita memakai map untuk daftar tipe yang diizinkan sekaligus menentukan ekstensi penyimpanan. Selain jpg, png, dan webp, semuanya ditolak dengan status 415. Kedua, setelah membaca 512 byte pertama, posisi baca file sudah bergeser. Kita harus Seek kembali ke awal supaya nanti file tersimpan utuh, bukan terpotong.

    Kamu mungkin bertanya kenapa SVG tidak masuk daftar, padahal dia juga format gambar. SVG sebenarnya file teks XML dan bisa berisi script. Kalau disajikan dari domain yang sama dengan aplikasimu, script itu bisa dijalankan browser dan menjadi celah XSS. Untuk upload gambar dari user, amannya batasi ke format bitmap saja.

    Menyimpan file dengan aman

    Sekarang bagian yang paling sering jadi lubang keamanan: nama file. Naluri pertama kebanyakan orang adalah memakai header.Filename apa adanya. Jangan.

    Nama file dikirim oleh user, dan user bisa mengirim apa saja. Termasuk nama seperti ini.

    ../../etc/cron.d/jahat

    Kalau kamu menggabungkan nama itu dengan folder uploads begitu saja, tanda ../ akan membawa tulisan keluar dari folder uploads dan menimpa file lain di server. Serangan ini disebut path traversal. Satu request upload bisa berubah jadi pintu masuk ke seluruh sistem.

    Solusinya sederhana. Abaikan nama dari user sepenuhnya. Server yang menentukan nama, dibuat acak supaya tidak bisa ditebak dan tidak mungkin bentrok dengan file lain.

    import (
        "crypto/rand"
        "encoding/hex"
    )
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }

    Enam belas byte acak dari crypto/rand menghasilkan nama 32 karakter heksadesimal. Kalau kamu lebih suka format standar, library uuid seperti github.com/google/uuid juga hasilnya sama amannya. Karena nama selalu baru, upload tidak akan pernah menimpa file yang sudah ada.

    Lalu proses simpannya. Pastikan folder tujuan ada dengan os.MkdirAll, buat file tujuan, salin isinya dengan io.Copy.

    const folderUpload = "uploads"
    
    if err := os.MkdirAll(folderUpload, 0o755); err != nil {
        http.Error(w, "gagal menyiapkan folder", http.StatusInternalServerError)
        return
    }
    
    nama, err := namaAcak()
    if err != nil {
        http.Error(w, "gagal membuat nama file", http.StatusInternalServerError)
        return
    }
    
    lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
    tujuan, err := os.Create(lokasi)
    if err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }
    defer tujuan.Close()
    
    if _, err := io.Copy(tujuan, file); err != nil {
        http.Error(w, "gagal menyimpan file", http.StatusInternalServerError)
        return
    }

    io.Copy menyalin per potongan kecil, jadi file besar tidak dimuat seluruhnya ke memori. Ekstensi kita ambil dari hasil deteksi tipe tadi, bukan dari nama asli, jadi konsisten dengan isi sebenarnya. Angka 0o755 pada MkdirAll adalah izin folder di Linux. Pemilik boleh menulis, user lain hanya bisa membaca. Formatnya oktal, sama dengan yang dipakai perintah chmod.

    Menyimpan path ke database

    File fisik sudah tersimpan. Sekarang produk harus tahu di mana fotonya. Kita tidak menyimpan file di database, cukup path-nya saja. Ini menyambung pola query yang sudah kita pakai sejak bagian 12.

    pathFoto := "/uploads/" + nama + ekstensi
    
    _, err = db.Exec(
        "UPDATE produk SET foto = ? WHERE id = ?",
        pathFoto, id,
    )
    if err != nil {
        http.Error(w, "gagal menyimpan data foto", http.StatusInternalServerError)
        return
    }

    Kolom foto tinggal ditambahkan ke tabel produk dengan satu perintah ALTER TABLE produk ADD COLUMN foto VARCHAR(100). Yang tersimpan adalah path publiknya, jadi frontend bisa langsung memakainya sebagai src gambar.

    Menyajikan file yang sudah diupload

    Foto yang tersimpan harus bisa diakses lewat URL. Go punya http.FileServer untuk menyajikan isi folder, dan http.StripPrefix untuk memotong awalan URL supaya cocok dengan struktur folder.

    fs := http.FileServer(http.Dir("uploads"))
    mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", fs))

    Tanpa StripPrefix, request ke /uploads/abc.jpg akan dicari sebagai uploads/uploads/abc.jpg. Dengan pemotongan awalan, dia benar mengarah ke uploads/abc.jpg.

    Untuk belajar dan untuk aplikasi kecil, ini cukup. Di production, file statis biasanya diserahkan ke Nginx yang sudah kita pasang sebagai reverse proxy di bagian 15. Nginx memang dirancang untuk menyajikan file statis, lengkap dengan cache header, dan dia melakukannya tanpa menyentuh aplikasi Go sama sekali. Aplikasi kamu jadi fokus mengurus logika bisnis saja.

    location /uploads/ {
        alias /var/www/aplikasi/uploads/;
        expires 30d;
    }

    Blok ini menyuruh Nginx melayani semua request /uploads/ langsung dari folder di disk, plus cache 30 hari di browser.

    Satu aturan penting apa pun cara penyajiannya. Folder uploads harus hanya berisi file hasil upload, terpisah dari kode aplikasi. Jangan pernah menyajikan folder aplikasi sebagai file statis, dan jangan menaruh file upload di folder yang bisa dieksekusi sebagai kode.

    Menghapus foto lama saat diganti

    Ada satu detail yang sering dilupakan. Kalau user mengganti foto produk, file lama masih tergeletak di disk. Lama kelamaan folder uploads penuh sampah. Jadi sebelum menyimpan path baru, ambil dulu path lama, lalu hapus filenya setelah semuanya sukses.

    var fotoLama sql.NullString
    err = db.QueryRow(
        "SELECT foto FROM produk WHERE id = ?", id,
    ).Scan(&fotoLama)
    
    // ... proses simpan file baru dan UPDATE database ...
    
    if fotoLama.Valid && fotoLama.String != "" {
        lokasiLama := filepath.Join(".", fotoLama.String)
        if err := os.Remove(lokasiLama); err != nil {
            slog.Warn("gagal menghapus foto lama",
                "path", lokasiLama, "error", err)
        }
    }

    Perhatikan cara kita menangani errornya. Kalau os.Remove gagal, misalnya file sudah tidak ada, request tidak perlu digagalkan. Foto baru sudah tersimpan dan database sudah benar, user tidak dirugikan apa pun. Cukup catat kejadiannya lewat slog yang sudah kita siapkan di bagian 18, supaya kamu bisa memeriksa dan membersihkannya nanti. Tidak semua error layak mematikan request.

    Batas jujur pendekatan simpan di disk

    Sebelum kamu memakai pola ini di semua project, ada batasnya yang perlu kamu tahu.

    Pertama, file menempel di satu server. Kalau kamu pindah server atau server rusak dan tidak ada backup, semua foto hilang. Kedua, begitu aplikasi berjalan di dua server di belakang load balancer, masalah muncul. Upload masuk ke server A, tapi request berikutnya dilayani server B yang tidak punya filenya.

    Jawaban untuk masalah ini adalah object storage, layanan seperti Amazon S3, Cloudflare R2, atau DigitalOcean Spaces. Konsepnya begini: file tidak disimpan di disk server kamu, melainkan dikirim ke layanan penyimpanan terpisah yang bisa diakses semua server lewat API. Kamu mendapat URL untuk tiap file, tahan hilang karena datanya direplikasi, dan server aplikasi bisa ditambah kurangi tanpa pusing soal file. Alur kodenya mirip, hanya tujuan io.Copy berganti dari file lokal ke client S3. Untuk aplikasi satu server dengan backup rutin, simpan di disk masih pilihan yang wajar dan murah.

    Latihan: endpoint foto produk yang utuh

    Sekarang kita rangkai semuanya menjadi satu program lengkap yang bisa langsung kamu jalankan.

    package main
    
    import (
        "crypto/rand"
        "database/sql"
        "encoding/hex"
        "encoding/json"
        "io"
        "log/slog"
        "net/http"
        "os"
        "path/filepath"
    
        _ "github.com/go-sql-driver/mysql"
    )
    
    const (
        folderUpload = "uploads"
        maksUkuran   = 2 << 20 // 2 MB
    )
    
    var tipeDiizinkan = map[string]string{
        "image/jpeg": ".jpg",
        "image/png":  ".png",
        "image/webp": ".webp",
    }
    
    func namaAcak() (string, error) {
        b := make([]byte, 16)
        if _, err := rand.Read(b); err != nil {
            return "", err
        }
        return hex.EncodeToString(b), nil
    }
    
    func uploadFoto(db *sql.DB) http.HandlerFunc {
        return func(w http.ResponseWriter, r *http.Request) {
            id := r.PathValue("id")
    
            r.Body = http.MaxBytesReader(w, r.Body, maksUkuran)
            if err := r.ParseMultipartForm(maksUkuran); err != nil {
                http.Error(w, "file terlalu besar, maksimal 2 MB",
                    http.StatusRequestEntityTooLarge)
                return
            }
    
            file, _, err := r.FormFile("foto")
            if err != nil {
                http.Error(w, "field foto wajib diisi", http.StatusBadRequest)
                return
            }
            defer file.Close()
    
            buf := make([]byte, 512)
            n, err := file.Read(buf)
            if err != nil && err != io.EOF {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
            ekstensi, ok := tipeDiizinkan[http.DetectContentType(buf[:n])]
            if !ok {
                http.Error(w, "hanya jpg, png, dan webp yang diizinkan",
                    http.StatusUnsupportedMediaType)
                return
            }
            if _, err := file.Seek(0, io.SeekStart); err != nil {
                http.Error(w, "gagal membaca file", http.StatusInternalServerError)
                return
            }
    
            var fotoLama sql.NullString
            err = db.QueryRow(
                "SELECT foto FROM produk WHERE id = ?", id,
            ).Scan(&fotoLama)
            if err == sql.ErrNoRows {
                http.Error(w, "produk tidak ditemukan", http.StatusNotFound)
                return
            }
            if err != nil {
                http.Error(w, "gagal membaca data produk",
                    http.StatusInternalServerError)
                return
            }
    
            if err := os.MkdirAll(folderUpload, 0o755); err != nil {
                http.Error(w, "gagal menyiapkan folder",
                    http.StatusInternalServerError)
                return
            }
            nama, err := namaAcak()
            if err != nil {
                http.Error(w, "gagal membuat nama file",
                    http.StatusInternalServerError)
                return
            }
            lokasi := filepath.Join(folderUpload, nama+ekstensi)
    
            tujuan, err := os.Create(lokasi)
            if err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
            defer tujuan.Close()
            if _, err := io.Copy(tujuan, file); err != nil {
                http.Error(w, "gagal menyimpan file",
                    http.StatusInternalServerError)
                return
            }
    
            pathFoto := "/uploads/" + nama + ekstensi
            if _, err := db.Exec(
                "UPDATE produk SET foto = ? WHERE id = ?", pathFoto, id,
            ); err != nil {
                http.Error(w, "gagal menyimpan data foto",
                    http.StatusInternalServerError)
                return
            }
    
            if fotoLama.Valid && fotoLama.String != "" {
                lokasiLama := filepath.Join(".", fotoLama.String)
                if err := os.Remove(lokasiLama); err != nil {
                    slog.Warn("gagal menghapus foto lama",
                        "path", lokasiLama, "error", err)
                }
            }
    
            w.Header().Set("Content-Type", "application/json")
            json.NewEncoder(w).Encode(map[string]string{
                "id":   id,
                "foto": pathFoto,
            })
        }
    }
    
    func main() {
        db, err := sql.Open("mysql",
            "root:rahasia@tcp(127.0.0.1:3306)/toko?parseTime=true")
        if err != nil {
            slog.Error("gagal membuka database", "error", err)
            os.Exit(1)
        }
        defer db.Close()
    
        mux := http.NewServeMux()
        mux.HandleFunc("POST /produk/{id}/foto", uploadFoto(db))
        mux.Handle("GET /uploads/",
            http.StripPrefix("/uploads/",
                http.FileServer(http.Dir(folderUpload))))
    
        slog.Info("server berjalan", "port", 8080)
        http.ListenAndServe(":8080", mux)
    }

    Coba dari terminal. Flag -F pada curl otomatis mengirim request sebagai multipart, dan tanda @ berarti ambil isi dari file di komputermu.

    curl -F "foto=@kaos-polos.jpg" http://localhost:8080/produk/12/foto

    Kalau sukses, responsnya seperti ini.

    {"id":"12","foto":"/uploads/9f3c1a7e2b64d0f18a5c47e9b2d6031f.jpg"}

    Buka http://localhost:8080/uploads/9f3c...jpg di browser dan foto akan tampil. Sekarang uji jalur gagalnya. Kirim file 10 MB, kamu akan menerima status 413 dengan pesan file terlalu besar. Kirim file PDF yang di-rename jadi .jpg, kamu akan menerima status 415 karena isi filenya bukan gambar. Validasi kita membaca isi, bukan nama, jadi trik rename tidak mempan.

    Sebagai latihan tambahan, coba dua hal. Pertama, catat setiap upload sukses lewat slog lengkap dengan id produk dan ukuran file, lalu amati lognya. Kedua, buat endpoint DELETE /produk/{id}/foto yang menghapus file sekaligus mengosongkan kolom foto di database. Semua bahannya sudah ada di artikel ini.

    Penutup

    API produk kamu sekarang bisa menerima foto dengan aman. Ukuran dibatasi lewat MaxBytesReader, tipe dicek dari isi file, nama dibuat acak supaya bebas path traversal, path tersimpan rapi di database, dan file lama ikut dibersihkan. Pola yang sama berlaku untuk dokumen, bukti transfer, atau lampiran apa pun.

    Di bagian 21 kita akan membahas kemampuan lain yang hampir pasti dibutuhkan aplikasi nyata, yaitu Mengirim Email dari Aplikasi Go. Mulai dari email verifikasi sampai notifikasi transaksi, semuanya dari kode Go sendiri.

    Kalau kamu sedang membangun aplikasi yang butuh fitur upload, manajemen produk, atau sistem internal lain dan ingin dikerjakan tim yang berpengalaman, lihat layanan pengembangan sistem aplikasi Arrazy. Sampai jumpa di bagian berikutnya.

  • Belajar Kubernetes #3: Arsitektur Kubernetes Cluster

    Belajar Kubernetes #3: Arsitektur Kubernetes Cluster

    Arsitektur Kubernetes terdiri dari dua bagian besar: control plane yang bertugas mengatur seluruh cluster, dan worker node tempat aplikasi kamu benar-benar berjalan. Control plane berisi kube-apiserver, etcd, scheduler, dan controller-manager. Worker node berisi kubelet, kube-proxy, dan container runtime seperti containerd. Semua komunikasi antar komponen lewat satu pintu, yaitu kube-apiserver.

    Kalau di dua bagian sebelumnya kita sudah install Minikube dan paham posisi Kubernetes dibanding Docker, sekarang saatnya membuka kap mesinnya. Artikel ini bagian ketiga dari seri Belajar Kubernetes dari Nol. Kita akan bedah tiap komponen, lihat langsung wujudnya di Minikube, lalu telusuri perjalanan satu perintah kubectl apply dari terminal sampai container menyala di node.

    Prasyarat Sebelum Mulai

    Kamu butuh Minikube yang sudah jalan. Di seri ini saya pakai Minikube v1.36.0 dengan Kubernetes v1.33 dan driver Docker. Kalau belum install, kembali dulu ke bagian pertama seri. Kalau masih bingung kenapa Kubernetes perlu banyak komponen padahal Docker terlihat sederhana, baca dulu Belajar Kubernetes #2: Perbedaan Docker dan Kubernetes. Bagian itu menjelaskan kenapa orkestrasi butuh “otak” terpisah dari mesin yang menjalankan container.

    Pastikan cluster hidup:

    minikube status

    Output yang diharapkan:

    minikube
    type: Control Plane
    host: Running
    kubelet: Running
    apiserver: Running
    kubeconfig: Configured

    Gambaran Besar: Control Plane vs Worker Node

    Bayangkan cluster Kubernetes sebagai sebuah kantor. Control plane adalah lantai manajemen. Worker node adalah lantai produksi tempat pekerjaan dikerjakan. Manajemen tidak pernah mengerjakan produksi langsung. Mereka hanya mencatat pesanan, memutuskan siapa mengerjakan apa, lalu memantau hasilnya.

    +--------------------- CONTROL PLANE ---------------------+
    |                                                          |
    |   +----------------+        +---------------------+     |
    |   | kube-apiserver |<------>|        etcd         |     |
    |   |  (pintu masuk) |        |  (penyimpan state)  |     |
    |   +-------+--------+        +---------------------+     |
    |           ^                                              |
    |           |                                              |
    |   +-------+--------+        +---------------------+     |
    |   | kube-scheduler |        | controller-manager  |     |
    |   +----------------+        +---------------------+     |
    |                                                          |
    +---------------------------+------------------------------+
                                |
                  semua lewat apiserver (HTTPS)
                                |
    +--------------------- WORKER NODE ------------------------+
    |                                                           |
    |   +---------+     +------------+     +----------------+  |
    |   | kubelet |---->| containerd |---->| container app  |  |
    |   +---------+     +------------+     +----------------+  |
    |                                                           |
    |   +------------+                                          |
    |   | kube-proxy |  (mengatur lalu lintas jaringan)         |
    |   +------------+                                          |
    |                                                           |
    +-----------------------------------------------------------+

    Di production, control plane biasanya punya server sendiri, bahkan tiga server sekaligus supaya tahan gangguan. Worker node bisa puluhan sampai ribuan. Di Minikube, semuanya dijejalkan ke satu mesin. Praktis untuk belajar, tapi konsepnya tetap sama persis.

    Komponen Control Plane dan Tugasnya

    kube-apiserver, Resepsionis Satu Pintu

    Semua permintaan ke cluster masuk lewat kube-apiserver. Perintah kubectl, laporan dari kubelet, keputusan scheduler, semuanya. Tidak ada komponen yang boleh saling bicara langsung. Persis resepsionis kantor yang memvalidasi tamu, mencatat keperluan, lalu meneruskan ke bagian yang tepat. Apiserver juga yang memeriksa autentikasi dan otorisasi, jadi dia sekaligus satpamnya.

    etcd, Lemari Arsip Cluster

    etcd adalah database key-value yang menyimpan seluruh kondisi cluster. Berapa replika yang diminta, pod apa berjalan di node mana, isi ConfigMap, semuanya ada di sini. Hanya apiserver yang boleh membuka lemari arsip ini. Kalau etcd hilang tanpa backup, cluster kehilangan ingatannya. Aplikasi mungkin masih jalan sebentar, tapi cluster tidak lagi tahu apa yang seharusnya dia kelola.

    kube-scheduler, Manajer Penempatan

    Setiap kali ada pod baru yang belum punya node, scheduler yang memutuskan pod itu jalan di mana. Dia menimbang sisa CPU dan memori tiap node, aturan afinitas, dan batasan lain. Seperti manajer yang menerima tumpukan tugas baru lalu membagikannya ke karyawan yang bebannya paling ringan dan keahliannya cocok. Penting diingat, scheduler hanya memutuskan. Dia tidak menjalankan apa pun. Keputusannya dicatat kembali lewat apiserver.

    kube-controller-manager, Supervisor yang Tidak Pernah Tidur

    Controller-manager berisi banyak controller kecil yang kerjanya satu: membandingkan kondisi nyata dengan kondisi yang diinginkan, lalu mengoreksi selisihnya. Kamu minta 3 replika, ternyata satu pod mati, controller membuat penggantinya. Seperti supervisor yang tiap beberapa detik keliling lantai produksi sambil membawa daftar pesanan. Ada yang kurang, dia langsung menugaskan ulang. Pola “desired state vs actual state” ini adalah jantung cara kerja Kubernetes.

    Komponen Worker Node dan Alurnya

    kubelet, Mandor di Tiap Node

    kubelet adalah agen yang berjalan di setiap node. Dia rajin bertanya ke apiserver, “ada pod yang ditugaskan ke node saya?” Kalau ada, dia yang mengeksekusi: minta container runtime menarik image, membuat container, lalu melaporkan statusnya balik ke apiserver. kubelet juga yang menjalankan health check ke container.

    Container Runtime (containerd), Tukang yang Benar-Benar Bekerja

    kubelet tidak bisa menjalankan container sendiri. Dia menyuruh container runtime lewat antarmuka standar bernama CRI (Container Runtime Interface). Runtime yang paling umum sekarang adalah containerd, yang juga dipakai Minikube secara default sejak versi 1.30-an ketika memakai driver Docker. containerd yang menarik image dari registry, membuat filesystem container, dan menjalankan prosesnya.

    kube-proxy, Petugas Lalu Lintas Jaringan

    kube-proxy mengatur aturan jaringan di tiap node supaya traffic ke sebuah Service bisa sampai ke pod yang tepat, meskipun pod itu pindah-pindah node. Dia bekerja dengan menulis aturan iptables atau IPVS. Kita akan sering ketemu komponen ini lagi di bagian Service nanti.

    Praktik: Bedah Komponen di Minikube

    Teori cukup. Sekarang buktikan semua komponen itu benar-benar ada. Di Minikube, komponen control plane sendiri dijalankan sebagai pod di namespace kube-system:

    kubectl get pods -n kube-system

    Output yang diharapkan kurang lebih seperti ini:

    NAME                               READY   STATUS    RESTARTS   AGE
    coredns-674b8bbfcf-x7k2m           1/1     Running   0          15m
    etcd-minikube                      1/1     Running   0          15m
    kube-apiserver-minikube            1/1     Running   0          15m
    kube-controller-manager-minikube   1/1     Running   0          15m
    kube-proxy-9fkq4                   1/1     Running   0          15m
    kube-scheduler-minikube            1/1     Running   0          15m
    storage-provisioner                1/1     Running   0          15m

    Semua nama yang kita bahas ada di sana. coredns dan storage-provisioner adalah komponen tambahan, nanti kita bahas di bagian lain. Sekarang masuk ke dalam node Minikube untuk melihat kubelet, karena kubelet tidak berjalan sebagai pod melainkan sebagai service di sistem operasi node:

    minikube ssh

    Setelah masuk, cek kubelet dan containerd:

    docker@minikube:~$ pgrep -a kubelet
    1123 /var/lib/minikube/binaries/v1.33.1/kubelet --bootstrap-kubeconfig=...
    
    docker@minikube:~$ sudo crictl ps | head -5
    CONTAINER      IMAGE          CREATED         STATE     NAME
    a1b2c3d4e5f6   6ba9545b2183   16 minutes ago  Running   kube-apiserver
    ...

    crictl adalah CLI untuk bicara langsung ke container runtime lewat CRI. Dari sini terlihat jelas: apiserver, etcd, dan kawan-kawannya pada akhirnya juga cuma container yang dijalankan containerd, diasuh oleh kubelet. Ketik exit untuk keluar dari node.

    Terakhir, cek ke mana sebenarnya kubectl kamu mengirim perintah:

    kubectl cluster-info

    Output yang diharapkan:

    Kubernetes control plane is running at https://192.168.49.2:8443
    CoreDNS is running at https://192.168.49.2:8443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

    Catat alamat itu. 192.168.49.2 adalah IP node Minikube, dan 8443 adalah port kube-apiserver. Angka ini akan berguna sekali di bagian troubleshooting.

    Alur Lengkap Satu Perintah kubectl apply

    Sekarang gabungkan semuanya. Misal kamu menjalankan kubectl apply -f deployment.yaml yang meminta 1 replika nginx. Ini yang terjadi di balik layar, urut:

    1. kubectl membaca kubeconfig di ~/.kube/config, menemukan alamat apiserver (di Minikube: https://192.168.49.2:8443), lalu mengirim request HTTPS berisi manifest kamu.
    2. kube-apiserver memvalidasi request itu. Autentikasi lolos, format YAML benar, lalu objek Deployment dicatat ke etcd. Sampai sini kubectl sudah menjawab “deployment.apps/nginx created”, padahal belum ada container apa pun yang jalan.
    3. Controller-manager melihat ada Deployment baru yang butuh 1 replika tapi realitasnya 0. Dia membuat objek Pod lewat apiserver. Pod ini statusnya Pending, belum punya node.
    4. Scheduler melihat ada pod tanpa node. Dia menilai node yang tersedia, memilih satu, lalu mencatat keputusannya lewat apiserver.
    5. kubelet di node terpilih melihat ada pod yang ditugaskan untuknya. Dia menyuruh containerd menarik image nginx dan menjalankan container.
    6. kubelet melaporkan status balik ke apiserver, yang menyimpannya di etcd. Status pod berubah menjadi Running, dan itulah yang kamu lihat saat menjalankan kubectl get pods.

    Perhatikan polanya. Tidak ada satu pun komponen yang saling perintah langsung. Semua menulis dan membaca lewat apiserver, mirip karyawan kantor yang berkoordinasi lewat satu sistem tiket, bukan teriak-teriakan antar meja. Pola ini yang membuat Kubernetes tahan banting. Satu komponen mati, komponen lain tinggal melanjutkan dari catatan terakhir di etcd. Pemahaman alur ini juga yang kami pakai sehari-hari di Arrazy saat menelusuri masalah deployment di server klien, karena begitu tahu alurnya, kamu tahu persis komponen mana yang harus dicurigai saat sesuatu macet.

    Troubleshooting: Error yang Sering Dialami Pemula

    connection refused ke port 8443

    The connection to the server 192.168.49.2:8443 was refused - did you specify the right host or port?

    Cara membacanya: kubectl mencoba menghubungi kube-apiserver di IP node Minikube port 8443, tapi tidak ada yang menjawab. Artinya apiserver mati, dan penyebab paling umum adalah cluster Minikube-nya memang tidak sedang berjalan, misalnya setelah laptop restart. Solusinya:

    minikube status
    minikube start

    Kalau minikube status menunjukkan host Running tapi apiserver Stopped, jalankan minikube logs untuk melihat kenapa apiserver gagal naik. Sering kali penyebabnya resource, coba minikube start --memory=4096.

    connection refused ke localhost:8080

    The connection to the server localhost:8080 was refused - did you specify the right host or port?

    Beda dengan error sebelumnya, yang ini alamatnya localhost:8080. Itu alamat fallback kubectl saat dia tidak menemukan kubeconfig sama sekali. Biasanya karena file ~/.kube/config hilang, variabel KUBECONFIG menunjuk file yang salah, atau kamu menjalankan kubectl sebagai user lain (misalnya pakai sudo, yang home directory-nya beda). Cek dengan kubectl config view. Kalau kosong, jalankan minikube update-context atau minikube start supaya kubeconfig ditulis ulang.

    Pod kube-system CrashLoopBackOff setelah laptop sleep

    Kadang setelah laptop suspend lama, kubectl get pods -n kube-system menunjukkan etcd atau apiserver restart berkali-kali. Jam internal node melompat dan sertifikat internal sempat dianggap tidak valid. Solusi paling cepat untuk lingkungan belajar:

    minikube stop
    minikube start

    Kalau masih bermasalah juga, minikube delete lalu minikube start membuat cluster baru yang bersih. Di Minikube ini aman, karena tidak ada data production di dalamnya.

    Penutup dan Lanjutan Seri

    Sekarang kamu tahu isi kap mesin Kubernetes: control plane sebagai lantai manajemen dengan apiserver, etcd, scheduler, dan controller-manager, lalu worker node sebagai lantai produksi dengan kubelet, containerd, dan kube-proxy. Kamu juga sudah membuktikan sendiri komponennya lewat kubectl get pods -n kube-system dan minikube ssh, plus paham perjalanan lengkap satu kubectl apply.

    Bagian berikutnya, “Belajar Kubernetes #4: Perintah Dasar kubectl”, akan fokus melatih tanganmu dengan perintah kubectl yang paling sering dipakai sehari-hari. Artikelnya terbit menyusul, pantau daftarnya di halaman hub Belajar Kubernetes dari Nol.

    Referensi

  • Contoh Deskripsi Produk yang Menjual, Bukan Cuma Spesifikasi

    Contoh Deskripsi Produk yang Menjual, Bukan Cuma Spesifikasi

    Deskripsi produk yang menjual sebenarnya cuma perlu menjawab tiga pertanyaan pembeli, dan urutannya penting. Pertama, ini cocok buat saya tidak. Kedua, bedanya sama produk sebelah apa. Ketiga, apa yang saya dapat persisnya kalau checkout. Spesifikasi tetap perlu ditulis, tapi tempatnya setelah manfaat, bukan jadi pembuka. Masalahnya, kebanyakan deskripsi di marketplace justru dibalik: copas mentah spesifikasi dari supplier, lalu berharap pembeli menerjemahkan sendiri artinya.

    Rumus singkatnya begini. Buka dengan satu kalimat soal masalah atau keinginan pembeli. Lanjutkan dengan manfaat utama dalam bahasa sehari-hari. Baru setelah itu detail spesifikasi yang rapi, bagian yang meyakinkan seperti garansi dan isi paket, lalu tutup dengan ajakan ringan. Di artikel ini kita bedah rumusnya satu per satu, lengkap dengan tiga pasang contoh sebelum dan sesudah yang bisa langsung kamu tiru untuk produkmu sendiri.

    Kenapa Copas Spesifikasi Supplier Tidak Bikin Orang Beli

    Coba buka halaman produk kompetitormu di marketplace. Besar kemungkinan deskripsinya mirip semua: daftar bahan, ukuran, berat, warna. Wajar, karena sumbernya sama, yaitu file dari supplier atau distributor yang tinggal ditempel.

    Masalahnya, spesifikasi itu bahasa pabrik, bukan bahasa pembeli. Orang yang mau beli botol minum tidak sedang mencari “stainless steel 304 double wall vacuum”. Dia sedang mencari botol yang bikin airnya tetap dingin sampai sore. Dua hal itu sebenarnya sama, tapi hanya satu yang langsung nyambung di kepala pembeli.

    Ada satu lagi akibatnya. Kalau deskripsimu identik dengan puluhan toko lain, satu-satunya pembeda yang tersisa adalah harga. Kamu terseret perang harga bukan karena produkmu kalah, tapi karena tidak ada yang menjelaskan kenapa produkmu layak dipilih. Deskripsi yang ditulis sendiri adalah cara paling murah untuk keluar dari perang itu.

    Deskripsi seperti ini juga bukan cuma untuk marketplace. Format yang sama bisa kamu pakai di toko online sendiri sampai katalog produk digital yang dikirim lewat WhatsApp. Sekali menulis dengan benar, satu deskripsi bisa dipakai di banyak tempat.

    Rumus Lima Bagian yang Bisa Langsung Dipakai

    Supaya tidak bingung mulai dari mana, pakai urutan ini setiap kali menulis deskripsi:

    • Pembuka soal pembeli, bukan soal produk. Satu atau dua kalimat yang menyentuh masalah atau keinginan mereka. Contoh: “Sering beli lampu belajar tapi cahayanya malah bikin mata cepat lelah?”
    • Manfaat utama dalam bahasa sehari-hari. Jawab masalah tadi dengan apa yang produkmu lakukan untuk pembeli, bukan apa komponennya.
    • Detail spesifikasi yang rapi. Di sinilah data supplier dipakai. Susun jadi poin pendek supaya enak dibaca di layar HP.
    • Bagian yang meyakinkan. Isi paket, garansi, cara klaim, catatan penting seperti tabel ukuran. Bagian ini mencegah komplain sekaligus menghilangkan ragu.
    • Ajakan ringan. Satu kalimat penutup. Tidak perlu memaksa, cukup arahkan langkah berikutnya seperti cek varian atau tanya admin dulu.

    Urutan ini mengikuti cara orang membaca di HP. Dua baris pertama menentukan apakah dia lanjut scroll atau menutup halaman. Jadi dua baris pertama harus tentang dia, bukan tentang kode bahan produkmu.

    Tiga Contoh Sebelum vs Sesudah

    Teori tanpa contoh susah dipraktikkan. Berikut tiga produk berbeda dengan versi copas supplier dan versi hasil rumus di atas.

    Contoh 1: Botol Minum

    Sebelum:

    “Botol minum stainless steel 304. Kapasitas 1000 ml. Double wall vacuum. Tahan panas dan dingin. Berat 350 gram. Warna: hitam, putih, biru.”

    Sesudah:

    “Sering merasa air di botol sudah tidak dingin padahal baru jam sepuluh pagi? Botol ini menahan suhu sampai 12 jam. Es batu yang kamu masukkan sebelum berangkat masih ada sampai sore. Kapasitas 1 liter, sekali isi cukup untuk seharian kerja tanpa bolak-balik ke dispenser.

    Detailnya: bahan stainless steel 304 yang aman untuk minuman panas dan dingin, dinding ganda sehingga bagian luar tidak berembun dan tidak panas dipegang, berat 350 gram dan muat di kantong samping tas. Pilihan warna hitam, putih, dan biru.

    Isi paket: 1 botol, 1 sikat pembersih, dus pengaman. Garansi tukar unit 7 hari untuk cacat produksi, cukup kirim video saat buka paket. Cek varian warna di menu sebelum checkout ya.”

    Contoh 2: Kemeja

    Sebelum:

    “Kemeja pria lengan panjang. Bahan katun premium. Ukuran M, L, XL, XXL. Jahitan rapi. Kualitas terjamin. Tersedia berbagai warna.”

    Sesudah:

    “Butuh kemeja yang masih kelihatan rapi sampai jam pulang kerja? Bahan katun toyobo di kemeja ini jatuhnya halus, tidak menerawang, dan tetap adem walau dipakai seharian di ruangan tanpa AC.

    Potongannya regular fit, nyaman untuk badan standar sampai sedikit berisi. Ukuran M sampai XXL, ukur dulu pakai tabel di foto ketiga supaya tidak salah pilih. Jahitan rantai di setiap sambungan, jadi tidak gampang lepas walau sering dicuci mesin.

    Isi paket 1 kemeja sesuai varian yang dipilih. Salah ukuran bisa tukar dalam 3 hari selama label belum dilepas, ongkos kirim penukaran ditanggung pembeli. Kalau ragu antara dua ukuran, chat admin dengan berat dan tinggi badan, nanti dibantu pilihkan.”

    Contoh 3: Lampu Belajar

    Sebelum:

    “Lampu belajar LED 8 watt. 3 mode cahaya. Baterai 1200 mAh rechargeable. Leher fleksibel. Input USB. Desain minimalis.”

    Sesudah:

    “Anak sering mengeluh matanya perih saat belajar malam? Cahaya lampu ini rata dan tidak berkedip, jadi nyaman dipakai membaca dua jam nonstop. Ada 3 mode: putih terang untuk belajar, putih hangat untuk membaca santai, dan redup untuk lampu tidur.

    Lehernya bisa ditekuk ke segala arah, arah cahaya gampang diatur tanpa memindah lampu. Baterai 1200 mAh tahan sekitar 4 jam di mode terang, jadi tetap nyala saat listrik padam. Mengisi daya lewat kabel USB biasa, bisa pakai charger HP yang sudah ada di rumah.

    Isi paket: 1 lampu, 1 kabel USB, kartu garansi. Garansi 6 bulan untuk mesin, klaim lewat chat toko dengan menyertakan nomor pesanan. Tersedia warna putih dan mint, pilih di varian.”

    Perhatikan polanya. Versi sesudah tidak membuang satu pun spesifikasi. Semua angka tetap ada, hanya dipindah ke belakang dan diberi arti. Itu inti dari deskripsi yang menjual: bukan menulis lebih banyak, tapi menyusun ulang dengan urutan yang benar.

    Cara Mengubah Spesifikasi Jadi Bahasa Pembeli

    Bagian paling sulit biasanya menerjemahkan angka teknis jadi manfaat. Triknya sederhana. Ambil satu spesifikasi, lalu tanya ke diri sendiri: terus kenapa? Ulangi sampai jawabannya terasa seperti sesuatu yang dialami pembeli.

    • Kapasitas 1000 ml. Terus kenapa? Sekali isi cukup untuk seharian kerja.
    • Katun 24s. Terus kenapa? Adem dipakai seharian, tidak gerah walau di luar ruangan.
    • Baterai 1200 mAh. Terus kenapa? Tetap nyala 4 jam saat listrik padam.
    • Berat 350 gram. Terus kenapa? Ringan dibawa, muat di kantong samping tas.
    • Jahitan rantai. Terus kenapa? Tidak gampang lepas walau sering dicuci mesin.

    Kalau kamu sendiri tidak tahu arti sebuah spesifikasi, tanyakan ke supplier atau coba produknya langsung. Deskripsi yang ditulis dari pengalaman memakai selalu terasa beda dengan yang ditulis dari brosur. Pembeli bisa merasakan perbedaannya, dan pertanyaan di kolom diskusi juga jauh berkurang.

    Kata yang Memperkuat vs Kata Kosong

    Ada satu kelompok kata yang kelihatannya menjual padahal tidak berbunyi apa-apa: berkualitas tinggi, terbaik, premium, murah meriah, dijamin puas. Semua toko memakainya, jadi tidak ada yang percaya lagi. Kata-kata ini kosong karena tidak bisa dibuktikan dan tidak bisa dibayangkan.

    Gantinya adalah detail konkret. Bandingkan dua versi ini:

    • “Jahitan berkualitas tinggi” jadi “jahitan rantai di setiap sambungan, tidak gampang lepas walau dicuci mesin”.
    • “Dijamin puas” jadi “garansi 7 hari uang kembali kalau ada cacat, cukup kirim video saat buka paket”.
    • “Bahan premium” jadi “katun toyobo, halus dan tidak menerawang”.
    • “Awet dan tahan lama” jadi “engsel sudah diuji buka tutup 10.000 kali oleh pabrik”.

    Aturannya gampang diingat. Kalau sebuah klaim bisa ditempel ke produk apa pun tanpa terasa aneh, klaim itu kosong. Kalau klaim hanya masuk akal untuk produkmu, berarti sudah konkret. Dan jangan menulis bukti yang tidak benar hanya supaya terdengar meyakinkan. Satu komplain karena janji palsu lebih mahal daripada seratus deskripsi bagus.

    Kesalahan Umum yang Bikin Pembeli Batal

    Beberapa kebiasaan ini sering merusak deskripsi yang sebenarnya sudah bagus isinya:

    • Paragraf panjang tanpa jeda. Di layar HP, lima kalimat yang digabung jadi satu blok terlihat seperti tembok teks. Pecah jadi paragraf pendek dua sampai tiga kalimat, dan pakai poin untuk daftar spesifikasi.
    • Semua huruf kapital. Niatnya menonjol, hasilnya terkesan seperti orang berteriak dan susah dibaca. Kalau mau menekankan sesuatu, cukup taruh di baris sendiri.
    • Janji berlebihan. “Anti pecah seumur hidup” untuk botol plastik biasa akan berakhir di kolom ulasan bintang satu. Tulis batas produk apa adanya, misalnya “tahan cipratan air, bukan untuk direndam”.
    • Tidak menulis isi paket. Pembeli yang mengira dapat charger padahal hanya unit saja akan komplain, dan komplainnya sah. Selalu tulis isi paket persis, termasuk yang tidak disertakan.

    Kesalahan-kesalahan ini kelihatan sepele, tapi semuanya berujung ke hal yang sama: pembeli ragu, lalu pindah ke toko sebelah yang deskripsinya lebih jelas.

    Deskripsi dan Kata yang Diketik Pembeli

    Satu hal terakhir yang sering dilupakan: deskripsi juga membantu produkmu ditemukan. Mesin pencari marketplace membaca judul dan deskripsi untuk mencocokkan produk dengan kata yang diketik pembeli di kolom cari.

    Kuncinya, pakai nama umum produk, bukan nama kreatif versi tokomu. Pembeli mengetik “botol minum 1 liter” atau “lampu belajar anak”, bukan “Aqua Buddy Series” yang cuma kamu yang tahu. Nama kreatif boleh tetap ada, tapi dampingi dengan sebutan yang benar-benar dicari orang, termasuk sebutan sehari-harinya. Misalnya tumbler dan botol minum, atau kemeja dan hem.

    Yang tidak boleh: menumpuk kata kunci sampai deskripsi tidak enak dibaca. Menulis “botol minum botol minum viral botol minum murah botol minum 1 liter” di bagian bawah deskripsi tidak menaikkan posisi produk, malah bikin toko terlihat asal-asalan. Cukup pastikan nama umum produk muncul secara natural di judul dan satu dua kali di deskripsi. Cara kerja pencarian di marketplace dan bedanya dengan mengandalkan toko online sendiri pernah kami bahas lebih dalam di artikel jualan di marketplace vs website sendiri.

    Sekarang giliranmu. Ambil satu produk terlarismu, buka deskripsinya, lalu susun ulang pakai rumus lima bagian di atas: pembuka soal pembeli, manfaat, spesifikasi, bagian yang meyakinkan, ajakan ringan. Satu produk dulu saja, tidak perlu langsung semua. Kalau dalam beberapa minggu pertanyaan di kolom diskusi berkurang dan konversinya membaik, kamu sudah tahu resep untuk sisa katalogmu.

  • Cara Bikin Promo yang Tidak Bikin Rugi: Hitung Sebelum Diskon

    Cara Bikin Promo yang Tidak Bikin Rugi: Hitung Sebelum Diskon

    Promo yang aman itu dihitung dari margin, bukan dari omzet. Ini jawaban singkatnya, dan di sinilah kebanyakan pemilik usaha keliru. Ambil contoh toko kue yang jual brownies Rp 50.000 per kotak. Modal bahan, kemasan, dan gas sekitar Rp 35.000. Untung bersihnya Rp 15.000 per kotak, alias margin 30 persen. Lalu toko sebelah pasang diskon 20 persen, dan kamu ikut-ikutan. Harga turun jadi Rp 40.000. Potongan Rp 10.000 itu terlihat kecil, cuma seperlima dari harga. Masalahnya, seluruh potongan itu diambil dari untung, bukan dari modal. Untungmu yang tadinya Rp 15.000 sekarang tinggal Rp 5.000. Terpangkas dua pertiga, bukan seperlima.

    Itulah kenapa banyak usaha yang ramai saat promo tapi dompetnya makin tipis. Diskon dibaca sebagai potongan omzet, padahal yang kena telak adalah margin. Di artikel ini kita bedah cara menghitung promo sebelum dipasang, jenis promo mana yang mahal dan mana yang lebih aman, sampai cara mengukur hasilnya. Semua pakai contoh toko kue yang sama supaya angkanya gampang diikuti.

    Hitung titik impas promo sebelum poster diskon naik

    Setiap promo punya satu pertanyaan kunci: berapa tambahan penjualan yang dibutuhkan supaya total untung minimal sama dengan sebelum promo. Kalau targetnya tidak masuk akal, promonya jangan jalan.

    Kembali ke toko kue tadi. Anggap dalam sebulan biasa terjual 100 kotak brownies.

    • Untung normal: 100 kotak x Rp 15.000 = Rp 1.500.000 per bulan
    • Untung saat diskon 20 persen: Rp 5.000 per kotak
    • Penjualan yang dibutuhkan supaya untung tetap Rp 1.500.000: Rp 1.500.000 dibagi Rp 5.000 = 300 kotak

    Artinya penjualan harus naik tiga kali lipat hanya supaya untungmu tidak turun. Bukan naik 20 persen, bukan dua kali lipat, tapi tiga kali lipat. Kalau dapur dan tenagamu sanggup produksi 300 kotak dan pasarnya memang ada, silakan. Kalau tidak, diskon 20 persen itu hampir pasti bikin untung bulanan turun meski toko terlihat ramai.

    Rumus cepatnya sederhana. Bagi margin normal dengan margin saat promo, hasilnya adalah berapa kali lipat penjualan yang harus kamu capai. Coba versi diskon 10 persen. Potongan Rp 5.000 menyisakan untung Rp 10.000 per kotak. Rp 15.000 dibagi Rp 10.000 sama dengan 1,5. Penjualan cukup naik 50 persen, dari 100 ke 150 kotak. Masih berat, tapi jauh lebih realistis daripada tiga kali lipat.

    Semua hitungan ini hanya bisa jalan kalau kamu tahu margin tiap produk. Kalau selama ini harga jual cuma ikut pasaran dan margin tidak pernah dihitung, bereskan dulu bagian itu. Prinsipnya sama dengan yang kami tulis di artikel cara menentukan harga jasa tanpa ikut perang harga: harga dan promo yang sehat selalu berangkat dari struktur biaya, bukan dari harga kompetitor.

    Bedah jenis promo dari sisi hitungan

    Diskon persen bukan satu-satunya bentuk promo. Justru ia bentuk yang paling mahal. Mari bandingkan beberapa jenis promo dengan angka toko kue yang sama.

    Diskon persen

    Sudah kita hitung di atas. Diskon 20 persen di produk bermargin 30 persen memangkas untung dua pertiga. Makin besar persennya, makin cepat margin habis. Diskon 30 persen di contoh kita artinya jual rugi, karena harga turun ke Rp 35.000, persis di angka modal.

    Potongan nominal

    Potongan Rp 5.000 untuk setiap kotak lebih mudah dikendalikan. Kamu tahu persis biayanya, tidak peduli produk mana yang dibeli. Untung tersisa Rp 10.000 per kotak. Bonusnya, tulisan potongan Rp 5.000 sering terasa lebih besar di mata pembeli daripada diskon 10 persen, padahal biayanya sama.

    Beli 2 gratis 1

    Ini sering lebih aman daripada diskon persen, dengan satu syarat: barang gratisnya dihitung dari HPP, bukan dari harga jual. Tapi tetap harus dicek. Beli 2 gratis 1 untuk brownies yang sama justru rugi di contoh kita. Pembeli bayar Rp 100.000 untuk 3 kotak, modalmu 3 x Rp 35.000 = Rp 105.000. Tekor Rp 5.000 tiap transaksi.

    Beda cerita kalau yang gratis adalah produk lain dengan modal rendah. Misal beli 2 kotak brownies gratis 1 toples kecil kue kering. Kue kering itu dijual Rp 20.000 tapi modalnya cuma Rp 8.000. Pembeli merasa dapat hadiah senilai Rp 20.000. Hitunganmu: pemasukan Rp 100.000, modal Rp 70.000 ditambah Rp 8.000, untung Rp 22.000. Turun sedikit dari Rp 30.000, tapi kamu memaksa transaksi minimal 2 kotak dan memberi kesan hadiah besar dengan biaya kecil.

    Bundling produk margin tinggi dan margin rendah

    Gandengkan brownies dengan kue kering tadi dalam satu paket. Harga terpisah Rp 80.000, harga paket Rp 70.000. Pembeli hemat Rp 10.000. Modal paket Rp 47.000, untungmu Rp 23.000. Margin paket masih 33 persen, lebih sehat daripada mendiskon brownies sendirian, dan produk margin tinggi ikut terangkat penjualannya.

    Gratis ongkir dengan minimum belanja

    Ongkir sekitar Rp 10.000 ditanggung toko, tapi hanya untuk belanja minimal Rp 100.000. Pembeli yang tadinya beli 1 kotak akan menggenapkan jadi 2. Untung 2 kotak Rp 30.000 dikurangi ongkir Rp 10.000 masih menyisakan Rp 20.000. Nilai transaksi rata-rata naik, biaya promonya jelas batasnya.

    Cashback atau poin untuk kunjungan berikutnya

    Cashback Rp 10.000 untuk pembelian berikutnya beda sifatnya dengan diskon langsung. Biayanya baru keluar kalau pelanggan benar-benar balik, dan saat balik dia belanja lagi. Kamu menggeser biaya promo dari sekadar memotong harga hari ini menjadi biaya menjaga pelanggan. Yang tidak pernah balik, cashbacknya hangus dan tidak jadi biaya.

    Tujuan promo menentukan desainnya

    Promo yang bagus selalu punya satu tujuan yang jelas, dan tujuannya menentukan bentuk serta batas hitungannya.

    • Menghabiskan stok lama. Kue kering sisa musim lebaran boleh dijual dengan margin tipis, bahkan sedikit di bawah modal kalau alternatifnya terbuang. Uang yang kembali lebih baik daripada stok yang jadi sampah.
    • Menarik pelanggan baru. Diskon besar untuk pembeli pertama itu sah, asal dihitung sebagai biaya akuisisi. Rugi Rp 5.000 di transaksi pertama tidak masalah kalau ada rencana jelas membuat dia beli kedua dan ketiga kalinya, misalnya lewat cashback atau kontak WhatsApp. Tanpa rencana repeat order, kamu cuma menyubsidi orang lewat.
    • Menaikkan transaksi rata-rata. Pakai minimum belanja, seperti gratis ongkir minimal Rp 100.000 atau gratis kue kering untuk pembelian 2 kotak. Promonya hanya aktif kalau pembeli belanja lebih banyak dari biasanya.
    • Meramaikan jam sepi. Potongan khusus jam 2 sampai 5 sore untuk toko yang sepi di jam itu. Biaya produksinya toh sudah jalan, oven tetap panas. Penjualan tambahan di jam mati hampir seluruhnya nilai tambah, asal promonya tidak menggeser pembeli jam ramai ke jam sepi.

    Kalau kamu tidak bisa menjawab promo ini tujuannya apa dalam satu kalimat, itu tanda promonya cuma ikut-ikutan.

    Jebakan promo yang jarang disadari

    Ada tiga pola yang pelan-pelan menggerus usaha, dan ketiganya sering terlihat seperti strategi.

    Promo terus-menerus melatih pelanggan menunggu diskon. Kalau brownies didiskon tiap akhir pekan, pelanggan belajar untuk tidak pernah beli di hari biasa. Harga normal Rp 50.000 berubah status jadi harga pajangan yang tidak laku. Ujungnya kamu terjebak: berhenti promo penjualan anjlok, lanjut promo margin tipis selamanya.

    Diskon di produk yang orang tetap beli tanpa diskon. Brownies best seller yang tiap hari habis tidak butuh diskon. Memotong harga di produk seperti ini artinya membuang untung untuk penjualan yang toh akan terjadi. Simpan amunisi promo untuk produk yang butuh didorong atau untuk pembeli yang butuh alasan mencoba.

    Promo tanpa batas waktu. Diskon yang tidak jelas kapan berakhirnya kehilangan dua hal sekaligus: pembeli tidak merasa perlu buru-buru, dan kamu tidak punya titik untuk berhenti dan mengevaluasi. Selalu pasang tanggal mulai dan tanggal selesai.

    Ukur hasil promo dengan jujur

    Setelah promo selesai, godaan terbesar adalah menilai dari keramaian. Omzet naik, toko ramai, foto struk berjejer. Padahal ukurannya harus untung total, bukan omzet.

    Caranya sederhana. Bandingkan untung bersih periode promo dengan periode normal yang sepadan. Misal promo diskon 10 persen jalan dua minggu dan terjual 90 kotak. Untungnya 90 x Rp 10.000 = Rp 900.000. Dua minggu normal biasanya laku 50 kotak, untung 50 x Rp 15.000 = Rp 750.000. Promo ini beneran nambah untung Rp 150.000, bukan cuma ramai. Kalau hasilnya terbalik, catat sebagai pelajaran dan jangan diulang dengan format yang sama.

    Satu ukuran lagi yang sering dilupakan: dari pembeli baru yang datang karena promo, berapa yang balik lagi bulan berikutnya dengan harga normal. Angka ini yang membedakan promo sebagai investasi atau sekadar bagi-bagi subsidi. Catat nomor atau nama pelanggan baru saat promo, lalu cek lagi sebulan kemudian.

    Perbandingan seperti ini hanya mungkin kalau angka penjualan dan biayanya tercatat. Tidak perlu software akuntansi mahal. Format sederhana yang kami bahas di artikel laporan keuangan sederhana untuk usaha kecil sudah cukup untuk melihat untung per periode dan membandingkannya.

    Promo yang sehat dimulai dari catatan yang rapi

    Semua hitungan di artikel ini sebenarnya cuma butuh tiga angka: harga jual, modal per produk, dan jumlah terjual per periode. Toko kue di contoh kita bisa memutuskan diskon 20 persen itu berbahaya karena dia tahu marginnya 30 persen dan penjualan normalnya 100 kotak. Tanpa dua angka itu, keputusan promo cuma tebak-tebakan.

    Jadi sebelum ikut perang diskon berikutnya, kerjakan urutan ini. Hitung margin tiap produk. Tentukan satu tujuan promo. Hitung titik impasnya dan cek apakah targetnya masuk akal. Pasang batas waktu. Setelah selesai, bandingkan untung, bukan omzet. Promo yang lolos urutan ini boleh jalan lagi, yang tidak lolos cukup jadi pelajaran murah. Dengan cara ini promo berubah dari tebakan yang bikin deg-degan jadi alat yang bisa diandalkan untuk membesarkan usaha.

  • Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Belajar Golang dari Nol #19: Worker dan Job Terjadwal di Go

    Di Belajar Golang dari Nol #18 kita sudah merapikan logging dan error handling di API produk. Sekarang API kita sudah bisa cerita sendiri lewat log ketika ada yang salah. Kalau kamu baru bergabung di tengah jalan, cek dulu daftar lengkap seri Belajar Golang supaya urutannya tidak lompat.

    Kali ini kita bahas dua pola yang hampir pasti kamu butuhkan begitu API dipakai orang sungguhan: worker dan job terjadwal.

    Dua jenis pekerjaan yang tidak cocok di handler

    Coba perhatikan dua situasi ini.

    Pertama, ada pekerjaan yang tidak boleh membuat request menunggu. Contohnya kirim email notifikasi atau generate laporan PDF. Kirim email bisa makan dua sampai lima detik. Kalau dikerjakan di dalam handler, user harus menatap loading selama itu. Padahal user tidak peduli emailnya sudah terkirim atau belum. Dia cuma mau tahu transaksinya berhasil.

    Kedua, ada pekerjaan yang harus jalan rutin tanpa disuruh siapa pun. Rekap penjualan harian, bersih-bersih data lama, cek stok yang menipis. Tidak ada request yang memicu pekerjaan ini. Dia harus jalan sendiri sesuai jadwal.

    Untuk masalah pertama kita pakai worker. Untuk masalah kedua kita pakai job terjadwal. Dua-duanya bisa dibangun dengan bahan yang sudah kamu pelajari di seri ini: goroutine, channel, dan context.

    Worker pool: channel sebagai antrian

    Di bagian 10 kamu sudah kenal goroutine dan channel. Waktu itu contohnya masih abstrak. Sekarang kita pakai untuk kasus nyata.

    Idenya sederhana. Channel jadi antrian pekerjaan. Beberapa goroutine jadi pekerja yang mengambil dari antrian itu. Handler cukup memasukkan pekerjaan ke channel, lalu lanjut membalas request.

    Pertama kita definisikan bentuk pekerjaannya sebagai struct.

    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }

    Lalu kita buat channel antrian dan beberapa worker.

    jobs := make(chan EmailJob, 100)
    
    var wg sync.WaitGroup
    for i := 1; i <= 3; i++ {
    	wg.Add(1)
    	go func(id int) {
    		defer wg.Done()
    		for job := range jobs {
    			slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    			kirimEmail(job)
    		}
    	}(i)
    }

    Perhatikan beberapa hal. Channel diberi buffer 100 supaya handler tidak ikut menunggu saat semua worker sedang sibuk. Tiga goroutine worker melakukan range di channel yang sama. Go otomatis membagi job ke worker yang sedang kosong. Kamu tidak perlu mengatur pembagiannya sendiri.

    Bagian for job := range jobs juga yang membuat shutdown jadi rapi. Loop ini baru berhenti ketika channel ditutup dan isinya habis. Jadi urutan mematikannya jelas: panggil close(jobs), worker menghabiskan sisa antrian, lalu wg.Wait() menunggu semuanya selesai. Tidak ada job yang dibuang di tengah jalan.

    close(jobs)
    wg.Wait()
    slog.Info("semua worker selesai")

    Contoh nyata: notifikasi setelah transaksi

    Sekarang kita sambungkan ke API. Misalkan setiap ada produk baru dibuat, admin harus dapat email. Tanpa worker, handlernya kira-kira begini.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	kirimEmail(EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"})
    	w.WriteHeader(http.StatusCreated)
    })

    Kalau kirimEmail makan dua detik, request juga makan dua detik. Coba cek dengan curl -w "%{time_total}", hasilnya sekitar 2,01 detik.

    Dengan worker, handler cukup melempar job ke channel lalu langsung membalas 201.

    mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    	// ... simpan produk ...
    	jobs <- EmailJob{Tujuan: "admin@toko.com", Isi: "Produk baru masuk"}
    	w.WriteHeader(http.StatusCreated)
    })

    Sekarang curl yang sama selesai dalam hitungan milidetik. Email tetap terkirim, tapi di belakang layar. User tidak perlu ikut menunggu pekerjaan yang bukan urusannya.

    Job terjadwal dengan time.Ticker

    Pola kedua: pekerjaan yang jalan rutin. Standard library sudah menyediakan time.Ticker, yaitu channel yang berdenyut setiap interval tertentu. Gabungkan dengan goroutine dan select, jadilah scheduler sederhana.

    Ingat pelajaran context di bagian 17. Setiap goroutine yang hidup lama wajib punya cara berhenti. Di sinilah ctx.Done() masuk.

    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }

    Jalankan sebagai goroutine: go jalankanRekap(ctx, time.Minute). Setiap menit dia menghitung jumlah produk dan mencatatnya lewat slog. Ketika context dibatalkan, select memilih cabang ctx.Done() dan goroutine keluar dengan rapi. Jangan lupa defer ticker.Stop() supaya resource ticker dilepas.

    Jalan pada jam tertentu

    Ticker cocok untuk interval. Tapi bagaimana kalau job harus jalan tiap tengah malam? Polanya sedikit berbeda: hitung durasi sampai jadwal berikutnya dengan time.Until, tidur selama itu, kerjakan, lalu ulangi.

    func jalankanTengahMalam(ctx context.Context, tugas func()) {
    	for {
    		sekarang := time.Now()
    		besok := time.Date(sekarang.Year(), sekarang.Month(), sekarang.Day()+1,
    			0, 0, 0, 0, sekarang.Location())
    		timer := time.NewTimer(time.Until(besok))
    
    		select {
    		case <-ctx.Done():
    			timer.Stop()
    			return
    		case <-timer.C:
    			tugas()
    		}
    	}
    }

    time.Date dengan hari ditambah satu dan jam nol menghasilkan tengah malam berikutnya. time.Until menghitung sisa waktunya. Setelah tugas selesai, loop mengulang dan menghitung tengah malam berikutnya lagi.

    Pola sederhana ini sering sudah cukup. Banyak orang buru-buru pasang library cron padahal jadwalnya cuma satu atau dua. Kalau kebutuhanmu masih bisa dijelaskan dalam satu kalimat, misalnya tiap jam atau tiap tengah malam, standard library sudah memadai. Lebih sedikit dependency, lebih mudah dipahami orang yang membaca kodenya nanti.

    Kapan layak pakai robfig/cron

    Kalau jadwalnya mulai ramai dan rumit, misalnya tiap hari kerja jam 8 pagi, tiap tanggal 1, plus tiap 15 menit di jam kantor, barulah library seperti robfig/cron layak dipakai. Dia menerima ekspresi cron yang sama dengan crontab di Linux.

    c := cron.New()
    c.AddFunc("0 0 * * *", rekapHarian)     // tiap tengah malam
    c.AddFunc("*/15 8-17 * * 1-5", cekStok) // tiap 15 menit, jam kerja
    c.Start()

    Install dengan go get github.com/robfig/cron/v3. Aturannya sama seperti dependency lain di seri ini: pakai ketika kebutuhannya nyata, bukan karena terlihat keren.

    Jebakan yang harus kamu tahu dari awal

    Sebelum kamu pakai pola ini di production, ada tiga jebakan yang jujur harus dibahas.

    Pertama, job hilang saat server restart. Antrian kita hidup di memori. Kalau server mati atau di-deploy ulang, semua job yang belum dikerjakan ikut lenyap. Untuk email notifikasi biasa, ini sering masih bisa diterima. Untuk job yang tidak boleh hilang, misalnya pemrosesan pembayaran, kamu butuh durable queue seperti Redis atau RabbitMQ. Job disimpan di luar proses aplikasi, jadi selamat dari restart. Kita tidak bedah itu sekarang, cukup tahu dulu batasnya di mana.

    Kedua, dua instance server berarti job ganda. Kalau nanti aplikasimu jalan di dua server demi ketersediaan, job terjadwal ikut jalan di dua-duanya. Rekap harian terkirim dua kali. Solusinya macam-macam, dari lock di database sampai memisahkan scheduler jadi service sendiri. Yang penting sekarang: sadari bahwa pola ini mengasumsikan satu instance.

    Ketiga, panic di worker mematikan goroutine diam-diam. Ini yang paling licik. Kalau kode di dalam worker panic, goroutine itu mati. Kamu punya tiga worker, satu panic, sisa dua. Panic lagi, sisa satu. Lama-lama antrian menumpuk dan tidak ada yang tahu kenapa. Ingat pelajaran bagian 18: panic yang tidak ditangani harus dicatat. Pasang recover di fungsi kerjanya.

    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan)
    }

    Dengan begini, job yang bermasalah tercatat di log dan workernya tetap hidup untuk job berikutnya.

    Satu catatan lagi di level server. Untuk job yang benar-benar terpisah dari API, misalnya script backup, kamu tidak harus menjadwalkannya dari dalam Go. systemd yang kita pakai di bagian 15 untuk menjalankan aplikasi juga punya fitur timer. Kamu tulis unit timer, systemd yang menjalankan program Go kecil sesuai jadwal. Kelebihannya, jadwal terlihat oleh admin server lewat systemctl list-timers, bukan tersembunyi di dalam kode.

    Latihan: API produk dengan worker dan rekap terjadwal

    Sekarang kita rangkai semuanya jadi satu program utuh. API produk menerima POST, melempar job email ke worker, dan job rekap jalan tiap 30 detik. Shutdown ditangani lewat signal.NotifyContext, cara standar menangkap Ctrl+C atau sinyal stop dari systemd.

    package main
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log/slog"
    	"net/http"
    	"os"
    	"os/signal"
    	"sync"
    	"syscall"
    	"time"
    )
    
    type Produk struct {
    	ID    int    `json:"id"`
    	Nama  string `json:"nama"`
    	Harga int    `json:"harga"`
    }
    
    type EmailJob struct {
    	Tujuan string
    	Isi    string
    }
    
    var (
    	mu     sync.Mutex
    	produk = []Produk{{ID: 1, Nama: "Kopi Arabika", Harga: 85000}}
    )
    
    func kirimEmail(job EmailJob) {
    	defer func() {
    		if r := recover(); r != nil {
    			slog.Error("worker panic", "tujuan", job.Tujuan, "error", r)
    		}
    	}()
    	time.Sleep(2 * time.Second) // simulasi kirim email
    	slog.Info("email terkirim", "tujuan", job.Tujuan, "isi", job.Isi)
    }
    
    func jalankanRekap(ctx context.Context, interval time.Duration) {
    	ticker := time.NewTicker(interval)
    	defer ticker.Stop()
    	for {
    		select {
    		case <-ctx.Done():
    			slog.Info("job rekap berhenti")
    			return
    		case <-ticker.C:
    			mu.Lock()
    			jumlah := len(produk)
    			mu.Unlock()
    			slog.Info("rekap produk", "jumlah", jumlah)
    		}
    	}
    }
    
    func main() {
    	slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
    
    	ctx, stop := signal.NotifyContext(context.Background(),
    		os.Interrupt, syscall.SIGTERM)
    	defer stop()
    
    	jobs := make(chan EmailJob, 100)
    	var wg sync.WaitGroup
    	for i := 1; i <= 3; i++ {
    		wg.Add(1)
    		go func(id int) {
    			defer wg.Done()
    			for job := range jobs {
    				slog.Info("worker ambil job", "worker", id, "tujuan", job.Tujuan)
    				kirimEmail(job)
    			}
    		}(i)
    	}
    
    	go jalankanRekap(ctx, 30*time.Second)
    
    	mux := http.NewServeMux()
    	mux.HandleFunc("POST /produk", func(w http.ResponseWriter, r *http.Request) {
    		var p Produk
    		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
    			http.Error(w, `{"error":"body tidak valid"}`, http.StatusBadRequest)
    			return
    		}
    		mu.Lock()
    		p.ID = len(produk) + 1
    		produk = append(produk, p)
    		mu.Unlock()
    
    		jobs <- EmailJob{
    			Tujuan: "admin@toko.com",
    			Isi:    fmt.Sprintf("Produk baru: %s", p.Nama),
    		}
    
    		w.Header().Set("Content-Type", "application/json")
    		w.WriteHeader(http.StatusCreated)
    		json.NewEncoder(w).Encode(p)
    	})
    
    	server := &http.Server{Addr: ":8080", Handler: mux}
    	go func() {
    		slog.Info("server jalan", "addr", ":8080")
    		if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
    			slog.Error("server gagal", "error", err)
    			os.Exit(1)
    		}
    	}()
    
    	<-ctx.Done()
    	slog.Info("sinyal berhenti diterima")
    
    	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    	defer cancel()
    	server.Shutdown(shutdownCtx)
    
    	close(jobs)
    	wg.Wait()
    	slog.Info("semua worker selesai, aplikasi berhenti")
    }

    Jalankan dengan go run main.go, lalu kirim beberapa request.

    curl -X POST localhost:8080/produk \
      -d '{"nama":"Teh Hijau","harga":45000}'

    Perhatikan lognya. Respons 201 datang seketika, log worker ambil job muncul, dua detik kemudian email terkirim. Tiap 30 detik muncul rekap produk dengan jumlah terbaru. Dua pola jalan berdampingan tanpa saling ganggu.

    Sekarang tekan Ctrl+C. Urutan lognya bercerita sendiri: sinyal diterima, server berhenti menerima request, job rekap berhenti, worker menghabiskan sisa antrian, baru aplikasi benar-benar mati. Itulah shutdown yang rapi. Tidak ada email yang setengah terkirim, tidak ada goroutine yang dipaksa mati.

    Selanjutnya

    API kita sekarang bisa mengerjakan tugas di belakang layar dan punya jadwal sendiri. Di bagian 20 kita bahas kebutuhan yang hampir pasti muncul di aplikasi nyata: Upload dan Menyimpan File di API Go.

    Kalau kamu sedang membangun sistem dengan kebutuhan background job dan penjadwalan seperti ini untuk bisnismu, tim kami di Arrazy juga mengerjakan pengembangan sistem aplikasi custom dari perancangan sampai deploy.

  • Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O Notation: Cara Mengukur Kompleksitas Algoritma

    Big O notation adalah cara menulis batas atas pertumbuhan jumlah operasi sebuah algoritma ketika ukuran inputnya membesar. Jadi O(n) tidak berarti “algoritma ini butuh n detik”. Artinya, kalau inputnya dikali dua, jumlah operasinya paling banyak ikut naik kira-kira dua kali. Big O mengukur pola pertumbuhan, bukan waktu eksekusi nyata.

    Ini bagian ketiga dari seri Belajar Struktur Data dari Nol. Di Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa kita sudah merasakan sendiri bedanya program cepat dan lambat. Sekarang kita kasih nama resmi untuk pola-pola itu, lalu buktikan lewat benchmark Go dengan input 1.000 versus 1.000.000 elemen. Kode di sini dites dengan Go 1.24, tapi jalan di Go 1.22 ke atas.

    Enam Kelas Kompleksitas yang Paling Sering Muncul

    Hampir semua algoritma di seri ini masuk ke salah satu dari enam kelas berikut, urut dari yang paling lambat tumbuh:

    • O(1), konstan. Jumlah operasi tidak peduli ukuran input. Contoh: akses elemen slice lewat indeks.
    • O(log n), logaritmik. Setiap langkah membuang setengah data. Contoh: binary search.
    • O(n), linear. Sentuh setiap elemen sekali. Contoh: menjumlahkan isi slice.
    • O(n log n), linearitmik. Kelas terbaik untuk sorting berbasis perbandingan. Contoh: merge sort, quick sort rata-rata.
    • O(n²), kuadratik. Setiap elemen dibandingkan dengan setiap elemen lain. Contoh: nested loop, bubble sort.
    • O(2ⁿ), eksponensial. Jumlah operasi berlipat dua setiap input bertambah satu. Contoh: rekursi Fibonacci naif.

    Kalau digambar, kurvanya kira-kira begini. Semakin curam, semakin cepat algoritma jadi tidak terpakai untuk data besar:

    operasi
    |            O(2^n)  O(n^2)
    |              |      /     O(n log n)
    |              |     /     /      O(n)
    |             /    /     /    ___/
    |           _/  _/    __/____/     O(log n)
    |      ____/__/______/------------ O(1)
    +---------------------------------> n
    

    Angka membuat bedanya lebih terasa. Perkiraan jumlah operasi untuk tiga ukuran input:

    Kelas n = 10 n = 1.000 n = 1.000.000
    O(1) 1 1 1
    O(log n) 3 10 20
    O(n) 10 1.000 1.000.000
    O(n log n) 33 10.000 20.000.000
    O(n²) 100 1.000.000 1.000.000.000.000
    O(2ⁿ) 1.024 lebih dari jumlah atom di alam semesta tidak masuk akal

    Perhatikan baris O(n²). Di n = 1.000 masih sejuta operasi, selesai dalam hitungan milidetik. Di n = 1.000.000 sudah satu triliun. Itulah kenapa program yang lancar saat demo bisa mati saat data produksi masuk.

    Praktik: Satu Fungsi Go untuk Tiap Kelas Kompleksitas

    Buat folder baru, lalu simpan kode berikut sebagai main.go:

    package main
    
    import "fmt"
    
    // O(1): berapa pun panjang slice, cuma satu operasi
    func Pertama(data []int) int {
    	return data[0]
    }
    
    // O(log n): setiap iterasi membuang setengah data (data harus terurut)
    func BinarySearch(data []int, target int) int {
    	low, high := 0, len(data)-1
    	for low <= high {
    		mid := (low + high) / 2
    		if data[mid] == target {
    			return mid
    		}
    		if data[mid] < target {
    			low = mid + 1
    		} else {
    			high = mid - 1
    		}
    	}
    	return -1
    }
    
    // O(n): sentuh setiap elemen tepat sekali
    func Jumlah(data []int) int {
    	total := 0
    	for _, v := range data {
    		total += v
    	}
    	return total
    }
    
    // O(n^2): nested loop, setiap elemen dibandingkan dengan elemen lain
    func HitungPasanganNol(data []int) int {
    	count := 0
    	for i := 0; i < len(data); i++ {
    		for j := i + 1; j < len(data); j++ {
    			if data[i]+data[j] == 0 {
    				count++
    			}
    		}
    	}
    	return count
    }
    
    // O(2^n): setiap pemanggilan memanggil dirinya dua kali
    func FibNaif(n int) int {
    	if n < 2 {
    		return n
    	}
    	return FibNaif(n-1) + FibNaif(n-2)
    }
    
    func main() {
    	data := []int{-3, -1, 0, 1, 3, 5, 8}
    	fmt.Println(Pertama(data))            // -3
    	fmt.Println(BinarySearch(data, 5))    // 5
    	fmt.Println(Jumlah(data))             // 13
    	fmt.Println(HitungPasanganNol(data))  // 2
    	fmt.Println(FibNaif(10))              // 55
    }
    

    Untuk O(n log n) kita pakai sort.Ints dari pustaka standar, yang implementasinya ada di kelas itu. Merge sort dan quick sort buatan sendiri menyusul di bagian 20 dan 21 seri ini.

    Jalankan dulu untuk memastikan semuanya benar:

    go mod init bigo
    go run main.go
    

    Output yang diharapkan:

    -3
    5
    13
    2
    55
    

    Benchmark Go: Input 1.000 vs 1.000.000

    Sekarang bagian serunya. Kita ukur pola pertumbuhannya pakai fitur benchmark bawaan Go. Simpan sebagai main_test.go di folder yang sama:

    package main
    
    import (
    	"fmt"
    	"math/rand"
    	"sort"
    	"testing"
    )
    
    var hasil int
    
    func buatData(n int) []int {
    	data := make([]int, n)
    	for i := range data {
    		data[i] = rand.Intn(n)
    	}
    	return data
    }
    
    func BenchmarkPertama(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Pertama(data)
    			}
    		})
    	}
    }
    
    func BenchmarkBinarySearch(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		sort.Ints(data)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = BinarySearch(data, -1)
    			}
    		})
    	}
    }
    
    func BenchmarkJumlah(b *testing.B) {
    	for _, n := range []int{1000, 1000000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = Jumlah(data)
    			}
    		})
    	}
    }
    
    // Sengaja hanya sampai 10.000. Baca penjelasan di bawah.
    func BenchmarkHitungPasanganNol(b *testing.B) {
    	for _, n := range []int{1000, 10000} {
    		data := buatData(n)
    		b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
    			for i := 0; i < b.N; i++ {
    				hasil = HitungPasanganNol(data)
    			}
    		})
    	}
    }
    

    Variabel hasil di level package itu penting. Tanpa itu, compiler Go bisa membuang pemanggilan fungsi yang hasilnya tidak dipakai, dan benchmark kamu mengukur ruang kosong.

    Jalankan:

    go test -bench=. -benchtime=1s
    

    Angkanya pasti beda di tiap mesin, tapi polanya akan mirip seperti ini:

    BenchmarkPertama/n=1000-8            1000000000    0.25 ns/op
    BenchmarkPertama/n=1000000-8         1000000000    0.25 ns/op
    BenchmarkBinarySearch/n=1000-8       300000000     4.1 ns/op
    BenchmarkBinarySearch/n=1000000-8    150000000     8.3 ns/op
    BenchmarkJumlah/n=1000-8             4000000       310 ns/op
    BenchmarkJumlah/n=1000000-8          3800          315000 ns/op
    BenchmarkHitungPasanganNol/n=1000-8  3000          410000 ns/op
    BenchmarkHitungPasanganNol/n=10000-8 28            41000000 ns/op
    

    Baca polanya, bukan angkanya. Pertama tidak berubah walau input naik seribu kali lipat, itu wajah O(1). BinarySearch cuma naik sekitar dua kali karena log₂ 1.000 itu 10 dan log₂ 1.000.000 itu 20. Jumlah naik seribu kali, sebanding dengan inputnya. HitungPasanganNol naik seratus kali padahal inputnya cuma naik sepuluh kali, karena 10² = 100.

    Kenapa pasangan nol tidak dites di 1.000.000? Karena itu satu triliun perbandingan per iterasi, benchmark-nya tidak akan selesai dalam waktu masuk akal. FibNaif lebih parah lagi, di n = 50 saja sudah tidak praktis dijalankan.

    Cara Membaca Kode dan Menentukan Big O-nya

    Loop tunggal berarti O(n)

    Satu loop yang menyentuh setiap elemen sekali adalah O(n). Ada dua loop terpisah yang berurutan? Itu O(n + n) = O(2n), yang nanti kita sederhanakan jadi O(n) juga.

    Nested loop berarti kalikan

    Loop di dalam loop berarti jumlah iterasinya dikalikan. Dua loop yang sama-sama jalan n kali menghasilkan O(n × n) = O(n²). Tiga tingkat berarti O(n³). Hati-hati, ini sering menyelinap lewat pemanggilan fungsi: loop yang di dalamnya memanggil fungsi O(n) juga menghasilkan O(n²), walau di kode kelihatannya cuma satu loop.

    Loop yang membagi dua berarti O(log n)

    Kalau variabel loop dikali dua atau dibagi dua setiap iterasi, seperti for i := 1; i < n; i *= 2, jumlah iterasinya log₂ n. Sama dengan pola binary search di atas: ruang pencarian dipangkas setengah setiap putaran.

    Rekursi: hitung cabangnya

    Fungsi rekursif yang memanggil dirinya sekali biasanya O(n). Yang memanggil dirinya dua kali, seperti FibNaif, meledak jadi O(2ⁿ) karena tiap tingkat menggandakan jumlah pemanggilan.

    Aturan Penyederhanaan: Buang Konstanta, Ambil Suku Dominan

    Big O hanya peduli pola pertumbuhan untuk n yang besar, jadi ada dua aturan penulisan:

    1. Buang konstanta. O(2n + 10) ditulis O(n). O(n/2) juga O(n). Loop yang jalan dua kali tetap linear, cuma linearnya dua kali lebih tebal.
    2. Ambil suku yang paling dominan. O(n² + n + 100) ditulis O(n²). Saat n = 1.000.000, suku n² menyumbang satu triliun operasi sementara suku n cuma sejuta. Sisanya jadi tidak relevan.

    Satu pengecualian: dua variabel input yang berbeda jangan digabung. Algoritma yang memproses dua koleksi berbeda ukurannya O(n + m), bukan O(n). Ini muncul lagi nanti di graph, yang kompleksitasnya ditulis O(V + E).

    Kesalahan Umum Saat Memakai Big O

    Mengira O(1) selalu lebih cepat dari O(n). Salah untuk input kecil. Lookup map itu O(1) tapi ada biaya hashing di tiap akses, jadi scan linear di slice berisi 10 elemen sering lebih cepat karena datanya berdampingan di memori. Big O baru menang bicara saat n membesar.

    Menganggap Big O sama dengan waktu eksekusi nyata. Dua algoritma sama-sama O(n) bisa beda kecepatan lima kali lipat karena konstanta yang dibuang tadi. Big O menjawab “bagaimana perilakunya saat data tumbuh”, benchmark menjawab “berapa cepat di mesin ini dengan data ini”. Kamu butuh keduanya. Di proyek klien, tim Arrazy biasanya memakai Big O saat memilih struktur data di tahap desain, lalu memvalidasi titik-titik panas dengan benchmark seperti di atas sebelum sistem naik ke produksi.

    Lupa bahwa Big O adalah kasus terburuk yang umum dikutip. Quick sort rata-rata O(n log n) tapi terburuknya O(n²). Map Go rata-rata O(1) tapi terburuknya O(n). Saat membaca “Big O suatu algoritma”, pastikan kamu tahu itu angka rata-rata atau terburuk.

    Cheat Sheet Big O untuk Seri Ini

    Simpan tabel ini. Semua struktur data dan algoritma di bawah akan kita bedah satu per satu di bagian berikutnya, dan angka-angka ini jadi rujukan kita terus.

    Struktur / Algoritma Akses Cari Sisip Hapus
    Array / Slice O(1) O(n) O(n) O(n)
    Singly / Doubly Linked List O(n) O(n) O(1) di ujung O(1) jika node diketahui
    Stack O(n) O(n) O(1) push O(1) pop
    Queue O(n) O(n) O(1) enqueue O(1) dequeue
    Hash Table / map O(1) rata-rata, O(n) terburuk O(1) rata-rata O(1) rata-rata
    Binary Search Tree (seimbang) O(log n) O(log n) O(log n) O(log n)
    Heap / Priority Queue O(1) puncak O(n) O(log n) O(log n)
    Algoritma Terbaik Rata-rata Terburuk
    Bubble Sort / Insertion Sort O(n) O(n²) O(n²)
    Merge Sort O(n log n) O(n log n) O(n log n)
    Quick Sort O(n log n) O(n log n) O(n²)
    Binary Search O(1) O(log n) O(log n)
    BFS / DFS pada graph O(V + E)

    Troubleshooting: Masalah yang Sering Muncul Saat Benchmark

    go test bilang “no test files”

    Penyebabnya hampir selalu nama file. File benchmark wajib berakhiran _test.go, misalnya main_test.go. Nama seperti benchmark.go atau test_main.go tidak akan dikenali. Pastikan juga kamu menjalankan go test -bench=. di folder yang sama dengan file tersebut, dan go mod init sudah dijalankan.

    Hasil benchmark 0.25 ns/op padahal fungsinya berat

    Compiler membuang pemanggilan fungsi karena hasilnya tidak pernah dipakai. Ini yang disebut dead code elimination. Solusinya seperti di kode kita: tampung hasil ke variabel level package seperti var hasil int. Untuk O(1) sungguhan seperti Pertama, angka di bawah 1 ns memang normal.

    Benchmark tidak pernah selesai atau laptop menggantung

    Kamu mungkin memberi input besar ke fungsi O(n²) atau O(2ⁿ). HitungPasanganNol dengan sejuta elemen berarti sekitar satu triliun operasi per iterasi, dan Go akan mencoba mengulanginya berkali-kali. Tekan Ctrl+C, kecilkan inputnya, atau batasi dengan -benchtime=10x agar hanya jalan 10 iterasi.

    Fungsi benchmark tidak dijalankan padahal tidak ada error

    Nama fungsi harus diawali Benchmark dengan huruf besar dan menerima b *testing.B. Selain itu flag -bench wajib diberi pola, dan titik berarti semua. Tanpa flag itu, go test hanya menjalankan unit test biasa.

    Lanjut ke Bagian Berikutnya

    Kamu sekarang punya kosakata untuk menilai algoritma: enam kelas kompleksitas, cara membacanya dari kode, aturan penyederhanaannya, plus cheat sheet yang akan terus kita pakai. Di bagian 4, “Array dan Slice di Go: Struktur Data Paling Dasar”, kita buktikan kenapa akses indeks itu O(1) tapi sisip di tengah O(n). Artikelnya terbit menyusul, pantau di halaman hub seri Belajar Struktur Data.

    Referensi