[#48] Tosspay mocking 서버 구현을 위한 멀티모듈 전환 #49

Merged
pricelees merged 39 commits from feat/#48 into main 2025-09-30 00:39:14 +00:00
5 changed files with 228 additions and 159 deletions
Showing only changes of commit eeb87e1bc3 - Show all commits

View File

@ -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<String, Any> = 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, Any>,
): String {
val payload: MutableMap<String, Any> = 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<String, Any> = 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<String, Any> = 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}"

View File

@ -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<String, Any> = 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()
}
})

View File

@ -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<OncePerRequestFilter> {
val filter = HttpRequestLoggingFilter(apiLogMessageConverter)
val filter = HttpRequestLoggingFilter(webLogMessageConverter)
return FilterRegistrationBean<OncePerRequestFilter>(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)
}
}

View File

@ -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, Any>): 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)
}
}

View File

@ -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<String, Any> = 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
}
}
}
})