diff --git a/common/log/build.gradle.kts b/common/log/build.gradle.kts new file mode 100644 index 00000000..5087714b --- /dev/null +++ b/common/log/build.gradle.kts @@ -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")) +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/LogConfiguration.kt b/common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt similarity index 85% rename from service/src/main/kotlin/com/sangdol/roomescape/common/log/LogConfiguration.kt rename to common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt index bf20b06e..da4073e8 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/LogConfiguration.kt +++ b/common/log/src/main/kotlin/com/sangdol/common/log/config/LogConfiguration.kt @@ -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 diff --git a/common/log/src/main/kotlin/com/sangdol/common/log/config/LogType.kt b/common/log/src/main/kotlin/com/sangdol/common/log/config/LogType.kt new file mode 100644 index 00000000..e39c85bb --- /dev/null +++ b/common/log/src/main/kotlin/com/sangdol/common/log/config/LogType.kt @@ -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 +} diff --git a/common/log/src/main/kotlin/com/sangdol/common/log/message/AbstractLogMaskingConverter.kt b/common/log/src/main/kotlin/com/sangdol/common/log/message/AbstractLogMaskingConverter.kt new file mode 100644 index 00000000..42e117ef --- /dev/null +++ b/common/log/src/main/kotlin/com/sangdol/common/log/message/AbstractLogMaskingConverter.kt @@ -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, + 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()}" + } +} diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverter.kt b/common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt similarity index 92% rename from service/src/main/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverter.kt rename to common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt index f9b7c87b..a8048ef2 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ApiLogMessageConverter.kt +++ b/common/log/src/main/kotlin/com/sangdol/common/log/message/ApiLogMessageConverter.kt @@ -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 diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt b/common/log/src/main/kotlin/com/sangdol/common/log/sql/MDCAwareSlowQueryListenerWithoutParams.kt similarity index 96% rename from service/src/main/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt rename to common/log/src/main/kotlin/com/sangdol/common/log/sql/MDCAwareSlowQueryListenerWithoutParams.kt index a6e979c5..ecd79d13 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParams.kt +++ b/common/log/src/main/kotlin/com/sangdol/common/log/sql/MDCAwareSlowQueryListenerWithoutParams.kt @@ -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 diff --git a/common/log/src/main/kotlin/com/sangdol/common/log/sql/SlowQueryDataSourceFactory.kt b/common/log/src/main/kotlin/com/sangdol/common/log/sql/SlowQueryDataSourceFactory.kt new file mode 100644 index 00000000..60467751 --- /dev/null +++ b/common/log/src/main/kotlin/com/sangdol/common/log/sql/SlowQueryDataSourceFactory.kt @@ -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() + } +} \ No newline at end of file diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ControllerLoggingAspect.kt b/common/log/src/main/kotlin/com/sangdol/common/log/web/ControllerLoggingAspect.kt similarity index 91% rename from service/src/main/kotlin/com/sangdol/roomescape/common/log/ControllerLoggingAspect.kt rename to common/log/src/main/kotlin/com/sangdol/common/log/web/ControllerLoggingAspect.kt index c88b1abb..1b10df4c 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ControllerLoggingAspect.kt +++ b/common/log/src/main/kotlin/com/sangdol/common/log/web/ControllerLoggingAspect.kt @@ -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() { } diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/HttpRequestLoggingFilter.kt b/common/log/src/main/kotlin/com/sangdol/common/log/web/HttpRequestLoggingFilter.kt similarity index 93% rename from service/src/main/kotlin/com/sangdol/roomescape/common/log/HttpRequestLoggingFilter.kt rename to common/log/src/main/kotlin/com/sangdol/common/log/web/HttpRequestLoggingFilter.kt index bf72e30d..eccd372a 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/HttpRequestLoggingFilter.kt +++ b/common/log/src/main/kotlin/com/sangdol/common/log/web/HttpRequestLoggingFilter.kt @@ -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 {} diff --git a/service/build.gradle.kts b/service/build.gradle.kts index 8cbf1766..1dc77c5b 100644 --- a/service/build.gradle.kts +++ b/service/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(project(":common:persistence")) implementation(project(":common:utils")) implementation(project(":common:types")) + implementation(project(":common:log")) } tasks.jar { diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ProxyDataSourceConfig.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/log/ProxyDataSourceConfig.kt index e6c7f117..11b32ea6 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/ProxyDataSourceConfig.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/log/ProxyDataSourceConfig.kt @@ -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") diff --git a/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt b/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt index 70abcc43..d5077d89 100644 --- a/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt +++ b/service/src/main/kotlin/com/sangdol/roomescape/common/log/RoomescapeLogMaskingConverter.kt @@ -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() +) diff --git a/service/src/test/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt b/service/src/test/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt deleted file mode 100644 index 488de01a..00000000 --- a/service/src/test/kotlin/com/sangdol/roomescape/common/log/MDCAwareSlowQueryListenerWithoutParamsTest.kt +++ /dev/null @@ -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 - } - } -})