이번 글은 Spring Boot 멀티 모듈 기반 프로젝트에서 로깅(Logging)을 체계적으로 구성하고,
MDC(Mapped Diagnostic Context)를 활용해 요청 단위 추적(traceId)을 적용하는 방법을 정리한 글입니다.
실무 환경에서는 "어떤 요청에서 어떤 오류가 발생했는지"를 빠르게 확인하는 것이 매우 중요한데,
이를 위해 traceId, eventId, clientIp, userId 등을 로그에 자동으로 포함시키면 분석이 매우 쉬워집니다.
📚Spring Boot(Kotlin) 기본 셋팅 — 전체 시리즈
- 왜 멀티 모듈 구조인가? (아키텍처 철학 & 전체 설계 편)
- API Response 포맷 설계
- 글로벌 예외 처리(GlobalExceptionHandler)
- Swagger(OpenAPI) 설정
- Security(JWT) 기본 골격
- JWT TokenProvider
- Redis 설정
- Validation 설정
- Logging + MDC(traceId) 설정 ← 지금 글
- application.yml 프로필 분리 (local/dev/prod)
- 멀티모듈 + JPA 기본 구조 정리
- 완성된 프로젝트 템플릿 git 공유
✔ 로깅(Logging)은 왜 중요한가?
백엔드 서버를 운영하다 보면 반드시 마주치는 상황이 있습니다:
- 사용자가 “에러가 떴어요”라고 했는데 서버에서는 문제 원인을 찾기 어려움
- 특정 요청만 실패하는데 어떤 요청인지 파악이 안 됨
- 서비스에서 예상치 못한 예외(Exception)가 발생했는데 로그 정보가 부족함
- 클라이언트 IP나 userId 등이 로그에 없어 분석이 어려움
이 문제를 해결하기 위해 실무에서는 MDC(Mapped Diagnostic Context)를 활용해 각 요청마다 고유한 traceId / eventId를 생성하고, 모든 로그에 자동으로 포함시킵니다.
이렇게 하면:
- 로그 검색이 쉬워짐 → 한 요청에 대한 로그를 모두 추적 가능
- 에러 발생 시 eventId로 빠르게 상황 파악
- 여러 서버에서 발생한 로그도 하나의 traceId로 묶어 관찰
- 보안/모니터링 측면에서도 매우 유리
✔ LoggingFilter — 요청 단위 MDC 값 설정
LoggingFilter는 모든 HTTP 요청에 대해 가장 먼저 실행되며,
각 요청마다 traceId/eventId/clientIp 등의 값을 MDC에 기록합니다.
📌 LoggingFilter (api 모듈)
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()
}
}
}
📌 왜 LoggingFilter는 api 모듈에 두었는가?
LoggingFilter는 HTTP 요청을 받아들이는 가장 바깥 레이어에 존재해야 합니다.
즉, Controller가 있는 api 모듈에서 요청을 처리하기 직전 단계에서 동작해야 가장 자연스럽고 명확합니다.
- Controller가 있는 모듈 → api
- 요청을 최초로 받는 모듈 → api
- Filter는 "입구" 역할 → api가 적합
따라서 LoggingFilter를 api 모듈에 배치했습니다.
✔ LoggingUtil — 공통 로깅 유틸리티 (application 모듈)
예외를 처리할 때 혹은 서비스 비즈니스 로직에서 공통적으로 사용되는 로깅 로직은 application 계층에 두는 것이 DDD/계층형 아키텍처 측면에서 적합합니다.
📌 LoggingUtil (application 모듈)
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 ?: ""
}
}
📌 왜 LoggingUtil은 application 모듈인가?
LoggingUtil은 “요청을 받는 레이어”가 아니라 “비즈니스 로직에서 사용할 공통 기능”입니다.
즉, 서비스 레이어 중심의 로직이므로 application 모듈에 배치했습니다.
- 비즈니스 예외 처리 → application 레이어 책임
- 공통 로깅 유틸 → Controller~Service 어디서든 사용
- api 모듈에 두면 비즈니스 로직에서 케이스 별로 로그 생성 시 계층 의존성이 역전됨 - application 모듈(비지니스 로직)에서 api 모듈에 있는 클래스를 호출하기 때문
✔ 글로벌 예외 처리에서 eventId 포함시키기
예상하지 못한 서버 오류가 발생했을 때, eventId를 사용자에게 반환하면 관리자나 개발자는 로그에서 같은 eventId를 검색해 바로 원인을 찾을 수 있습니다.
@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)
}
→ API 클라이언트는 eventId를 받게 되고, 서버 로그에서 같은 eventId를 검색하면 실패 원인을 즉시 확인할 수 있습니다.
✔ SecurityConfig에서 Filter 등록
커스텀 필터를 Spring Security 체인에 등록하려면 FilterRegistrationBean을 사용합니다.
@Bean
fun loggingFilterRegistration(loggingFilter: LoggingFilter): FilterRegistrationBean {
return FilterRegistrationBean().apply {
filter = loggingFilter
order = Int.MIN_VALUE // 요청 필터 중 가장 먼저 실행
addUrlPatterns("/*")
}
}
order를 가장 낮게 설정해 LoggingFilter가 모든 필터보다 먼저 실행되도록 합니다.
→ 그래야 traceId/eventId가 이후 필터와 로그 전체에 적용됩니다.
✔ 마무리
정리해보면,
- LoggingFilter → api 모듈 (HTTP 진입 지점)
- LoggingUtil → application 모듈 (비즈니스 로직 공통 유틸)
- traceId / eventId / clientIp / userId 등을 MDC에 저장해 로그 추적 용이
- SecurityConfig에서 필터 가장 먼저 실행되도록 등록
- 예외 발생 시 eventId를 응답에 포함시켜 빠른 문제 파악 가능
로깅은 단순히 “로그 남기기”가 아니라, 운영/분석/장애 대응 속도를 극적으로 개선시키는 핵심 인프라입니다.
다음 글에서는 application.yml 환경 분리 부분을 다룹니다!
https://jaemoi8.tistory.com/46
Spring Boot(Kotlin) — 10편. application.yml 프로필 분리 (local/dev/prod)
이번 글에서는 Spring Boot 멀티 모듈 기반 프로젝트에서 프로필(Profile)을 활용해 환경별 설정(local/dev/prod)을 분리하는 방법을 정리합니다.프로필 분리는 실무 백엔드 프로젝트에서 거의 필수이며,
jaemoi8.tistory.com