This post explains how to systematically configure Logging in a Spring Boot multi-module project,
and how to apply request-level tracing using MDC (Mapped Diagnostic Context).
In real-world environments, it is extremely important to quickly identify “which request caused an error.”
By automatically including values like traceId, eventId, clientIp, userId in every log entry, log analysis becomes dramatically easier.
📚 Spring Boot(Kotlin) Basic Setup — Full Series
- Why multi-module architecture? (Architecture philosophy & high-level design)
- API Response format design
- Global Exception Handling
- Swagger(OpenAPI) configuration
- Security (JWT) base structure
- JWT TokenProvider
- Redis configuration
- Validation setup
- Logging + MDC(traceId) setup ← this post
- application.yml profile separation (local/dev/prod)
- Multi-module + JPA basic structure
- Final project template git distribution
✔ Why is Logging important?
While operating a backend server, you will frequently encounter situations like the following:
- A user says “I got an error,” but the root cause cannot be found in the server logs
- A specific request keeps failing, but there is no way to identify which one
- An unexpected exception occurs, but the log lacks necessary diagnostic information
- Log entries do not contain client IP or userId, making analysis difficult
To solve these issues, real-world systems use MDC to generate a unique traceId / eventId for each request and automatically include them in every log entry.
Once applied, the benefits are clear:
- Easy log searching → retrieve all logs belonging to the same request
- Quick understanding of error events using eventId
- Even multi-server logs can be grouped using a single traceId
- Strong benefits for monitoring and security auditing
✔ LoggingFilter — Setting MDC values per request
The LoggingFilter runs before every HTTP request and stores essential information (traceId, eventId, clientIp, etc.) in MDC for downstream logging.
📌 LoggingFilter (api module)
package com.example.api.config
import com.example.application.common.LoggingUtil
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.util.*
@Component
class LoggingFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain
) {
try {
MDC.put("traceId", UUID.randomUUID().toString())
MDC.put("eventId", LoggingUtil.generateEventId())
MDC.put("clientIp", request.remoteAddr)
logger.info("Incoming request: ${request.method} ${request.requestURI}")
chain.doFilter(request, response)
} finally {
MDC.clear()
}
}
}
📌 Why is LoggingFilter placed in the api module?
LoggingFilter should live in the outermost layer that receives HTTP requests.
In a multi-module structure, the api module is where all controllers reside, so placing the filter here ensures it runs immediately before request processing.
- The module that contains Controllers → api
- The entry point for all HTTP requests → api
- A filter acts like a “gateway” → placing it in api is ideal
Therefore, placing LoggingFilter in the api module is the correct architectural choice.
✔ LoggingUtil — Shared logging utility (application module)
Logging logic that is reused in exception handling or service-level logic belongs in the application module, as this aligns with layered/DDD architecture.
📌 LoggingUtil (application module)
package com.example.application.common
import mu.KLogger
import org.apache.commons.codec.binary.Base32
import org.apache.commons.lang3.exception.ExceptionUtils
import org.slf4j.MDC
import java.nio.ByteBuffer
object LoggingUtil {
private val base32 = Base32()
fun generateEventId(): String {
val buffer = ByteBuffer.allocate(8)
buffer.putLong(System.nanoTime())
return base32.encodeToString(buffer.array()).substring(2, 10)
}
fun logUnexpectedError(
logger: KLogger,
ex: Exception,
param: Any? = null
): String {
val eventId = MDC.get("eventId")
val traceId = MDC.get("traceId")
val userId = MDC.get("userId")
val clientIp = MDC.get("clientIp")
val root = ExceptionUtils.getRootCause(ex) ?: ex
val stack = ex.stackTrace.firstOrNull()
val method = if (stack != null)
"${stack.className}.${stack.methodName}(L${stack.lineNumber})"
else "Unknown"
val logMsg = buildString {
appendLine("[Unexpected Error]")
appendLine("eventId=$eventId traceId=$traceId userId=$userId ip=$clientIp")
appendLine("method=$method")
appendLine("rootCause=$root")
if (param != null) appendLine("param=$param")
}
logger.error(logMsg, ex)
return eventId ?: ""
}
fun logBusinessError(logger: KLogger, ex: Throwable): String {
val eventId = MDC.get("eventId")
logger.warn("[BusinessError][$eventId] ${ex.message}")
return eventId ?: ""
}
}
📌 Why is LoggingUtil placed in the application module?
LoggingUtil is not part of the “request handling layer,” but a shared utility used throughout service/business logic.
Thus it belongs in the application layer.
- Business exception handling → belongs to application layer
- Shared logging utility → used from both Controller and Service
- If placed in api, service-layer code would depend on api → a violation of layered architecture
✔ Including eventId in Global Exception Handling
When an unexpected server error occurs, returning the eventId to the client allows developers/administrators to instantly find the matching log entry.
@ExceptionHandler(Exception::class)
fun handleException(e: Exception): ResponseEntity<ApiResponse> {
val eventId = LoggingUtil.logUnexpectedError(logger, e)
val body = ApiResponse.fail(
code = ErrorCode.INTERNAL_ERROR.code,
messageKey = ErrorCode.INTERNAL_ERROR.messageKey,
detail = mapOf("eventId" to eventId)
)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(body)
}
→ The API client receives the eventId,
and searching the logs using the same ID reveals the exact failure details instantly.
✔ Registering the Filter in SecurityConfig
To register a custom filter within the Spring Security chain, use FilterRegistrationBean:
@Bean
fun loggingFilterRegistration(loggingFilter: LoggingFilter): FilterRegistrationBean<LoggingFilter> {
return FilterRegistrationBean<LoggingFilter>().apply {
filter = loggingFilter
order = Int.MIN_VALUE // run before all other filters
addUrlPatterns("/*")
}
}
Setting order to the lowest value ensures LoggingFilter runs before any other filters.
→ This guarantees traceId/eventId are available throughout the filter chain and in all logs.
✔ Conclusion
To summarize:
- LoggingFilter → api module (HTTP entry point)
- LoggingUtil → application module (shared business utility)
- MDC stores traceId / eventId / clientIp / userId → enables full request traceability
- Filter is registered to run first via SecurityConfig
- eventId is returned in errors → rapid debugging and issue correlation
Logging is not simply “printing logs” it is critical infrastructure that dramatically improves operations, debugging, and incident response speed.
The next article will cover application.yml environment separation.
https://jaemoi8.tistory.com/47
Spring Boot(Kotlin) — ep.10 application.yml Profile Separation (local/dev/prod)
In this article, we will configure profiles in a Spring Boot multi-module project and separate environment-specific configurations (local/dev/prod) using application-{profile}.yml.Profile separation is essential in real-world backend applications because i
jaemoi8.tistory.com