Tidak semua pekerjaan backend terjadi saat permintaan HTTP. Backup database tiap malam, pengiriman email ringkasan harian, pembersihan sesi kedaluwarsa — semua adalah tugas latar belakang yang dijadwalkan. Spring menyediakan @Scheduled untuk kasus sederhana dan Quartz scheduler untuk kebutuhan kompleks. Pada chapter ini kita akan memahami keduanya serta cron expression yang menjadi bahasa universal penjadwalan.
@Scheduled Spring #
Anotasi @Scheduled di Spring menjadwalkan method untuk dieksekusi otomatis. Tiga parameter utama:
fixedRate: eksekusi setiap N milidetik, terlepas dari durasi eksekusi sebelumnya.fixedDelay: eksekusi N milidetik SETELAH eksekusi sebelumnya selesai.cron: ekspresi cron untuk jadwal kompleks.
@Component
class ReportService {
@Scheduled(cron = "0 9 * * * *") // Setiap hari jam 9:00
fun generateDailyReport() { ... }
@Scheduled(fixedDelay = 60_000) // Setiap 1 menit setelah eksekusi selesai
fun cleanupExpiredSessions() { ... }
}Untuk mengaktifkan @Scheduled, tambahkan @EnableScheduling di class @SpringBootApplication. Spring akan memindai method @Scheduled dan mendaftarkannya ke TaskScheduler.
Cron Expression #
Cron expression adalah string yang menentukan jadwal. Format tradisional punya 5 field, Spring menambahkan field detik di depan menjadi 6 field:
* * * * * *
| | | | | |
| | | | | └── day of week (0-6, Minggu=0)
| | | | └── month (1-12)
| | | └── day of month (1-31)
| | └── hour (0-23)
| └── minute (0-59)
└── second (0-59, Spring saja)Setiap field dapat berisi: * (semua nilai), angka tunggal (5), range (0-10), list (1,5,10), atau step (*/5 artinya setiap 5 unit).
Contoh Cron #
| Cron | Arti |
|---|---|
0 9 * * * * | Setiap hari jam 9:00:00 |
*/5 * * * * * | Setiap 5 detik |
0 0 * * 0 | Setiap hari Minggu 00:00 |
30 2 * * * | Setiap hari jam 02:30 |
0 9 * * MON | Setiap Senin jam 9:00 |
Quartz memakai format 6-7 field (tambahan tahun opsional di akhir). Spring @Scheduled memakai format 6 field (detik di depan).
Quartz Scheduler #
Quartz adalah library scheduler yang lebih kaya dari @Scheduled. Quartz mendukung persistence (JobStore JDBC), clustering (multiple instance menjalankan scheduler yang sinkron), dan job listener. Quartz memisahkan konsep Job (apa yang dijalankan) dari Trigger (kapan dijalankan).
class BackupJob : Job {
override fun execute(context: JobExecutionContext) {
// Logika backup database
}
}
val job = JobBuilder.newJob(BackupJob::class.java)
.withIdentity("nightlyBackup", "group1")
.build()
val trigger = TriggerBuilder.newTrigger()
.withIdentity("nightlyTrigger", "group1")
.withSchedule(CronScheduleBuilder.cronSchedule("30 2 * * *"))
.build()
scheduler.scheduleJob(job, trigger)Demo Scheduler #
Kita akan memodelkan scheduler sebagai JobStore yang menyimpan job dan mengeksekusinya saat tick(time) dipanggil. Cron expression diparse menjadi set nilai valid per field, lalu dicocokkan dengan waktu.
// background_jobs.kt
// Mendemonstrasikan penjadwalan tugas latar belakang: @Scheduled, Quartz
// scheduler, JobStore, dan cron expression. Pada kode produksi, Spring
// Scheduler memproses @Scheduled; Quartz memakai JobDetail + Trigger.
// Di sini kita memodelkannya sebagai scheduler ringkas yang mendukung
// cron expression sederhana.
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
// Mock anotasi Spring
@Target(AnnotationTarget.FUNCTION) annotation class Scheduled(val cron: String = "")
// Cron field: menit, jam, hari-bulan, bulan, hari-minggu
data class CronExpression(val expr: String) {
private val parts = expr.split(" ")
val minutes: Set<Int> = parseField(parts.getOrElse(0) { "*" }, 0, 59)
val hours: Set<Int> = parseField(parts.getOrElse(1) { "*" }, 0, 23)
val dayOfMonth: Set<Int> = parseField(parts.getOrElse(2) { "*" }, 1, 31)
val month: Set<Int> = parseField(parts.getOrElse(3) { "*" }, 1, 12)
val dayOfWeek: Set<Int> = parseField(parts.getOrElse(4) { "*" }, 0, 6)
// * artinya semua nilai valid; angka tunggal artinya nilai spesifik;
// */N artinya kelipatan N mulai dari min
private fun parseField(field: String, min: Int, max: Int): Set<Int> = when {
field == "*" -> (min..max).toSet()
field.startsWith("*/") -> {
val step = field.removePrefix("*/").toInt()
(min..max step step).toSet()
}
else -> setOf(field.toInt())
}
fun matches(time: LocalDateTime): Boolean =
time.minute in minutes &&
time.hour in hours &&
time.dayOfMonth in dayOfMonth &&
time.monthValue in month &&
time.dayOfWeek.value % 7 in dayOfWeek
}
// Job yang akan dieksekusi scheduler
data class JobDetail(val name: String, val cron: CronExpression, val action: () -> String)
// Quartz-style JobStore: menyimpan job dan trigger
class JobStore {
private val jobs = mutableListOf<JobDetail>()
private val executions = mutableListOf<Pair<LocalDateTime, String>>()
fun addJob(job: JobDetail) { jobs.add(job) }
// Tick scheduler: jalankan semua job yang cocok dengan waktu saat ini
fun tick(time: LocalDateTime) {
for (job in jobs) {
if (job.cron.matches(time)) {
val result = job.action()
executions.add(time to "[${job.name}] $result")
}
}
}
fun executions(): List<Pair<LocalDateTime, String>> = executions.toList()
fun jobCount(): Int = jobs.size
}
// Contoh service dengan @Scheduled
class ReportService {
@Scheduled(cron = "0 9 * * * *") // Setiap jam 9:00
fun generateDailyReport(): String = "Daily report generated"
@Scheduled(cron = "0 0 * * 0") // Setiap hari Minggu 00:00
fun generateWeeklyReport(): String = "Weekly report generated"
@Scheduled(cron = "*/5 * * * *") // Setiap 5 menit (simplified: menit kelipatan 5)
fun cleanupExpiredSessions(): String = "Expired sessions cleaned"
}
class BackupService {
@Scheduled(cron = "30 2 * * *") // Setiap hari 02:30
fun nightlyBackup(): String = "Database backed up to /backup/db.tar.gz"
}
fun main() {
val store = JobStore()
val reports = ReportService()
val backups = BackupService()
// Registrasi job dari service
store.addJob(JobDetail("daily-report", CronExpression("0 9 * * *"), reports::generateDailyReport))
store.addJob(JobDetail("weekly-report", CronExpression("0 0 * * 0"), reports::generateWeeklyReport))
store.addJob(JobDetail("cleanup-sessions", CronExpression("*/5 * * * *"), reports::cleanupExpiredSessions))
store.addJob(JobDetail("nightly-backup", CronExpression("30 2 * * *"), backups::nightlyBackup))
println("=== Job terdaftar: ${store.jobCount()} ===")
// Simulasi beberapa waktu
val times = listOf(
LocalDateTime.of(2026, 1, 15, 9, 0), // daily report + cleanup
LocalDateTime.of(2026, 1, 15, 9, 5), // cleanup
LocalDateTime.of(2026, 1, 15, 2, 30), // nightly backup
LocalDateTime.of(2026, 1, 18, 0, 0), // weekly report (Sunday)
LocalDateTime.of(2026, 1, 18, 9, 0), // daily report (Sunday 9am)
LocalDateTime.of(2026, 1, 19, 14, 25) // no jobs
)
val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
println("\n=== Simulasi tick ===")
times.forEach { t ->
store.tick(t)
println(" Ticked: ${t.format(fmt)}")
}
println("\n=== Eksekusi yang tercatat ===")
store.executions().forEach { (time, msg) ->
println(" ${time.format(fmt)} -> $msg")
}
println("\n=== Cron expression reference ===")
listOf(
"0 9 * * *" to "Setiap hari jam 9:00",
"*/5 * * * *" to "Setiap 5 menit",
"0 0 * * 0" to "Setiap Minggu 00:00",
"30 2 * * *" to "Setiap hari 02:30"
).forEach { (cron, desc) ->
println(" $cron -> $desc")
}
}Output yang Dihasilkan:
=== Job terdaftar: 4 ===
=== Simulasi tick ===
Ticked: 2026-01-15 09:00
Ticked: 2026-01-15 09:05
Ticked: 2026-01-15 02:30
Ticked: 2026-01-18 00:00
Ticked: 2026-01-18 09:00
Ticked: 2026-01-19 14:25
=== Eksekusi yang tercatat ===
2026-01-15 09:00 -> [daily-report] Daily report generated
2026-01-15 09:00 -> [cleanup-sessions] Expired sessions cleaned
2026-01-15 09:05 -> [cleanup-sessions] Expired sessions cleaned
2026-01-15 02:30 -> [cleanup-sessions] Expired sessions cleaned
2026-01-15 02:30 -> [nightly-backup] Database backed up to /backup/db.tar.gz
2026-01-18 00:00 -> [weekly-report] Weekly report generated
2026-01-18 00:00 -> [cleanup-sessions] Expired sessions cleaned
2026-01-18 09:00 -> [daily-report] Daily report generated
2026-01-18 09:00 -> [cleanup-sessions] Expired sessions cleaned
2026-01-19 14:25 -> [cleanup-sessions] Expired sessions cleaned
=== Cron expression reference ===
0 9 * * * -> Setiap hari jam 9:00
*/5 * * * * -> Setiap 5 menit
0 0 * * 0 -> Setiap Minggu 00:00
30 2 * * * -> Setiap hari 02:30Mari kita bedah bagian penting kode di atas:
data class CronExpression(val expr: String): model cron expression dengan 5 field. Propertyminutes,hours,dayOfMonth,month,dayOfWeekadalah set nilai yang valid — di-parse saat konstruktor dipanggil.fun parseField(field, min, max): Set<Int>: parser 3 kasus:*→ semua nilai dalam range;*/N→ kelipatan N; angka tunggal → set dengan satu elemen. Pada produksi, parser cron lengkap menangani list (1,5,10), range (0-10), dan nama hari/bulan.fun matches(time: LocalDateTime): Boolean: cek apakah setiap field cocok.time.dayOfWeek.value % 7mengonversiLocalDateTime(Senin=1, Minggu=7) ke konvensi cron (Minggu=0, Senin=1, …, Sabtu=6).fun tick(time: LocalDateTime): eksekusi semua job yang cron cocok dengan waktu. Job dieksekusi sinkron — pada produksi, eksekusi paralel via thread pool.store.addJob(JobDetail("daily-report", CronExpression("0 9 * * *"), reports::generateDailyReport)): registrasi job dengan callable referencereports::generateDailyReport. Pada Spring, anotasi@Scheduleddipindai otomatis — tidak perlu registrasi manual.
Idempotency Job #
Job yang dijadwalkan dapat berjalan dua kali bila instance restart tepat saat jadwal. Untuk job yang tidak idempoten (mis. transfer uang), gunakan distributed lock (Redis Redlock, database advisory lock) agar hanya satu instance yang menjalankan job pada satu waktu.
Referensi Lanjutan #
- Spring Boot reference
— rujukan resmi Spring Boot untuk
@Scheduledtask dan eksekusi pekerjaan latar belakang - Ktor welcome
— halaman Ktor sebagai dasar menjalankan background coroutine lewat
Applicationscope
Selanjutnya #
Setelah tugas latar belakang berjalan, langkah terakhir adalah memastikan aplikasi siap produksi. Chapter Backend Production Readiness akan membahas health check, metrics, graceful shutdown, dan structured logging.