Tampilan
Kenapa penting
Saat ada laporan "pembayaran saya kepotong tapi order masih pending" pukul dua pagi, log adalah satu-satunya saksi apa yang terjadi di server. Debugger tidak bisa dipasang di production, dan System.out.println tidak punya waktu, level, nama class, maupun request id — semua tercampur menjadi satu aliran teks yang tidak bisa difilter. Logging yang baik memungkinkan kamu menyalakan detail hanya untuk satu package, menelusuri satu request di antara ribuan, dan mengirim log ke sistem terpusat. Spring Boot memakai SLF4J + Logback secara default, jadi pola di bab ini langsung terpakai di level Spring Boot nanti.
Konsep inti
Kenapa bukan System.out
System.out.println | Logger (SLF4J + Logback) |
|---|---|
| Tanpa waktu, level, thread, nama class | Format seragam: waktu, level, thread, logger, pesan |
| Tidak bisa dimatikan sebagian | Level per package bisa diubah lewat konfigurasi |
| Selalu ke console | Console, file bergulir, JSON ke sistem log terpusat |
Stack trace lewat printStackTrace() terpisah | Exception tercatat bersama pesan dan konteksnya |
SLF4J (API) + Logback (implementasi)
SLF4J (Simple Logging Facade for Java) hanyalah API: interface Logger dan LoggerFactory. Kodemu hanya bergantung pada SLF4J. Logback adalah implementasi yang benar-benar menulis log. Pola ini sama dengan PaymentGateway di bab Mockito — bergantung pada interface, implementasi bisa diganti (misalnya ke Log4j 2) tanpa mengubah kode.
xml
<!-- Potongan pom.xml; logback-classic membawa slf4j-api secara transitif -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.38</version>
</dependency>Di project Spring Boot kamu tidak perlu menambahkannya: spring-boot-starter-logging sudah ikut di setiap starter dan versinya diatur Boot.
Satu logger per class, disimpan sebagai private static final:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LevelDemo {
private static final Logger log = LoggerFactory.getLogger(LevelDemo.class);
public static void main(String[] args) {
String orderId = "ORD-1001";
long amount = 150_000;
log.trace("Masuk checkout, argumen mentah: {} {}", orderId, amount);
log.debug("Menghitung ongkir untuk {}", orderId);
log.info("Order {} dibayar, total {}", orderId, amount);
log.warn("Stok SKU {} tinggal {}", "KOPI-250", 2);
log.error("Gagal mengirim email konfirmasi untuk {}", orderId);
}
}Output tanpa file konfigurasi (Logback memakai default: level DEBUG, ke console):
text
14:37:07.892 [main] DEBUG LevelDemo -- Menghitung ongkir untuk ORD-1001
14:37:07.895 [main] INFO LevelDemo -- Order ORD-1001 dibayar, total 150000
14:37:07.895 [main] WARN LevelDemo -- Stok SKU KOPI-250 tinggal 2
14:37:07.895 [main] ERROR LevelDemo -- Gagal mengirim email konfirmasi untuk ORD-1001Baris TRACE tidak tampil karena berada di bawah level aktif.
Level dan kapan memakainya
Level berurutan TRACE < DEBUG < INFO < WARN < ERROR. Menyetel level INFO berarti INFO ke atas tampil.
| Level | Pakai untuk | Contoh |
|---|---|---|
TRACE | Detail sangat halus, biasanya mati bahkan di development | isi setiap iterasi, payload mentah |
DEBUG | Informasi diagnosis untuk developer; mati di production | hasil perhitungan ongkir, query yang dibuat |
INFO | Peristiwa bisnis penting yang normal | order dibayar, aplikasi start |
WARN | Ada yang tidak beres tetapi sistem pulih sendiri | retry ke gateway, stok menipis |
ERROR | Operasi gagal dan butuh perhatian manusia | pembayaran gagal setelah semua retry |
Patokan praktis: bila log ERROR muncul, seseorang seharusnya bertindak. Validasi input yang ditolak (user salah ketik) bukan ERROR — cukup INFO/DEBUG atau tidak dicatat sama sekali.
Parameterized logging dengan {}
Placeholder {} diisi argumen secara berurutan, dan teksnya baru dirangkai bila level itu aktif. Konkatenasi "..." + x selalu dihitung, bahkan bila log-nya dibuang. Untuk argumen yang mahal dihitung, SLF4J 2 punya fluent API dengan Supplier (lambda dari M04); addKeyValue menambahkan pasangan kunci–nilai yang berguna untuk structured logging.
java
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LazyDemo {
private static final Logger log = LoggerFactory.getLogger(LazyDemo.class);
static String summarize(List<String> skus) {
System.out.println("(summarize dipanggil)");
return String.join(",", skus);
}
public static void main(String[] args) {
List<String> cart = List.of("KOPI-250", "TEH-100");
log.trace("Isi keranjang: " + summarize(cart)); // konkatenasi tetap dihitung
log.atTrace().setMessage("Isi keranjang: {}")
.addArgument(() -> summarize(cart)).log(); // tidak dihitung bila TRACE mati
log.atInfo().setMessage("Checkout selesai")
.addKeyValue("orderId", "ORD-1001").addKeyValue("items", cart.size()).log();
}
}Output:
text
(summarize dipanggil)
14:37:44.372 [main] INFO LazyDemo -orderId="ORD-1001" items="2"- Checkout selesaisummarize hanya terpanggil sekali — dari baris konkatenasi yang hasilnya dibuang.
Mencatat exception dengan stack trace
Kirim objek exception sebagai argumen terakhir. Bandingkan empat gaya berikut:
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ExceptionStyles {
private static final Logger log = LoggerFactory.getLogger(ExceptionStyles.class);
public static void main(String[] args) {
Exception e = new IllegalStateException("Stok negatif untuk KOPI-250");
log.error("A: gagal " + e);
log.error("B: gagal {}", e.getMessage());
log.error("C: order {} gagal: {}", "ORD-7", e);
log.error("D: order {} gagal", "ORD-7", e);
}
}Output (frame internal launcher java dipotong):
text
14:37:33.061 [main] ERROR ExceptionStyles -- A: gagal java.lang.IllegalStateException: Stok negatif untuk KOPI-250
14:37:33.063 [main] ERROR ExceptionStyles -- B: gagal Stok negatif untuk KOPI-250
14:37:33.064 [main] ERROR ExceptionStyles -- C: order ORD-7 gagal: {}
java.lang.IllegalStateException: Stok negatif untuk KOPI-250
at ExceptionStyles.main(ExceptionStyles.java:8)
14:37:33.065 [main] ERROR ExceptionStyles -- D: order ORD-7 gagal
java.lang.IllegalStateException: Stok negatif untuk KOPI-250
at ExceptionStyles.main(ExceptionStyles.java:8)A dan B kehilangan stack trace — kamu tidak tahu baris mana yang gagal. C menyisakan {} karena throwable terakhir selalu dianggap exception, bukan pengisi placeholder. D adalah bentuk yang benar. Satu lagi: catat exception sekali di tempat ia ditangani, bukan di setiap lapisan sambil di-rethrow — kalau tidak, satu kegagalan muncul sebagai lima stack trace.
Konfigurasi logback.xml dan MDC
Logback mencari logback.xml di classpath (di Maven: src/main/resources). Tiga konsep intinya: appender (tujuan tulis), encoder/pattern (format baris), dan logger (level per nama, mewarisi dari parent; root adalah induk semuanya).
xml
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} %-5level [%X{requestId}] %logger{20} - %msg%n</pattern>
</encoder>
</appender>
<logger name="com.example.payment" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>%X{requestId} membaca nilai dari MDC (Mapped Diagnostic Context): peta kunci–nilai per thread yang otomatis menempel di setiap event log. Dengan menaruh request id di MDC sekali di awal request, semua log dari request itu — dari class mana pun — bisa dicari bersama. MDC.putCloseable dipadukan dengan try-with-resources (M03) agar kuncinya pasti dihapus; server memakai ulang thread, dan MDC yang tidak dibersihkan akan "bocor" ke request berikutnya.
Di Spring Boot, namai file ini logback-spring.xml agar fitur tambahan Boot (misalnya <springProfile>) bisa dipakai, atau cukup atur level lewat properti logging.level.com.example.payment=DEBUG.
Contoh di dunia backend
Alur checkout dengan request id di MDC, level DEBUG hanya untuk package payment, dan exception tercatat lengkap. Dalam kode sungguhan setiap class ada di package-nya dan memakai getLogger(PaymentClient.class); di sini nama logger ditulis sebagai string agar contohnya muat dalam satu file yang bisa dijalankan.
java
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
class GatewayTimeoutException extends RuntimeException {
GatewayTimeoutException(String message) {
super(message);
}
}
class PaymentClient {
private static final Logger log = LoggerFactory.getLogger("com.example.payment.PaymentClient");
String charge(String orderId, long amount) {
log.debug("POST /charges order={} amount={}", orderId, amount);
if (amount > 1_000_000) {
throw new GatewayTimeoutException("Gateway tidak merespons dalam 5000 ms");
}
return "TRX-" + orderId.substring(4);
}
}
class CheckoutService {
private static final Logger log = LoggerFactory.getLogger("com.example.checkout.CheckoutService");
private final PaymentClient payments = new PaymentClient();
void checkout(String orderId, long amount) {
log.debug("Detail keranjang {}", orderId); // tidak tampil: logger ini mewarisi level INFO dari root
try {
String trx = payments.charge(orderId, amount);
log.info("Order {} dibayar, trx={}", orderId, trx);
} catch (GatewayTimeoutException e) {
log.error("Pembayaran order {} gagal", orderId, e); // exception sebagai argumen TERAKHIR
}
}
}
public class CheckoutApp {
public static void main(String[] args) {
CheckoutService service = new CheckoutService();
handleRequest(service, "ORD-1001", 150_000);
handleRequest(service, "ORD-1002", 2_500_000);
}
// Di aplikasi web, bagian ini dikerjakan filter/interceptor sekali per request
static void handleRequest(CheckoutService service, String orderId, long amount) {
String requestId = UUID.randomUUID().toString().substring(0, 8);
try (MDC.MDCCloseable ignored = MDC.putCloseable("requestId", requestId)) {
service.checkout(orderId, amount);
}
}
}Output dengan logback.xml di atas (request id acak tiap kali jalan; frame launcher dipotong):
text
14:37:21.778 DEBUG [8754582e] c.e.p.PaymentClient - POST /charges order=ORD-1001 amount=150000
14:37:21.782 INFO [8754582e] c.e.c.CheckoutService - Order ORD-1001 dibayar, trx=TRX-1001
14:37:21.782 DEBUG [632396f9] c.e.p.PaymentClient - POST /charges order=ORD-1002 amount=2500000
14:37:21.783 ERROR [632396f9] c.e.c.CheckoutService - Pembayaran order ORD-1002 gagal
GatewayTimeoutException: Gateway tidak merespons dalam 5000 ms
at PaymentClient.charge(CheckoutApp.java:19)
at CheckoutService.checkout(CheckoutApp.java:32)
at CheckoutApp.handleRequest(CheckoutApp.java:51)
at CheckoutApp.main(CheckoutApp.java:44)Cari 632396f9 di sistem log, dan seluruh jejak request yang gagal langsung terkumpul.
Structured logging (JSON)
Di production, log biasanya dikirim ke sistem terpusat (Elasticsearch/OpenSearch, Loki, Graylog). Log berformat JSON bisa difilter per field (level, orderId, requestId) tanpa regex yang rapuh. Logback punya encoder JSON, dan Spring Boot (sejak 3.4, juga di Boot 4) punya dukungan bawaan — cukup satu properti:
yaml
# application.yml — Potongan
logging:
structured:
format:
console: ecs # pilihan bawaan lain: logstash, gelfNilai MDC dan pasangan dari addKeyValue ikut menjadi field JSON. Detailnya dibahas di level Spring Boot.
Jangan log data sensitif
Password, token/API key, header Authorization, cookie sesi, nomor kartu, OTP, dan data pribadi (NIK, nomor HP, alamat lengkap) tidak boleh masuk log. Log disalin ke banyak tempat, disimpan lama, dan dibaca banyak orang. Catat ID internal (userId, orderId) sebagai gantinya, dan hati-hati dengan toString() record yang memuat field rahasia atau log body request mentah.
Pertanyaan interview
1. Kenapa memakai SLF4J, bukan langsung Logback?
SLF4J adalah facade: kode dan library hanya bergantung pada API-nya, sedangkan aplikasi memilih satu implementasi (Logback, Log4j 2). Library pihak ketiga yang juga memakai SLF4J otomatis menulis ke backend yang sama, dan implementasi bisa diganti tanpa mengubah kode.
2. Apa itu MDC dan apa risikonya?
MDC adalah peta kunci–nilai per thread yang ditempelkan ke setiap event log — biasanya request id, user id, atau trace id. Risikonya: thread di server dipakai ulang, jadi MDC yang tidak dibersihkan akan muncul di log request lain. Selalu hapus di blok finally atau lewat MDC.putCloseable dengan try-with-resources. Kode yang pindah thread (thread pool, async) juga perlu menyalin MDC secara eksplisit.
3. Kapan memakai WARN dan kapan ERROR?
WARN untuk kondisi tidak normal yang masih tertangani — retry berhasil, fallback dipakai, konfigurasi usang. ERROR untuk operasi yang gagal dan butuh tindakan. Bila ERROR dipakai untuk hal yang wajar (misalnya input user tidak valid), alert jadi bising dan error sungguhan tenggelam.
Jebakan umum
- Konkatenasi string di log —
log.debug("x=" + mahal())tetap menghitungmahal()walauDEBUGmati. Pakai{}atau fluent API denganSupplier. - Membuang stack trace —
log.error("Gagal {}", e.getMessage()). Kirim objekesebagai argumen terakhir. - Log-and-rethrow di setiap lapisan — satu kegagalan tercatat berkali-kali. Catat sekali di tempat ditangani.
- MDC tidak dibersihkan — request id bocor ke request lain di thread yang sama.
- Semua jadi
ERRORatau semua jadiINFO— level kehilangan makna; filter dan alert tidak berguna. - Mencatat password, token, atau PII — termasuk lewat
toString()object dan body request mentah.
Ringkasan
- Pakai logger, bukan
System.out: format seragam, level per package, dan tujuan tulis yang bisa diatur. - SLF4J adalah API, Logback implementasinya; satu
private static final Loggerper class. - Level
TRACE<DEBUG<INFO<WARN<ERROR;ERRORberarti ada yang harus bertindak. - Pakai
{}untuk pesan, dan exception sebagai argumen terakhir agar stack trace tercatat. logback.xmlmengatur appender, pattern, dan level; MDC menempelkan request id ke setiap baris.- Structured logging JSON memudahkan pencarian; jangan pernah log data sensitif.