From 252acf866a96a02d0b1f6ebf27130c8841f198e5 Mon Sep 17 00:00:00 2001 From: pricelees Date: Tue, 29 Jul 2025 06:49:56 +0000 Subject: [PATCH] =?UTF-8?q?[#26]=20=EB=AA=A8=EB=8B=88=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #26 ## ✨ 작업 내용 - actuator, micrometer tracing / datasource 추가 - 로그에 traceId, spanId 추가 - 로깅 방식 개선: 필터가 http 요청 기록 -> 컨트롤러 요청 / 응답은 AOP를 이용하여 기록 ## 🧪 테스트 - 로그 메시지를 만들고, 마스킹하는 두 클래스에 대한 테스트 완료 - 전체 테스트 정상 통과 확인 ## 📚 참고 자료 및 기타 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/27 Co-authored-by: pricelees Co-committed-by: pricelees --- build.gradle.kts | 7 ++ .../roomescape/auth/business/AuthService.kt | 5 +- .../auth/exception/AuthErrorCode.kt | 2 +- .../auth/web/support/AuthInterceptor.kt | 23 +++-- .../auth/web/support/MemberIdResolver.kt | 16 ++- .../exception/ExceptionControllerAdvice.kt | 99 ++++++++++++++----- .../common/log/ApiLogMessageConverter.kt | 81 +++++++++++++++ .../common/log/ControllerLoggingAspect.kt | 94 ++++++++++++++++++ .../common/log/HttpRequestLoggingFilter.kt | 39 ++++++++ .../roomescape/common/log/LogConfiguration.kt | 35 +++++++ .../roomescape/common/log/LoggingFilter.kt | 74 -------------- .../log/RoomescapeLogMaskingConverter.kt | 20 ++-- .../member/business/MemberService.kt | 2 +- .../business/ReservationService.kt | 3 +- src/main/resources/application-local.yaml | 7 +- src/main/resources/application.yaml | 16 +++ src/main/resources/logback-local.xml | 2 +- .../auth/business/AuthServiceTest.kt | 2 +- .../roomescape/auth/web/AuthControllerTest.kt | 2 +- .../common/log/ApiLogMessageConverterTest.kt | 63 ++++++++++++ .../log/RoomescapeLogMaskingConverterTest.kt | 77 +++++++++++++++ .../member/controller/MemberControllerTest.kt | 2 +- .../theme/web/ThemeControllerTest.kt | 6 +- .../roomescape/util/RoomescapeApiTest.kt | 4 + 24 files changed, 557 insertions(+), 124 deletions(-) create mode 100644 src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt create mode 100644 src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt create mode 100644 src/main/kotlin/roomescape/common/log/HttpRequestLoggingFilter.kt create mode 100644 src/main/kotlin/roomescape/common/log/LogConfiguration.kt delete mode 100644 src/main/kotlin/roomescape/common/log/LoggingFilter.kt create mode 100644 src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt create mode 100644 src/test/kotlin/roomescape/common/log/RoomescapeLogMaskingConverterTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 0db9d916..ae046cbe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/src/main/kotlin/roomescape/auth/business/AuthService.kt b/src/main/kotlin/roomescape/auth/business/AuthService.kt index 21619524..b28e6c7c 100644 --- a/src/main/kotlin/roomescape/auth/business/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/business/AuthService.kt @@ -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) { diff --git a/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt index 7c585267..1fdc7f06 100644 --- a/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt +++ b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt @@ -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", "회원 정보를 찾을 수 없어요."), } diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt index c8b90bcc..9b7dadb8 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt @@ -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) } } } diff --git a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt index 49cbdaca..2731f5d9 100644 --- a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt @@ -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) + } } } diff --git a/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt b/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt index 5037d01d..25b83831 100644 --- a/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt +++ b/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt @@ -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 { 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 { - log.debug { "message: ${e.message}" } + @ExceptionHandler(value = [MethodArgumentNotValidException::class, HttpMessageNotReadableException::class]) + fun handleInvalidRequestValueException(e: Exception): ResponseEntity { + 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 { - 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 { - 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 } } } diff --git a/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt new file mode 100644 index 00000000..7cf16685 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/ApiLogMessageConverter.kt @@ -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 = 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 { + val payload: MutableMap = 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 = 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 = 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 +) diff --git a/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt b/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt new file mode 100644 index 00000000..c8cf4e45 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/ControllerLoggingAspect.kt @@ -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 = 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 { + val signature = joinPoint.signature as MethodSignature + val args = joinPoint.args + val payload = mutableMapOf() + payload["controller_method"] = joinPoint.signature.toShortString() + + val requestParams: MutableMap = mutableMapOf() + val pathVariables: MutableMap = 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 + } +} diff --git a/src/main/kotlin/roomescape/common/log/HttpRequestLoggingFilter.kt b/src/main/kotlin/roomescape/common/log/HttpRequestLoggingFilter.kt new file mode 100644 index 00000000..24f937f4 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/HttpRequestLoggingFilter.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/roomescape/common/log/LogConfiguration.kt b/src/main/kotlin/roomescape/common/log/LogConfiguration.kt new file mode 100644 index 00000000..5905b7c6 --- /dev/null +++ b/src/main/kotlin/roomescape/common/log/LogConfiguration.kt @@ -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 { + val filter = HttpRequestLoggingFilter(apiLogMessageConverter) + + return FilterRegistrationBean(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) + } +} diff --git a/src/main/kotlin/roomescape/common/log/LoggingFilter.kt b/src/main/kotlin/roomescape/common/log/LoggingFilter.kt deleted file mode 100644 index 07a63658..00000000 --- a/src/main/kotlin/roomescape/common/log/LoggingFilter.kt +++ /dev/null @@ -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( - "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) - } - } -} diff --git a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt index 58e41174..3fb696b1 100644 --- a/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt +++ b/src/main/kotlin/roomescape/common/log/RoomescapeLogMaskingConverter.kt @@ -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()}" + } + } } diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt index ad6881d0..565590f7 100644 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ b/src/main/kotlin/roomescape/member/business/MemberService.kt @@ -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 { diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index 78c945c1..a5374f75 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -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" } } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 200aec25..9a8b7f2d 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -37,4 +37,9 @@ jdbc: log-level: DEBUG logger-name: query-logger multiline: true - includes: connection,query,keys,fetch \ No newline at end of file + includes: connection,query,keys,fetch + +management: + tracing: + sampling: + probability: 1 \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 99f1feb3..46a9706c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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 diff --git a/src/main/resources/logback-local.xml b/src/main/resources/logback-local.xml index 440c5649..fb0a2202 100644 --- a/src/main/resources/logback-local.xml +++ b/src/main/resources/logback-local.xml @@ -4,7 +4,7 @@ class="roomescape.common.log.RoomescapeLogMaskingConverter"/> + 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"/> diff --git a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt index 587d1ded..8d629510 100644 --- a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt +++ b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt @@ -74,7 +74,7 @@ class AuthServiceTest : BehaviorSpec({ authService.checkLogin(userId) } - exception.errorCode shouldBe AuthErrorCode.UNIDENTIFIABLE_MEMBER + exception.errorCode shouldBe AuthErrorCode.MEMBER_NOT_FOUND } } } diff --git a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt index 03ee82bd..0e895d9d 100644 --- a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt +++ b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt @@ -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, diff --git a/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt b/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt new file mode 100644 index 00000000..62eea129 --- /dev/null +++ b/src/test/kotlin/roomescape/common/log/ApiLogMessageConverterTest.kt @@ -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 = 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() + } +}) diff --git a/src/test/kotlin/roomescape/common/log/RoomescapeLogMaskingConverterTest.kt b/src/test/kotlin/roomescape/common/log/RoomescapeLogMaskingConverterTest.kt new file mode 100644 index 00000000..b4ed4462 --- /dev/null +++ b/src/test/kotlin/roomescape/common/log/RoomescapeLogMaskingConverterTest.kt @@ -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\":\"****\"}}" + } + } +}) diff --git a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt index 1d87a7d7..8eaef1c0 100644 --- a/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt +++ b/src/test/kotlin/roomescape/member/controller/MemberControllerTest.kt @@ -59,7 +59,7 @@ class MemberControllerTest( `when`("관리자가 아니면 에러 응답을 받는다.") { then("비회원") { doNotLogin() - val expectedError = AuthErrorCode.INVALID_TOKEN + val expectedError = AuthErrorCode.MEMBER_NOT_FOUND runGetTest( mockMvc = mockMvc, diff --git a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt index c8fb56d6..7a6a130d 100644 --- a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt +++ b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt @@ -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, diff --git a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt index b393d50a..f5e40349 100644 --- a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt +++ b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt @@ -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