generated from pricelees/issue-pr-template
[#26] 모니터링 환경 구성 #27
@ -47,8 +47,15 @@ dependencies {
|
||||
// Logging
|
||||
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3")
|
||||
implementation("net.logstash.logback:logstash-logback-encoder:8.1")
|
||||
implementation("com.github.loki4j:loki-logback-appender:2.0.0")
|
||||
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.1.1")
|
||||
|
||||
// Observability
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("io.micrometer:micrometer-tracing-bridge-otel")
|
||||
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
|
||||
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||
|
||||
// Kotlin
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
|
||||
@ -21,8 +21,8 @@ class AuthService(
|
||||
private val jwtHandler: JwtHandler,
|
||||
) {
|
||||
fun login(request: LoginRequest): LoginResponse {
|
||||
log.debug { "[AuthService.login] 로그인 시작: email=${request.email}" }
|
||||
val params = "email=${request.email}, password=${request.password}"
|
||||
log.debug { "[AuthService.login] 로그인 시작: $params" }
|
||||
|
||||
val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED, params, "login") {
|
||||
memberService.findByEmailAndPassword(request.email, request.password)
|
||||
@ -36,7 +36,7 @@ class AuthService(
|
||||
fun checkLogin(memberId: Long): LoginCheckResponse {
|
||||
log.debug { "[AuthService.checkLogin] 로그인 확인 시작: memberId=$memberId" }
|
||||
val member: MemberEntity =
|
||||
fetchMemberOrThrow(AuthErrorCode.UNIDENTIFIABLE_MEMBER, "memberId=$memberId", "checkLogin") {
|
||||
fetchMemberOrThrow(AuthErrorCode.MEMBER_NOT_FOUND, "memberId=$memberId", "checkLogin") {
|
||||
memberService.findById(memberId)
|
||||
}
|
||||
|
||||
@ -55,7 +55,6 @@ class AuthService(
|
||||
block: () -> MemberEntity,
|
||||
): MemberEntity {
|
||||
try {
|
||||
log.debug { "[AuthService.$calledBy] 회원 조회 시작: $params" }
|
||||
return block()
|
||||
} catch (e: Exception) {
|
||||
if (e !is RoomescapeException) {
|
||||
|
||||
@ -13,5 +13,5 @@ enum class AuthErrorCode(
|
||||
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "토큰이 만료됐어요."),
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."),
|
||||
LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."),
|
||||
UNIDENTIFIABLE_MEMBER(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."),
|
||||
MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."),
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
package roomescape.auth.web.support
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.method.HandlerMethod
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
@ -11,6 +14,8 @@ import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
import roomescape.member.business.MemberService
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class AuthInterceptor(
|
||||
private val memberService: MemberService,
|
||||
@ -28,23 +33,29 @@ class AuthInterceptor(
|
||||
return true
|
||||
}
|
||||
|
||||
val member: MemberEntity = findMember(request)
|
||||
val accessToken: String? = request.accessToken()
|
||||
log.info { "[AuthInterceptor] 인증 시작. accessToken=${accessToken}" }
|
||||
val member: MemberEntity = findMember(accessToken)
|
||||
|
||||
if (admin != null && !member.isAdmin()) {
|
||||
log.info { "[AuthInterceptor] 관리자 인증 실패. memberId=${member.id}, role=${member.role}" }
|
||||
throw AuthException(AuthErrorCode.ACCESS_DENIED)
|
||||
}
|
||||
|
||||
MDC.put("member_id", "${member.id}")
|
||||
log.info { "[AuthInterceptor] 인증 완료. memberId=${member.id}, role=${member.role}" }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun findMember(request: HttpServletRequest): MemberEntity {
|
||||
private fun findMember(accessToken: String?): MemberEntity {
|
||||
try {
|
||||
val token: String? = request.accessToken()
|
||||
val memberId: Long = jwtHandler.getMemberIdFromToken(token)
|
||||
|
||||
val memberId = jwtHandler.getMemberIdFromToken(accessToken)
|
||||
return memberService.findById(memberId)
|
||||
.also { MDC.put("member_id", "$memberId") }
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
log.info { "[AuthInterceptor] 회원 조회 실패. accessToken = ${accessToken}" }
|
||||
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
throw AuthException(errorCode, e.message ?: errorCode.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,21 @@
|
||||
package roomescape.auth.web.support
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.core.MethodParameter
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory
|
||||
import org.springframework.web.context.request.NativeWebRequest
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver
|
||||
import org.springframework.web.method.support.ModelAndViewContainer
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
class MemberIdResolver(
|
||||
private val jwtHandler: JwtHandler
|
||||
@ -27,6 +34,13 @@ class MemberIdResolver(
|
||||
val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest
|
||||
val token: String? = request.accessToken()
|
||||
|
||||
return jwtHandler.getMemberIdFromToken(token)
|
||||
try {
|
||||
return jwtHandler.getMemberIdFromToken(token)
|
||||
.also { MDC.put("member_id", "$it") }
|
||||
} catch (e: Exception) {
|
||||
log.info { "[MemberIdResolver] 회원 조회 실패. message=${e.message}" }
|
||||
val errorCode = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
throw AuthException(errorCode, e.message ?: errorCode.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,55 +2,110 @@ package roomescape.common.exception
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import roomescape.auth.exception.AuthException
|
||||
import roomescape.common.dto.response.CommonErrorResponse
|
||||
import roomescape.common.log.ApiLogMessageConverter
|
||||
import roomescape.common.log.ConvertResponseMessageRequest
|
||||
import roomescape.common.log.LogType
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@RestControllerAdvice
|
||||
class ExceptionControllerAdvice(
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
private val messageConverter: ApiLogMessageConverter
|
||||
) {
|
||||
@ExceptionHandler(value = [RoomescapeException::class])
|
||||
fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> {
|
||||
val errorCode: ErrorCode = e.errorCode
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
val type = if (e is AuthException) LogType.AUTHENTICATION_FAILURE else LogType.APPLICATION_FAILURE
|
||||
logException(
|
||||
type = type,
|
||||
httpStatus = httpStatus.value(),
|
||||
errorResponse = errorResponse,
|
||||
exception = e
|
||||
)
|
||||
|
||||
return ResponseEntity
|
||||
.status(errorCode.httpStatus)
|
||||
.body(CommonErrorResponse(errorCode, e.message))
|
||||
.status(httpStatus)
|
||||
.body(errorResponse)
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = [HttpMessageNotReadableException::class])
|
||||
fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<CommonErrorResponse> {
|
||||
log.debug { "message: ${e.message}" }
|
||||
@ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class])
|
||||
fun handleInvalidRequestValueException(e: Exception): ResponseEntity<CommonErrorResponse> {
|
||||
val message: String = if (e is MethodArgumentNotValidException) {
|
||||
e.bindingResult.allErrors
|
||||
.mapNotNull { it.defaultMessage }
|
||||
.joinToString(", ")
|
||||
} else {
|
||||
e.message!!
|
||||
}
|
||||
log.debug { "[ExceptionControllerAdvice] Invalid Request Value Exception occurred: $message" }
|
||||
|
||||
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
|
||||
return ResponseEntity
|
||||
.status(errorCode.httpStatus)
|
||||
.body(CommonErrorResponse(errorCode))
|
||||
}
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
@ExceptionHandler(value = [MethodArgumentNotValidException::class])
|
||||
fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<CommonErrorResponse> {
|
||||
val message: String = e.bindingResult.allErrors
|
||||
.mapNotNull { it.defaultMessage }
|
||||
.joinToString(", ")
|
||||
log.debug { "message: $message" }
|
||||
logException(
|
||||
type = LogType.APPLICATION_FAILURE,
|
||||
httpStatus = httpStatus.value(),
|
||||
errorResponse = errorResponse,
|
||||
exception = e
|
||||
)
|
||||
|
||||
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
|
||||
return ResponseEntity
|
||||
.status(errorCode.httpStatus)
|
||||
.body(CommonErrorResponse(errorCode))
|
||||
.status(httpStatus)
|
||||
.body(errorResponse)
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = [Exception::class])
|
||||
fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> {
|
||||
log.error(e) { "message: ${e.message}" }
|
||||
log.error(e) { "[ExceptionControllerAdvice] Unexpected exception occurred: ${e.message}" }
|
||||
|
||||
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
|
||||
val httpStatus: HttpStatus = errorCode.httpStatus
|
||||
val errorResponse = CommonErrorResponse(errorCode)
|
||||
|
||||
logException(
|
||||
type = LogType.UNHANDLED_EXCEPTION,
|
||||
httpStatus = httpStatus.value(),
|
||||
errorResponse = errorResponse,
|
||||
exception = e
|
||||
)
|
||||
|
||||
return ResponseEntity
|
||||
.status(errorCode.httpStatus)
|
||||
.body(CommonErrorResponse(errorCode))
|
||||
.status(httpStatus)
|
||||
.body(errorResponse)
|
||||
}
|
||||
|
||||
private fun logException(
|
||||
type: LogType,
|
||||
httpStatus: Int,
|
||||
errorResponse: CommonErrorResponse,
|
||||
exception: Exception
|
||||
) {
|
||||
val commonRequest = ConvertResponseMessageRequest(
|
||||
type = type,
|
||||
httpStatus = httpStatus,
|
||||
startTime = MDC.get("startTime")?.toLongOrNull(),
|
||||
body = errorResponse,
|
||||
)
|
||||
|
||||
val logMessage = if (errorResponse.message == exception.message) {
|
||||
messageConverter.convertToResponseMessage(commonRequest)
|
||||
} else {
|
||||
messageConverter.convertToResponseMessage(commonRequest.copy(exception = exception))
|
||||
}
|
||||
|
||||
log.warn { logMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.slf4j.MDC
|
||||
|
||||
enum class LogType {
|
||||
INCOMING_HTTP_REQUEST,
|
||||
CONTROLLER_INVOKED,
|
||||
CONTROLLER_SUCCESS,
|
||||
AUTHENTICATION_FAILURE,
|
||||
APPLICATION_FAILURE,
|
||||
UNHANDLED_EXCEPTION
|
||||
}
|
||||
|
||||
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? = MDC.get("member_id")?.toLong()
|
||||
if (memberId != null) payload["member_id"] = memberId else payload["member_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["status_code"] = request.httpStatus
|
||||
|
||||
MDC.get("member_id")?.toLongOrNull()
|
||||
?.let { payload["member_id"] = it }
|
||||
?: run { payload["member_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 httpStatus: Int = 200,
|
||||
val startTime: Long? = null,
|
||||
val body: Any? = null,
|
||||
val exception: Exception? = null
|
||||
)
|
||||
@ -0,0 +1,94 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import org.aspectj.lang.JoinPoint
|
||||
import org.aspectj.lang.ProceedingJoinPoint
|
||||
import org.aspectj.lang.annotation.Around
|
||||
import org.aspectj.lang.annotation.Aspect
|
||||
import org.aspectj.lang.annotation.Pointcut
|
||||
import org.aspectj.lang.reflect.MethodSignature
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
@Aspect
|
||||
class ControllerLoggingAspect(
|
||||
private val messageConverter: ApiLogMessageConverter,
|
||||
) {
|
||||
|
||||
@Pointcut("execution(* roomescape..web..*Controller.*(..))")
|
||||
fun allController() {
|
||||
}
|
||||
|
||||
@Around("allController()")
|
||||
fun logAPICalls(joinPoint: ProceedingJoinPoint): Any? {
|
||||
val startTime: Long = MDC.get("startTime").toLongOrNull() ?: System.currentTimeMillis()
|
||||
val controllerPayload: Map<String, Any> = parsePayload(joinPoint)
|
||||
|
||||
log.info {
|
||||
messageConverter.convertToControllerInvokedMessage(servletRequest(), controllerPayload)
|
||||
}
|
||||
|
||||
try {
|
||||
return joinPoint.proceed()
|
||||
.also { logSuccess(startTime, it) }
|
||||
} catch (e: Exception) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun logSuccess(startTime: Long, result: Any) {
|
||||
val responseEntity = result as ResponseEntity<*>
|
||||
val logMessage = messageConverter.convertToResponseMessage(
|
||||
ConvertResponseMessageRequest(
|
||||
type = LogType.CONTROLLER_SUCCESS,
|
||||
httpStatus = responseEntity.statusCode.value(),
|
||||
startTime = startTime,
|
||||
body = responseEntity.body
|
||||
)
|
||||
)
|
||||
|
||||
log.info { logMessage }
|
||||
}
|
||||
|
||||
private fun servletRequest(): HttpServletRequest {
|
||||
return (RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes).request
|
||||
}
|
||||
|
||||
private fun parsePayload(joinPoint: JoinPoint): Map<String, Any> {
|
||||
val signature = joinPoint.signature as MethodSignature
|
||||
val args = joinPoint.args
|
||||
val payload = mutableMapOf<String, Any>()
|
||||
payload["controller_method"] = joinPoint.signature.toShortString()
|
||||
|
||||
val requestParams: MutableMap<String, Any> = mutableMapOf()
|
||||
val pathVariables: MutableMap<String, Any> = mutableMapOf()
|
||||
signature.method.parameters.forEachIndexed { index, parameter ->
|
||||
val arg = args[index]
|
||||
|
||||
parameter.getAnnotation(RequestBody::class.java)?.let {
|
||||
payload["request_body"] = arg
|
||||
}
|
||||
|
||||
parameter.getAnnotation(PathVariable::class.java)?.let {
|
||||
pathVariables[parameter.name] = arg
|
||||
}
|
||||
|
||||
parameter.getAnnotation(RequestParam::class.java)?.let {
|
||||
requestParams[parameter.name] = arg
|
||||
}
|
||||
}
|
||||
if (pathVariables.isNotEmpty()) payload["path_variable"] = pathVariables
|
||||
if (requestParams.isNotEmpty()) payload["request_param"] = requestParams
|
||||
|
||||
return payload
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper
|
||||
|
||||
private val log: KLogger = KotlinLogging.logger {}
|
||||
|
||||
class HttpRequestLoggingFilter(
|
||||
private val messageConverter: ApiLogMessageConverter
|
||||
) : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
log.info { messageConverter.convertToHttpRequestMessage(request) }
|
||||
|
||||
val cachedRequest = ContentCachingRequestWrapper(request)
|
||||
val cachedResponse = ContentCachingResponseWrapper(response)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
MDC.put("startTime", startTime.toString())
|
||||
|
||||
try {
|
||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||
cachedResponse.copyBodyToResponse()
|
||||
} finally {
|
||||
MDC.remove("startTime")
|
||||
MDC.remove("member_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/main/kotlin/roomescape/common/log/LogConfiguration.kt
Normal file
35
src/main/kotlin/roomescape/common/log/LogConfiguration.kt
Normal file
@ -0,0 +1,35 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.context.annotation.DependsOn
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
|
||||
@Configuration
|
||||
class LogConfiguration {
|
||||
|
||||
@Bean
|
||||
@DependsOn(value = ["apiLogMessageConverter"])
|
||||
fun filterRegistrationBean(
|
||||
apiLogMessageConverter: ApiLogMessageConverter
|
||||
): FilterRegistrationBean<OncePerRequestFilter> {
|
||||
val filter = HttpRequestLoggingFilter(apiLogMessageConverter)
|
||||
|
||||
return FilterRegistrationBean<OncePerRequestFilter>(filter)
|
||||
.apply { this.order = Ordered.HIGHEST_PRECEDENCE + 2 }
|
||||
}
|
||||
|
||||
@Bean
|
||||
@DependsOn(value = ["apiLogMessageConverter"])
|
||||
fun apiLoggingAspect(apiLogMessageConverter: ApiLogMessageConverter): ControllerLoggingAspect {
|
||||
return ControllerLoggingAspect(apiLogMessageConverter)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun apiLogMessageConverter(objectMapper: ObjectMapper): ApiLogMessageConverter {
|
||||
return ApiLogMessageConverter(objectMapper)
|
||||
}
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.core.annotation.Order
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper
|
||||
import org.springframework.web.util.ContentCachingResponseWrapper
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
class LoggingFilter(
|
||||
private val objectMapper: ObjectMapper
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain
|
||||
) {
|
||||
val cachedRequest = ContentCachingRequestWrapper(request)
|
||||
val cachedResponse = ContentCachingResponseWrapper(response)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
filterChain.doFilter(cachedRequest, cachedResponse)
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
|
||||
logAPISummary(cachedRequest, cachedResponse, duration)
|
||||
cachedResponse.copyBodyToResponse()
|
||||
|
||||
}
|
||||
|
||||
private fun logAPISummary(
|
||||
request: ContentCachingRequestWrapper,
|
||||
response: ContentCachingResponseWrapper,
|
||||
duration: Long
|
||||
) {
|
||||
val payload = linkedMapOf<String, Any>(
|
||||
"type" to "API_LOG",
|
||||
"method" to request.method,
|
||||
"url" to request.requestURL.toString(),
|
||||
)
|
||||
request.queryString?.let { payload["query_params"] = it }
|
||||
payload["remote_ip"] = request.remoteAddr
|
||||
payload["status_code"] = response.status
|
||||
payload["duration_ms"] = duration
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
request.contentAsByteArray.takeIf { it.isNotEmpty() }
|
||||
?.let { payload["request_body"] = parseContent(it) }
|
||||
|
||||
response.contentAsByteArray.takeIf { it.isNotEmpty() }
|
||||
?.let { payload["response_body"] = parseContent(it) }
|
||||
}
|
||||
|
||||
log.info { objectMapper.writeValueAsString(payload) }
|
||||
}
|
||||
|
||||
private fun parseContent(content: ByteArray): Any {
|
||||
return try {
|
||||
objectMapper.readValue(content, Map::class.java)
|
||||
} catch (_: Exception) {
|
||||
String(content, StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,9 @@ import roomescape.common.config.JacksonConfig
|
||||
|
||||
private const val MASK: String = "****"
|
||||
private val SENSITIVE_KEYS = setOf("password", "accessToken")
|
||||
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
||||
|
||||
class RoomescapeLogMaskingConverter(
|
||||
private val objectMapper: ObjectMapper = JacksonConfig().objectMapper()
|
||||
) : MessageConverter() {
|
||||
class RoomescapeLogMaskingConverter : MessageConverter() {
|
||||
override fun convert(event: ILoggingEvent): String {
|
||||
val message: String = event.formattedMessage
|
||||
|
||||
@ -37,13 +36,14 @@ class RoomescapeLogMaskingConverter(
|
||||
|
||||
private fun maskedPlainMessage(message: String): String {
|
||||
val keys: String = SENSITIVE_KEYS.joinToString("|")
|
||||
val regex = Regex("(?i)($keys)(\\s*=\\s*)(\\S+)")
|
||||
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}${MASK}"
|
||||
"${key}${delimiter}${maskedValue}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ class RoomescapeLogMaskingConverter(
|
||||
node?.forEachEntry { key, childNode ->
|
||||
when {
|
||||
childNode.isValueNode -> {
|
||||
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, MASK)
|
||||
if (key in SENSITIVE_KEYS) (node as ObjectNode).put(key, maskValue(childNode.asText()))
|
||||
}
|
||||
|
||||
childNode.isObject -> maskRecursive(childNode)
|
||||
@ -70,4 +70,12 @@ class RoomescapeLogMaskingConverter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maskValue(value: String): String {
|
||||
return if (value.length <= 2) {
|
||||
MASK
|
||||
} else {
|
||||
"${value.first()}$MASK${value.last()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ class MemberService(
|
||||
}
|
||||
|
||||
private fun fetchOrThrow(calledBy: String, params: String, block: () -> MemberEntity?): MemberEntity {
|
||||
log.debug { "[MemberService.$calledBy] 회원 조회 시작: params=$params" }
|
||||
log.debug { "[MemberService.$calledBy] 회원 조회 시작: $params" }
|
||||
return block()
|
||||
?.also { log.info { "[MemberService.$calledBy] 회원 조회 완료: memberId=${it.id}" } }
|
||||
?: run {
|
||||
|
||||
@ -60,7 +60,7 @@ class ReservationService(
|
||||
|
||||
fun deleteReservation(reservationId: Long, memberId: Long) {
|
||||
validateIsMemberAdmin(memberId, "deleteReservation")
|
||||
log.info { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" }
|
||||
log.debug { "[ReservationService.deleteReservation] 예약 삭제 시작: reservationId=$reservationId, memberId=$memberId" }
|
||||
reservationRepository.deleteById(reservationId)
|
||||
log.info { "[ReservationService.deleteReservation] 예약 삭제 완료: reservationId=$reservationId" }
|
||||
}
|
||||
@ -272,7 +272,6 @@ class ReservationService(
|
||||
}
|
||||
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 시작: reservationId=$reservationId" }
|
||||
reservationRepository.delete(reservation)
|
||||
log.debug { "[ReservationService.deleteWaiting] 대기 예약 삭제 완료: reservationId=$reservationId" }
|
||||
|
||||
log.info { "[ReservationService.deleteWaiting] 대기 취소 완료: reservationId=$reservationId, memberId=$memberId" }
|
||||
}
|
||||
|
||||
@ -37,4 +37,9 @@ jdbc:
|
||||
log-level: DEBUG
|
||||
logger-name: query-logger
|
||||
multiline: true
|
||||
includes: connection,query,keys,fetch
|
||||
includes: connection,query,keys,fetch
|
||||
|
||||
management:
|
||||
tracing:
|
||||
sampling:
|
||||
probability: 1
|
||||
@ -1,9 +1,25 @@
|
||||
server:
|
||||
tomcat:
|
||||
mbeanregistry:
|
||||
enabled: true
|
||||
forward-headers-strategy: framework
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: ${ACTIVE_PROFILE:local}
|
||||
jpa:
|
||||
open-in-view: false
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,loggers,prometheus
|
||||
base-path: ${ACTUATOR_PATH:/actuator}
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
payment:
|
||||
api-base-url: https://api.tosspayments.com
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
class="roomescape.common.log.RoomescapeLogMaskingConverter"/>
|
||||
|
||||
<property name="CONSOLE_LOG_PATTERN"
|
||||
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %green(${PID:- }) --- [%15.15thread] %cyan(%-40logger{36}) : %maskedMessage%n%throwable"/>
|
||||
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) %green(${PID:- }) --- [%15.15thread] [%magenta(%X{traceId:-},%X{spanId:-})] %cyan(%-40logger{36}) : %maskedMessage%n%throwable"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
|
||||
@ -74,7 +74,7 @@ class AuthServiceTest : BehaviorSpec({
|
||||
authService.checkLogin(userId)
|
||||
}
|
||||
|
||||
exception.errorCode shouldBe AuthErrorCode.UNIDENTIFIABLE_MEMBER
|
||||
exception.errorCode shouldBe AuthErrorCode.MEMBER_NOT_FOUND
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ class AuthControllerTest(
|
||||
every { memberRepository.findByIdOrNull(invalidMemberId) } returns null
|
||||
|
||||
Then("에러 응답을 받는다.") {
|
||||
val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER
|
||||
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
runGetTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
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
|
||||
import roomescape.auth.exception.AuthErrorCode
|
||||
import roomescape.auth.exception.AuthException
|
||||
|
||||
class ApiLogMessageConverterTest : StringSpec({
|
||||
val converter = ApiLogMessageConverter(jacksonObjectMapper())
|
||||
val request: HttpServletRequest = mockk()
|
||||
|
||||
beforeTest {
|
||||
MDC.remove("member_id")
|
||||
MDC.put("member_id", "1")
|
||||
}
|
||||
|
||||
afterSpec {
|
||||
MDC.remove("member_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","member_id":1,"controller_method":"${controllerPayload.get("controller_method")}","request_body":{"key1":"value1"}}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
"Controller 응답 메시지를 반환한다." {
|
||||
val request = ConvertResponseMessageRequest(
|
||||
type = LogType.CONTROLLER_SUCCESS,
|
||||
httpStatus = 200,
|
||||
exception = AuthException(AuthErrorCode.MEMBER_NOT_FOUND, "테스트 메시지!")
|
||||
)
|
||||
|
||||
converter.convertToResponseMessage(request) shouldBe """
|
||||
{"type":"CONTROLLER_SUCCESS","status_code":200,"member_id":1,"exception":{"class":"AuthException","message":"테스트 메시지!"}}
|
||||
""".trimIndent()
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,77 @@
|
||||
package roomescape.common.log
|
||||
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent
|
||||
import io.kotest.assertions.assertSoftly
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.equals.shouldBeEqual
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
||||
class RoomescapeLogMaskingConverterTest : FunSpec({
|
||||
|
||||
val converter = RoomescapeLogMaskingConverter()
|
||||
val event: ILoggingEvent = mockk()
|
||||
|
||||
context("평문 로그에서는 key=value 형식을 처리한다.") {
|
||||
|
||||
test("2글자 초과이면 맨 앞, 맨 뒤를 제외한 나머지를 가린다.") {
|
||||
val email = "a@a.a"
|
||||
val password = "password12"
|
||||
val accessToken = "accessToken12"
|
||||
|
||||
every {
|
||||
event.formattedMessage
|
||||
} returns "email=${email}, password=${password}, accessToken = $accessToken"
|
||||
|
||||
assertSoftly(converter.convert(event)) {
|
||||
this shouldContain "email=${email}"
|
||||
this shouldContain "password=${password.first()}****${password.last()}"
|
||||
this shouldContain "accessToken = ${accessToken.first()}****${accessToken.last()}"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
test("2글자 이하이면 전부 가린다.") {
|
||||
val email = "a@a.a"
|
||||
val password = "pa"
|
||||
val accessToken = "a"
|
||||
|
||||
every {
|
||||
event.formattedMessage
|
||||
} returns "email=${email}, password=${password}, accessToken = ${accessToken}"
|
||||
|
||||
assertSoftly(converter.convert(event)) {
|
||||
this shouldContain "email=${email}"
|
||||
this shouldContain "password=****"
|
||||
this shouldContain "accessToken = ****"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context("JSON 형식 로그를 처리한다.") {
|
||||
val json = "{\"request_body\":{\"email\":\"a@a.a\",\"password\":\"password12\"}}"
|
||||
|
||||
test("2글자 초과이면 맨 앞, 맨 뒤를 제외한 나머지를 가린다.") {
|
||||
val password = "password12"
|
||||
val json = "{\"request_body\":{\"email\":\"a@a.a\",\"password\":\"${password}\"}}"
|
||||
|
||||
every {
|
||||
event.formattedMessage
|
||||
} returns json
|
||||
|
||||
converter.convert(event) shouldBeEqual "{\"request_body\":{\"email\":\"a@a.a\",\"password\":\"${password.first()}****${password.last()}\"}}"
|
||||
}
|
||||
|
||||
test("2글자 이하이면 전부 가린다.") {
|
||||
val password = "pa"
|
||||
val json = "{\"request_body\":{\"email\":\"a@a.a\",\"password\":\"${password}\"}}"
|
||||
|
||||
every {
|
||||
event.formattedMessage
|
||||
} returns json
|
||||
|
||||
converter.convert(event) shouldBeEqual "{\"request_body\":{\"email\":\"a@a.a\",\"password\":\"****\"}}"
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -59,7 +59,7 @@ class MemberControllerTest(
|
||||
`when`("관리자가 아니면 에러 응답을 받는다.") {
|
||||
then("비회원") {
|
||||
doNotLogin()
|
||||
val expectedError = AuthErrorCode.INVALID_TOKEN
|
||||
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
|
||||
runGetTest(
|
||||
mockMvc = mockMvc,
|
||||
|
||||
@ -36,7 +36,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
|
||||
doNotLogin()
|
||||
|
||||
Then("에러 응답을 받는다.") {
|
||||
val expectedError = AuthErrorCode.INVALID_TOKEN
|
||||
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
runGetTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
@ -89,7 +89,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
|
||||
When("로그인 상태가 아니라면") {
|
||||
doNotLogin()
|
||||
Then("에러 응답을 받는다.") {
|
||||
val expectedError = AuthErrorCode.INVALID_TOKEN
|
||||
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
runPostTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
@ -234,7 +234,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
|
||||
When("로그인 상태가 아니라면") {
|
||||
doNotLogin()
|
||||
Then("에러 응답을 받는다.") {
|
||||
val expectedError = AuthErrorCode.INVALID_TOKEN
|
||||
val expectedError = AuthErrorCode.MEMBER_NOT_FOUND
|
||||
runDeleteTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
|
||||
@ -15,6 +15,7 @@ import roomescape.auth.infrastructure.jwt.JwtHandler
|
||||
import roomescape.auth.web.support.AuthInterceptor
|
||||
import roomescape.auth.web.support.MemberIdResolver
|
||||
import roomescape.common.config.JacksonConfig
|
||||
import roomescape.common.log.ApiLogMessageConverter
|
||||
import roomescape.member.business.MemberService
|
||||
import roomescape.member.infrastructure.persistence.MemberEntity
|
||||
import roomescape.member.infrastructure.persistence.MemberRepository
|
||||
@ -31,6 +32,9 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
|
||||
@SpykBean
|
||||
lateinit var memberService: MemberService
|
||||
|
||||
@SpykBean
|
||||
lateinit var apiLogMessageConverter: ApiLogMessageConverter
|
||||
|
||||
@MockkBean
|
||||
lateinit var memberRepository: MemberRepository
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user