From eeb87e1bc33169968cbc734cb056d6d9b0c43944 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 28 Sep 2025 13:14:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20LogPayloadBuilder=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EA=B8=B0=EC=A1=B4=EC=9D=98=20Api?= =?UTF-8?q?LogMessageConverter=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../log/message/ApiLogMessageConverter.kt | 77 -------- .../common/log/ApiLogMessageConverterTest.kt | 69 -------- .../common/web/config/WebLoggingConfig.kt} | 26 +-- .../web/support/log/WebLogMessageConverter.kt | 49 ++++++ .../support/log/WebLogMessageConverterTest.kt | 166 ++++++++++++++++++ 5 files changed, 228 insertions(+), 159 deletions(-) delete mode 100644 common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt delete mode 100644 common/log/src/test/kotlin/com/sangdol/common/log/ApiLogMessageConverterTest.kt rename common/{log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt => web/src/main/kotlin/com/sangdol/common/web/config/WebLoggingConfig.kt} (50%) create mode 100644 common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt create mode 100644 common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt diff --git a/common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt b/common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt deleted file mode 100644 index a8048ef2..00000000 --- a/common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.sangdol.common.log.message - -import com.fasterxml.jackson.databind.ObjectMapper -import com.sangdol.common.log.config.LogType -import com.sangdol.common.utils.MdcPrincipalIdUtil -import jakarta.servlet.http.HttpServletRequest - -class ApiLogMessageConverter( - private val objectMapper: ObjectMapper -) { - fun convertToHttpRequestMessage( - request: HttpServletRequest - ): String { - val payload: MutableMap = commonRequestPayload(LogType.INCOMING_HTTP_REQUEST, request) - - request.queryString?.let { payload["query_params"] = it } - payload["client_ip"] = request.remoteAddr - payload["user_agent"] = request.getHeader("User-Agent") - - return objectMapper.writeValueAsString(payload) - } - - fun convertToControllerInvokedMessage( - request: HttpServletRequest, - controllerPayload: Map, - ): String { - val payload: MutableMap = commonRequestPayload(LogType.CONTROLLER_INVOKED, request) - val memberId: Long? = MdcPrincipalIdUtil.extractAsLongOrNull() - if (memberId != null) payload["principal_id"] = memberId else payload["principal_id"] = "NONE" - - payload.putAll(controllerPayload) - - return objectMapper.writeValueAsString(payload) - } - - fun convertToResponseMessage(request: ConvertResponseMessageRequest): String { - val payload: MutableMap = mutableMapOf() - payload["type"] = request.type - payload["endpoint"] = request.endpoint - payload["status_code"] = request.httpStatus - - MdcPrincipalIdUtil.extractAsLongOrNull() - ?.let { payload["principal_id"] = it } - ?: run { payload["principal_id"] = "NONE" } - - request.startTime?.let { payload["duration_ms"] = System.currentTimeMillis() - it } - request.body?.let { payload["response_body"] = it } - request.exception?.let { - payload["exception"] = mapOf( - "class" to it.javaClass.simpleName, - "message" to it.message - ) - } - - return objectMapper.writeValueAsString(payload) - } - - private fun commonRequestPayload( - logType: LogType, - request: HttpServletRequest - ): MutableMap = mutableMapOf( - "type" to logType, - "method" to request.method, - "uri" to request.requestURI - ) -} - -data class ConvertResponseMessageRequest( - val type: LogType, - val endpoint: String, - val httpStatus: Int = 200, - val startTime: Long? = null, - val body: Any? = null, - val exception: Exception? = null -) - -fun HttpServletRequest.getEndpoint(): String = "${this.method} ${this.requestURI}" diff --git a/common/log/src/test/kotlin/com/sangdol/common/log/ApiLogMessageConverterTest.kt b/common/log/src/test/kotlin/com/sangdol/common/log/ApiLogMessageConverterTest.kt deleted file mode 100644 index 8e76c8d0..00000000 --- a/common/log/src/test/kotlin/com/sangdol/common/log/ApiLogMessageConverterTest.kt +++ /dev/null @@ -1,69 +0,0 @@ -package com.sangdol.common.log - -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.sangdol.common.log.config.LogType -import com.sangdol.common.log.message.ApiLogMessageConverter -import com.sangdol.common.log.message.ConvertResponseMessageRequest -import io.kotest.core.spec.style.StringSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import jakarta.servlet.http.HttpServletRequest -import org.slf4j.MDC - -class ApiLogMessageConverterTest : StringSpec({ - val converter = ApiLogMessageConverter(jacksonObjectMapper()) - val request: HttpServletRequest = mockk() - - beforeTest { - MDC.remove("principal_id") - MDC.put("principal_id", "1") - } - - afterSpec { - MDC.remove("principal_id") - } - - "HTTP 요청 메시지를 변환한다." { - val method = "POST".also { every { request.method } returns it } - val requestURI = "/test/sangdol".also { every { request.requestURI } returns it } - val clientIP = "127.0.0.1".also { every { request.remoteAddr } returns it } - val query = "key=value&key1=value1".also { every { request.queryString } returns it } - val userAgent = "Mozilla/5.".also { every { request.getHeader("User-Agent") } returns it } - - converter.convertToHttpRequestMessage(request) shouldBe """ - {"type":"INCOMING_HTTP_REQUEST","method":"$method","uri":"$requestURI","query_params":"$query","client_ip":"$clientIP","user_agent":"$userAgent"} - """.trimIndent() - } - - "Controller 요청 메시지를 변환한다." { - val controllerPayload: Map = mapOf( - "controller_method" to "Controller 요청 메시지를 변환한다.", - "request_body" to mapOf("key1" to "value1") - ) - val method = "POST".also { every { request.method } returns it } - val requestURI = "/test/sangdol".also { every { request.requestURI } returns it } - - converter.convertToControllerInvokedMessage(request, controllerPayload) shouldBe """ - {"type":"CONTROLLER_INVOKED","method":"$method","uri":"$requestURI","principal_id":1,"controller_method":"${ - controllerPayload.get( - "controller_method" - ) - }","request_body":{"key1":"value1"}} - """.trimIndent() - } - - "Controller 응답 메시지를 반환한다." { - val endpoint = "POST /test/sangdol" - val request = ConvertResponseMessageRequest( - type = LogType.CONTROLLER_SUCCESS, - endpoint = endpoint, - httpStatus = 200, - exception = RuntimeException("테스트 메시지!") - ) - - converter.convertToResponseMessage(request) shouldBe """ - {"type":"CONTROLLER_SUCCESS","endpoint":"$endpoint","status_code":200,"principal_id":1,"exception":{"class":"RuntimeException","message":"테스트 메시지!"}} - """.trimIndent() - } -}) diff --git a/common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt b/common/web/src/main/kotlin/com/sangdol/common/web/config/WebLoggingConfig.kt similarity index 50% rename from common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt rename to common/web/src/main/kotlin/com/sangdol/common/web/config/WebLoggingConfig.kt index da4073e8..9539657e 100644 --- a/common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt +++ b/common/web/src/main/kotlin/com/sangdol/common/web/config/WebLoggingConfig.kt @@ -1,9 +1,9 @@ -package com.sangdol.common.log.config +package com.sangdol.common.web.config import com.fasterxml.jackson.databind.ObjectMapper -import com.sangdol.common.log.message.ApiLogMessageConverter -import com.sangdol.common.log.web.ControllerLoggingAspect -import com.sangdol.common.log.web.HttpRequestLoggingFilter +import com.sangdol.common.web.asepct.ControllerLoggingAspect +import com.sangdol.common.web.servlet.HttpRequestLoggingFilter +import com.sangdol.common.web.support.log.WebLogMessageConverter import org.springframework.boot.web.servlet.FilterRegistrationBean import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -12,27 +12,27 @@ import org.springframework.core.Ordered import org.springframework.web.filter.OncePerRequestFilter @Configuration -class LogConfiguration { +class WebLoggingConfig { @Bean - @DependsOn(value = ["apiLogMessageConverter"]) + @DependsOn(value = ["webLogMessageConverter"]) fun filterRegistrationBean( - apiLogMessageConverter: ApiLogMessageConverter + webLogMessageConverter: WebLogMessageConverter ): FilterRegistrationBean { - val filter = HttpRequestLoggingFilter(apiLogMessageConverter) + val filter = HttpRequestLoggingFilter(webLogMessageConverter) return FilterRegistrationBean(filter) .apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 } } @Bean - @DependsOn(value = ["apiLogMessageConverter"]) - fun apiLoggingAspect(apiLogMessageConverter: ApiLogMessageConverter): ControllerLoggingAspect { - return ControllerLoggingAspect(apiLogMessageConverter) + @DependsOn(value = ["webLogMessageConverter"]) + fun apiLoggingAspect(webLogMessageConverter: WebLogMessageConverter): ControllerLoggingAspect { + return ControllerLoggingAspect(webLogMessageConverter) } @Bean - fun apiLogMessageConverter(objectMapper: ObjectMapper): ApiLogMessageConverter { - return ApiLogMessageConverter(objectMapper) + fun webLogMessageConverter(objectMapper: ObjectMapper): WebLogMessageConverter { + return WebLogMessageConverter(objectMapper) } } diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt new file mode 100644 index 00000000..1a2c47a7 --- /dev/null +++ b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverter.kt @@ -0,0 +1,49 @@ +package com.sangdol.common.web.support.log + +import com.fasterxml.jackson.databind.ObjectMapper +import com.sangdol.common.log.constant.LogType +import jakarta.servlet.http.HttpServletRequest + +class WebLogMessageConverter( + private val objectMapper: ObjectMapper +) { + fun convertToHttpRequestMessage(servletRequest: HttpServletRequest): String { + val payload = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .endpoint() + .queryString() + .clientIp() + .userAgent() + .build() + + return objectMapper.writeValueAsString(payload) + } + + fun convertToControllerInvokedMessage(servletRequest: HttpServletRequest, controllerPayload: Map): String { + val payload = LogPayloadBuilder(type = LogType.CONTROLLER_INVOKED, servletRequest = servletRequest) + .endpoint() + .principalId() + .additionalPayloads(controllerPayload) + .build() + + return objectMapper.writeValueAsString(payload) + } + + fun convertToResponseMessage( + type: LogType, + servletRequest: HttpServletRequest, + httpStatusCode: Int, + responseBody: Any? = null, + exception: Exception? = null, + ): String { + val payload = LogPayloadBuilder(type = type, servletRequest = servletRequest) + .endpoint() + .httpStatus(httpStatusCode) + .durationMs() + .principalId() + .responseBody(responseBody) + .exception(exception) + .build() + + return objectMapper.writeValueAsString(payload) + } +} diff --git a/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt new file mode 100644 index 00000000..84a1f62d --- /dev/null +++ b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/WebLogMessageConverterTest.kt @@ -0,0 +1,166 @@ +package com.sangdol.common.web.support.log + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.sangdol.common.log.constant.LogType +import com.sangdol.common.types.web.HttpStatus +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.utils.MdcStartTimeUtil +import io.kotest.assertions.assertSoftly +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.mockk +import jakarta.servlet.http.HttpServletRequest + +class WebLogMessageConverterTest : FunSpec({ + + val objectMapper = jacksonObjectMapper() + val converter = WebLogMessageConverter(objectMapper) + val servletRequest: HttpServletRequest = mockk() + + lateinit var method: String + lateinit var requestUri: String + lateinit var remoteAddr: String + lateinit var userAgent: String + lateinit var queryString: String + + beforeTest { + method = "GET".also { every { servletRequest.method } returns it } + requestUri = "/converter/test".also { every { servletRequest.requestURI } returns it } + remoteAddr = "localhost".also { every { servletRequest.remoteAddr } returns it } + userAgent = "Mozilla/5.0".also { every { servletRequest.getHeader("User-Agent") } returns it } + queryString = "key=value".also { every { servletRequest.queryString } returns it } + } + + afterSpec { + clearMocks(servletRequest) + } + + context("Http 요청 메시지를 변환한다.") { + test("정상 응답") { + val result = converter.convertToHttpRequestMessage(servletRequest) + + result shouldBe """ + {"type":"${LogType.INCOMING_HTTP_REQUEST.name}","endpoint":"$method $requestUri","query_params":"$queryString","client_ip":"$remoteAddr","user_agent":"$userAgent"} + """.trimIndent() + } + } + + context("Controller 요청 메시지를 변환한다") { + val principalId = 759980174446956066L.also { + MdcPrincipalIdUtil.set(it.toString()) + } + + test("정상 응답") { + val controllerPayload: Map = mapOf( + "controller_method" to "ThemeController.findThemeById(..)", + "path_variable" to mapOf("id" to "7599801744469560667") + ) + + val result = converter.convertToControllerInvokedMessage(servletRequest, controllerPayload) + + result shouldBe """ + {"type":"${LogType.CONTROLLER_INVOKED.name}","endpoint":"$method $requestUri","principal_id":$principalId,"controller_method":"${controllerPayload["controller_method"]}","path_variable":{"id":"${7599801744469560667}"}} + """.trimIndent() + } + } + + context("응답 메시지를 변환한다.") { + val principalId = 7599801744469560666 + + val body = mapOf( + "id" to 7599801744469560667, + "name" to "sangdol" + ) + + val exception = RuntimeException("hello") + + beforeTest { + MdcPrincipalIdUtil.set(principalId.toString()) + MdcStartTimeUtil.setCurrentTime() + } + + afterTest { + MdcPrincipalIdUtil.clear() + MdcStartTimeUtil.clear() + } + + test("응답 본문을 포함한다.") { + val result = converter.convertToResponseMessage( + type = LogType.SUCCEED, + servletRequest = servletRequest, + httpStatusCode = HttpStatus.OK.value(), + responseBody = body + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.SUCCEED.name + this["endpoint"] shouldBe "$method $requestUri" + this["status_code"] shouldBe HttpStatus.OK.value() + this["duration_ms"].shouldNotBeNull() + this["principal_id"] shouldBe principalId + this["response_body"] shouldBe body + this["exception"] shouldBe null + } + } + + test("예외를 포함한다.") { + val result = converter.convertToResponseMessage( + type = LogType.SUCCEED, + servletRequest = servletRequest, + httpStatusCode = HttpStatus.OK.value(), + exception = exception + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.SUCCEED.name + this["endpoint"] shouldBe "$method $requestUri" + this["status_code"] shouldBe HttpStatus.OK.value() + this["duration_ms"].shouldNotBeNull() + this["principal_id"] shouldBe principalId + this["response_body"] shouldBe null + this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message) + } + } + + test("예외 + 응답 본문을 모두 포함한다.") { + val result = converter.convertToResponseMessage( + type = LogType.SUCCEED, + servletRequest = servletRequest, + httpStatusCode = HttpStatus.OK.value(), + responseBody = body, + exception = exception + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.SUCCEED.name + this["endpoint"] shouldBe "$method $requestUri" + this["status_code"] shouldBe HttpStatus.OK.value() + this["duration_ms"].shouldNotBeNull() + this["principal_id"] shouldBe principalId + this["response_body"] shouldBe body + this["exception"] shouldBe mapOf("class" to exception.javaClass.simpleName, "message" to exception.message) + } + } + + test("예외, 응답 본문 모두 제외한다.") { + val result = converter.convertToResponseMessage( + type = LogType.SUCCEED, + servletRequest = servletRequest, + httpStatusCode = HttpStatus.OK.value(), + ) + + assertSoftly(objectMapper.readValue(result, LinkedHashMap::class.java)) { + this["type"] shouldBe LogType.SUCCEED.name + this["endpoint"] shouldBe "$method $requestUri" + this["status_code"] shouldBe HttpStatus.OK.value() + this["duration_ms"].shouldNotBeNull() + this["principal_id"] shouldBe principalId + this["response_body"] shouldBe null + this["exception"] shouldBe null + } + } + } +})