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

Merged
pricelees merged 39 commits from feat/#48 into main 2025-09-30 00:39:14 +00:00
13 changed files with 153 additions and 131 deletions
Showing only changes of commit 51a0dab2b4 - Show all commits

View File

@ -0,0 +1,15 @@
plugins {
id("org.springframework.boot")
kotlin("plugin.spring")
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-aop")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
testImplementation("io.mockk:mockk:1.14.4")
implementation(project(":common:utils"))
}

View File

@ -1,6 +1,9 @@
package com.sangdol.roomescape.common.log
package com.sangdol.common.log.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 org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

View File

@ -0,0 +1,10 @@
package com.sangdol.common.log.config
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
CONTROLLER_SUCCESS,
AUTHENTICATION_FAILURE,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}

View File

@ -0,0 +1,78 @@
package com.sangdol.common.log.message
import ch.qos.logback.classic.pattern.MessageConverter
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TextNode
abstract class AbstractLogMaskingConverter(
val sensitiveKeys: Set<String>,
val objectMapper: ObjectMapper
) : MessageConverter() {
val mask: String = "****"
override fun convert(event: ILoggingEvent): String {
val message: String = event.formattedMessage
return if (isJsonString(message)) {
maskedJsonString(message)
} else {
maskedPlainMessage(message)
}
}
private fun isJsonString(message: String): Boolean {
val trimmed = message.trim()
return trimmed.startsWith("{") && trimmed.endsWith("}")
}
private fun maskedJsonString(body: String): String = objectMapper.readValue(body, JsonNode::class.java)
.apply { maskRecursive(this) }
.toString()
private fun maskedPlainMessage(message: String): String {
val keys: String = sensitiveKeys.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*[:=]\\s*)([^(,|\"|?)]+)")
return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1]
val delimiter = matchResult.groupValues[2]
val maskedValue = maskValue(matchResult.groupValues[3].trim())
"${key}${delimiter}${maskedValue}"
}
}
private fun maskRecursive(node: JsonNode?) {
node?.forEachEntry { key, childNode ->
when {
childNode.isValueNode -> {
if (key in sensitiveKeys) (node as ObjectNode).put(key, maskValue(childNode.asText()))
}
childNode.isObject -> maskRecursive(childNode)
childNode.isArray -> {
val arrayNode = childNode as ArrayNode
val originSize = arrayNode.size()
if (originSize > 1) {
val first = arrayNode.first()
arrayNode.removeAll()
arrayNode.add(first)
arrayNode.add(TextNode("(...logged only first of $originSize elements)"))
}
arrayNode.forEach { maskRecursive(it) }
}
}
}
}
private fun maskValue(value: String): String {
return "${value.first()}$mask${value.last()}"
}
}

View File

@ -1,17 +1,9 @@
package com.sangdol.roomescape.common.log
package com.sangdol.common.log.message
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.http.HttpServletRequest
import com.sangdol.common.log.config.LogType
import com.sangdol.common.utils.MdcPrincipalIdUtil
enum class LogType {
INCOMING_HTTP_REQUEST,
CONTROLLER_INVOKED,
CONTROLLER_SUCCESS,
AUTHENTICATION_FAILURE,
APPLICATION_FAILURE,
UNHANDLED_EXCEPTION
}
import jakarta.servlet.http.HttpServletRequest
class ApiLogMessageConverter(
private val objectMapper: ObjectMapper

View File

@ -1,4 +1,4 @@
package com.sangdol.roomescape.common.log
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.ExecutionInfo
import net.ttddyy.dsproxy.QueryInfo

View File

@ -0,0 +1,20 @@
package com.sangdol.common.log.sql
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import javax.sql.DataSource
object SlowQueryDataSourceFactory {
fun create(dataSource: DataSource, loggerName: String, logLevel: String, thresholdMs: Long): DataSource {
val mdcAwareListener = MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(logLevel.uppercase()),
thresholdMs = thresholdMs
)
return ProxyDataSourceBuilder.create(dataSource)
.name(loggerName)
.listener(mdcAwareListener)
.buildProxy()
}
}

View File

@ -1,5 +1,9 @@
package com.sangdol.roomescape.common.log
package com.sangdol.common.log.web
import com.sangdol.common.log.config.LogType
import com.sangdol.common.log.message.ApiLogMessageConverter
import com.sangdol.common.log.message.ConvertResponseMessageRequest
import com.sangdol.common.log.message.getEndpoint
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.http.HttpServletRequest
@ -24,7 +28,7 @@ class ControllerLoggingAspect(
private val messageConverter: ApiLogMessageConverter,
) {
@Pointcut("execution(* com.sangdol.roomescape..web..*Controller*.*(..))")
@Pointcut("execution(* com.sangdol..web..*Controller*.*(..))")
fun allController() {
}

View File

@ -1,5 +1,7 @@
package com.sangdol.roomescape.common.log
package com.sangdol.common.log.web
import com.sangdol.common.log.message.ApiLogMessageConverter
import com.sangdol.common.utils.MdcPrincipalIdUtil
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.servlet.FilterChain
@ -9,7 +11,6 @@ import org.slf4j.MDC
import org.springframework.web.filter.OncePerRequestFilter
import org.springframework.web.util.ContentCachingRequestWrapper
import org.springframework.web.util.ContentCachingResponseWrapper
import com.sangdol.common.utils.MdcPrincipalIdUtil
private val log: KLogger = KotlinLogging.logger {}

View File

@ -57,6 +57,7 @@ dependencies {
implementation(project(":common:persistence"))
implementation(project(":common:utils"))
implementation(project(":common:types"))
implementation(project(":common:log"))
}
tasks.jar {

View File

@ -1,8 +1,7 @@
package com.sangdol.roomescape.common.log
import com.sangdol.common.log.sql.SlowQueryDataSourceFactory
import com.zaxxer.hikari.HikariDataSource
import net.ttddyy.dsproxy.listener.logging.SLF4JLogLevel
import net.ttddyy.dsproxy.support.ProxyDataSourceBuilder
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
@ -23,15 +22,12 @@ class ProxyDataSourceConfig {
fun dataSource(
@Qualifier("actualDataSource") actualDataSource: DataSource,
properties: SlowQueryProperties
): DataSource = ProxyDataSourceBuilder.create(actualDataSource)
.name(properties.loggerName)
.listener(
MDCAwareSlowQueryListenerWithoutParams(
logLevel = SLF4JLogLevel.nullSafeValueOf(properties.logLevel.uppercase()),
thresholdMs = properties.thresholdMs
)
)
.buildProxy()
): DataSource = SlowQueryDataSourceFactory.create(
dataSource = actualDataSource,
loggerName = properties.loggerName,
logLevel = properties.logLevel,
thresholdMs = properties.thresholdMs
)
@Bean
@ConfigurationProperties(prefix = "spring.datasource.hikari")

View File

@ -1,81 +1,9 @@
package com.sangdol.roomescape.common.log
import ch.qos.logback.classic.pattern.MessageConverter
import ch.qos.logback.classic.spi.ILoggingEvent
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TextNode
import com.sangdol.common.config.JacksonConfig
import com.sangdol.common.log.message.AbstractLogMaskingConverter
private const val MASK: String = "****"
private val SENSITIVE_KEYS = setOf("password", "accessToken", "phone")
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
class RoomescapeLogMaskingConverter : MessageConverter() {
override fun convert(event: ILoggingEvent): String {
val message: String = event.formattedMessage
return if (isJsonString(message)) {
maskedJsonString(message)
} else {
maskedPlainMessage(message)
}
}
private fun isJsonString(message: String): Boolean {
val trimmed = message.trim()
return trimmed.startsWith("{") && trimmed.endsWith("}")
}
private fun maskedJsonString(body: String): String = objectMapper.readValue(body, JsonNode::class.java)
.apply { maskRecursive(this) }
.toString()
private fun maskedPlainMessage(message: String): String {
val keys: String = SENSITIVE_KEYS.joinToString("|")
val regex = Regex("(?i)($keys)(\\s*=\\s*)([^(,|\"|?)\\s]+)")
return regex.replace(message) { matchResult ->
val key = matchResult.groupValues[1]
val delimiter = matchResult.groupValues[2]
val maskedValue = maskValue(matchResult.groupValues[3])
"${key}${delimiter}${maskedValue}"
}
}
private fun maskRecursive(node: JsonNode?) {
node?.forEachEntry { key, childNode ->
when {
childNode.isValueNode -> {
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, maskValue(childNode.asText()))
}
childNode.isObject -> maskRecursive(childNode)
childNode.isArray -> {
val arrayNode = childNode as ArrayNode
val originSize = arrayNode.size()
if (originSize > 1) {
val first = arrayNode.first()
arrayNode.removeAll()
arrayNode.add(first)
arrayNode.add(TextNode("(...logged only first of $originSize elements)"))
}
arrayNode.forEach { maskRecursive(it) }
}
}
}
}
private fun maskValue(value: String): String {
return if (value.length <= 2) {
MASK
} else {
"${value.first()}$MASK${value.last()}"
}
}
}
class RoomescapeLogMaskingConverter: AbstractLogMaskingConverter(
sensitiveKeys = setOf("password", "accessToken", "phone"),
objectMapper = JacksonConfig().objectMapper()
)

View File

@ -1,26 +0,0 @@
package com.sangdol.roomescape.common.log
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
class MDCAwareSlowQueryListenerWithoutParamsTest : StringSpec({
"SQL 메시지에서 Params 항목은 가린다." {
val message = """Query:["select * from members m where m.email=?"], Params:[(a@a.a)]"""
val expected = """Query:["select * from members m where m.email=?"]"""
val result = SqlLogFormatter().maskParams(message)
result shouldBe expected
}
"입력된 thresholdMs 보다 소요시간이 긴 쿼리를 기록한다." {
val slowQueryThreshold = 10L
val slowQueryPredicate = SlowQueryPredicate(thresholdMs = slowQueryThreshold)
assertSoftly(slowQueryPredicate) {
it.test(slowQueryThreshold) shouldBe true
it.test(slowQueryThreshold + 1) shouldBe true
it.test(slowQueryThreshold - 1) shouldBe false
}
}
})