From 6cd269e772f29c58eff24f85418bdf0108bc10a0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 28 Sep 2025 13:14:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EC=9B=B9=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=97=90=EC=84=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EB=8D=98=20payload=20map=EC=9D=84=20=EB=B9=8C?= =?UTF-8?q?=EB=8D=94=20=ED=98=95=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=9C=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/support/log/LogPayloadBuilder.kt | 75 ++++++ .../web/support/log/LogPayloadBuilderTest.kt | 233 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt create mode 100644 common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt diff --git a/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt new file mode 100644 index 00000000..03dbe179 --- /dev/null +++ b/common/web/src/main/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilder.kt @@ -0,0 +1,75 @@ +package com.sangdol.common.web.support.log + +import com.sangdol.common.log.constant.LogType +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.utils.MdcStartTimeUtil +import jakarta.servlet.http.HttpServletRequest + +class LogPayloadBuilder( + private val type: LogType, + private val servletRequest: HttpServletRequest, + private val payload: MutableMap = mutableMapOf("type" to type) +) { + fun endpoint(): LogPayloadBuilder { + payload["endpoint"] = "${servletRequest.method} ${servletRequest.requestURI}" + return this + } + + fun clientIp(): LogPayloadBuilder { + servletRequest.remoteAddr?.let { payload["client_ip"] = it } + return this + } + + fun userAgent(): LogPayloadBuilder { + servletRequest.getHeader("User-Agent")?.let { payload["user_agent"] = it } + return this + } + + fun queryString(): LogPayloadBuilder { + servletRequest.queryString?.let { payload["query_params"] = it } + return this + } + + fun httpStatus(statusCode: Int?): LogPayloadBuilder { + statusCode?.let { payload["status_code"] = it } + + return this + } + + fun responseBody(body: Any?): LogPayloadBuilder { + body?.let { payload["response_body"] = it } + + return this + } + + fun durationMs(): LogPayloadBuilder { + MdcStartTimeUtil.extractDurationMsOrNull()?.let { payload["duration_ms"] = it } + return this + } + + fun principalId(): LogPayloadBuilder { + MdcPrincipalIdUtil.extractAsLongOrNull() + ?.let { payload["principal_id"] = it } + ?: run { payload["principal_id"] = "UNKNOWN" } + + return this + } + + fun exception(exception: Exception?): LogPayloadBuilder { + exception?.let { + payload["exception"] = mapOf( + "class" to it.javaClass.simpleName, + "message" to it.message + ) + } + + return this + } + + fun additionalPayloads(payload: Map): LogPayloadBuilder { + this.payload.putAll(payload) + return this + } + + fun build(): Map = payload +} diff --git a/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt new file mode 100644 index 00000000..0b905efa --- /dev/null +++ b/common/web/src/test/kotlin/com/sangdol/common/web/support/log/LogPayloadBuilderTest.kt @@ -0,0 +1,233 @@ +package com.sangdol.common.web.support.log + +import com.sangdol.common.log.constant.LogType +import com.sangdol.common.utils.MdcPrincipalIdUtil +import com.sangdol.common.utils.MdcStartTimeUtil +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 LogPayloadBuilderTest : FunSpec({ + 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("endpoint") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .endpoint() + .build() + + result["endpoint"] shouldBe "$method $requestUri" + } + + test("ServletRequest에서 null이 반환되면 그대로 들어간다.") { + every { servletRequest.method } returns null + every { servletRequest.requestURI } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .endpoint() + .build() + + result["endpoint"] shouldBe "null null" + } + } + + context("clientIp") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .clientIp() + .build() + + result["client_ip"] shouldBe remoteAddr + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.remoteAddr } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .clientIp() + .build() + + result["client_ip"] shouldBe null + } + } + + context("userAgent") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .userAgent() + .build() + + result["user_agent"] shouldBe userAgent + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.getHeader("User-Agent") } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .userAgent() + .build() + + result["user_agent"] shouldBe null + } + } + + context("queryString") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .queryString() + .build() + + result["query_params"] shouldBe queryString + } + + test("ServletRequest에서 null이 반환되면 추가되지 않는다.") { + every { servletRequest.queryString } returns null + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .queryString() + .build() + + result["query_params"] shouldBe null + } + } + + context("httpStatus") { + test("정상 응답") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .httpStatus(200) + .build() + + result["status_code"] shouldBe 200 + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .httpStatus(null) + .build() + + result["status_code"] shouldBe null + } + } + + context("responseBody") { + test("정상 응답") { + val body = mapOf("key" to "value") + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .responseBody(body) + .build() + + result["response_body"] shouldBe body + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .responseBody(null) + .build() + + result["response_body"] shouldBe null + } + } + + context("durationMs") { + test("정상 응답") { + MdcStartTimeUtil.setCurrentTime() + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .durationMs() + .build() + + result["duration_ms"].shouldNotBeNull() + MdcStartTimeUtil.clear() + } + + test("MDC에서 값을 가져올 수 없으면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .durationMs() + .build() + + result["duration_ms"] shouldBe null + } + } + + context("principalId") { + test("정상 응답") { + val principalId = 759980174446956066L.also { + MdcPrincipalIdUtil.set(it.toString()) + } + + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .principalId() + .build() + + result["principal_id"] shouldBe principalId + MdcPrincipalIdUtil.clear() + } + + test("MDC에서 값을 가져올 수 없으면 UNKNOWN 으로 표기된다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .principalId() + .build() + + result["principal_id"] shouldBe "UNKNOWN" + } + } + + context("exception") { + test("정상 응답") { + val exception = RuntimeException("hello") + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .exception(exception) + .build() + + result["exception"] shouldBe mapOf( + "class" to exception.javaClass.simpleName, + "message" to exception.message + ) + } + + test("null을 입력하면 추가되지 않는다.") { + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .exception(null) + .build() + + result["exception"] shouldBe null + } + } + + context("additionalPayloads") { + test("정상 응답") { + val payload = mapOf( + "key1" to "value1", + "key2" to "value2" + ) + val result = LogPayloadBuilder(type = LogType.INCOMING_HTTP_REQUEST, servletRequest = servletRequest) + .additionalPayloads(payload) + .build() + + result["key1"] shouldBe "value1" + result["key2"] shouldBe "value2" + } + } +})