-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Spring AOP를 활용한 MDC 로깅 아키텍처 구축 #96
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3ceade5
291929c
f94c79d
a82d5fc
ab71716
72e95a8
b5c06b4
1dcb448
79002a4
26fa8f3
f0db1a0
849c00a
0275ea9
8a76d12
726e8f2
1f5d04b
24bf8bb
7f080ea
ee50243
3daeacb
17d477f
d674403
0befe12
7daba0f
219744d
5b0dae7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,13 +48,22 @@ subprojects { | |
| apply(plugin = Plugins.Kotlin.JPA) | ||
| apply(plugin = Plugins.Kotlin.JVM) | ||
| apply(plugin = Plugins.JACOCO) | ||
| apply(plugin = Plugins.Kotlin.KAPT) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) KAPT 플러그인 서브모듈 적용 OK — 루트 plugins 블록은 apply false 권장 현재 루트 plugins { kotlin("kapt") version ... }로 루트에도 플러그인이 적용되고, 서브모듈에 다시 apply하고 있습니다. 루트는 아래와 같이 변경을 고려해 주세요(루트 plugins 블록): plugins {
id(Plugins.SPRING_BOOT) version Versions.SPRING_BOOT
id(Plugins.SPRING_DEPENDENCY_MANAGEMENT) version Versions.SPRING_DEPENDENCY_MANAGEMENT
kotlin(Plugins.Kotlin.Short.KAPT) version Versions.KOTLIN apply false
kotlin(Plugins.Kotlin.Short.JVM) version Versions.KOTLIN apply false
kotlin(Plugins.Kotlin.Short.SPRING) version Versions.KOTLIN apply false
kotlin(Plugins.Kotlin.Short.JPA) version Versions.KOTLIN apply false
id(Plugins.DETEKT) version Versions.DETEKT
id(Plugins.JACOCO)
id(Plugins.SONAR_QUBE) version Versions.SONAR_QUBE
}🤖 Prompt for AI Agents |
||
|
|
||
| java { | ||
| toolchain { | ||
| languageVersion.set(JavaLanguageVersion.of(Versions.JAVA_VERSION.toInt())) | ||
| } | ||
| } | ||
|
|
||
| configurations.all { | ||
| exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging") | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(Dependencies.Spring.STARTER_LOG4J2) | ||
| } | ||
move-hoon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| plugins.withId(Plugins.Kotlin.ALLOPEN) { | ||
| extensions.configure<org.jetbrains.kotlin.allopen.gradle.AllOpenExtension> { | ||
| annotation("jakarta.persistence.Entity") | ||
|
|
@@ -71,6 +80,7 @@ subprojects { | |
| "-Xconsistent-data-class-copy-visibility" | ||
| ) | ||
| jvmTarget = Versions.JAVA_VERSION | ||
| javaParameters = true | ||
| } | ||
| } | ||
| } | ||
|
|
||
This file was deleted.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,79 @@ | ||||||||||||||||||
| package org.yapp.gateway.filter | ||||||||||||||||||
|
|
||||||||||||||||||
| import jakarta.servlet.FilterChain | ||||||||||||||||||
| import jakarta.servlet.http.HttpServletRequest | ||||||||||||||||||
| import jakarta.servlet.http.HttpServletResponse | ||||||||||||||||||
| import org.slf4j.MDC | ||||||||||||||||||
| import org.springframework.security.core.context.SecurityContextHolder | ||||||||||||||||||
| import org.springframework.security.oauth2.jwt.Jwt | ||||||||||||||||||
| import org.springframework.stereotype.Component | ||||||||||||||||||
| import org.springframework.web.filter.OncePerRequestFilter | ||||||||||||||||||
| import java.util.* | ||||||||||||||||||
|
|
||||||||||||||||||
| @Component | ||||||||||||||||||
| class MdcLoggingFilter : OncePerRequestFilter() { | ||||||||||||||||||
| companion object { | ||||||||||||||||||
| private const val TRACE_ID_HEADER = "X-Request-ID" | ||||||||||||||||||
| private const val XFF_HEADER = "X-Forwarded-For" | ||||||||||||||||||
| private const val X_REAL_IP_HEADER = "X-Real-IP" | ||||||||||||||||||
| private const val TRACE_ID_KEY = "traceId" | ||||||||||||||||||
| private const val USER_ID_KEY = "userId" | ||||||||||||||||||
| private const val CLIENT_IP_KEY = "clientIp" | ||||||||||||||||||
| private const val REQUEST_INFO_KEY = "requestInfo" | ||||||||||||||||||
| private const val DEFAULT_GUEST_USER = "GUEST" | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| override fun doFilterInternal( | ||||||||||||||||||
| request: HttpServletRequest, | ||||||||||||||||||
| response: HttpServletResponse, | ||||||||||||||||||
| filterChain: FilterChain | ||||||||||||||||||
| ) { | ||||||||||||||||||
| val traceId = resolveTraceId(request) | ||||||||||||||||||
| populateMdc(request, traceId) | ||||||||||||||||||
|
|
||||||||||||||||||
| try { | ||||||||||||||||||
| filterChain.doFilter(request, response) | ||||||||||||||||||
| } finally { | ||||||||||||||||||
| MDC.clear() | ||||||||||||||||||
| } | ||||||||||||||||||
move-hoon marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun resolveTraceId(request: HttpServletRequest): String { | ||||||||||||||||||
| val incomingTraceId = request.getHeader(TRACE_ID_HEADER) | ||||||||||||||||||
| return incomingTraceId?.takeIf { it.isNotBlank() } | ||||||||||||||||||
| ?: UUID.randomUUID().toString().replace("-", "") | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun populateMdc(request: HttpServletRequest, traceId: String) { | ||||||||||||||||||
| MDC.put(TRACE_ID_KEY, traceId) | ||||||||||||||||||
| MDC.put(CLIENT_IP_KEY, extractClientIp(request)) | ||||||||||||||||||
| MDC.put(REQUEST_INFO_KEY, "${request.method} ${request.requestURI}") | ||||||||||||||||||
|
|
||||||||||||||||||
move-hoon marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||
| val userId = resolveUserId() | ||||||||||||||||||
| MDC.put(USER_ID_KEY, userId ?: DEFAULT_GUEST_USER) | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+52
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion PII 최소화: 원본 userId 대신 가명(userHash) 기록 권장 로그에 원본 userId를 직접 노출하면 PII 이슈가 발생할 수 있습니다. 원본을 유지해야 한다면 별도 키(userHash)에 해시를 함께 기록하고, log4j2 패턴은 userHash로 바꾸는 것이 안전합니다. log4j2-spring.xml에 대한 코멘트도 함께 확인 바랍니다. - val userId = resolveUserId()
- MDC.put(USER_ID_KEY, userId ?: DEFAULT_GUEST_USER)
+ val userId = resolveUserId() ?: DEFAULT_GUEST_USER
+ MDC.put(USER_ID_KEY, userId) // 필요 시 유지
+ MDC.put("userHash", hashSha256(userId)) // 로그 패턴은 userHash 사용 권장추가 함수(파일 내 임의 위치, 예: 클래스 하단)에 정의해 주세요: // import 필요: java.security.MessageDigest
private fun hashSha256(input: String): String {
val md = java.security.MessageDigest.getInstance("SHA-256")
val bytes = md.digest(input.toByteArray(Charsets.UTF_8))
val hex = StringBuilder(bytes.size * 2)
for (b in bytes) {
val i = b.toInt() and 0xff
if (i < 0x10) hex.append('0')
hex.append(i.toString(16))
}
return hex.toString()
} |
||||||||||||||||||
|
|
||||||||||||||||||
| private fun extractClientIp(request: HttpServletRequest): String { | ||||||||||||||||||
| val xffHeader = request.getHeader(XFF_HEADER) | ||||||||||||||||||
| if (!xffHeader.isNullOrBlank()) { | ||||||||||||||||||
| return xffHeader.split(",").first().trim() | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| val xRealIp = request.getHeader(X_REAL_IP_HEADER) | ||||||||||||||||||
| if (!xRealIp.isNullOrBlank()) { | ||||||||||||||||||
| return xRealIp.trim() | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| return request.remoteAddr | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| private fun resolveUserId(): String? { | ||||||||||||||||||
| val authentication = SecurityContextHolder.getContext().authentication ?: return null | ||||||||||||||||||
|
|
||||||||||||||||||
| return when (val principal = authentication.principal) { | ||||||||||||||||||
| is Jwt -> principal.subject | ||||||||||||||||||
| else -> principal?.toString() | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+73
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) principal.toString()는 과다/민감 정보 노출 위험 — authentication.name 사용 권장 toString()은 객체 상세를 포함할 수 있어 로그 과다/PII 위험이 큽니다. 일반 케이스는 authentication.name 이 안전하고 일관적입니다. - return when (val principal = authentication.principal) {
- is Jwt -> principal.subject
- else -> principal?.toString()
- }
+ return when (val principal = authentication.principal) {
+ is Jwt -> principal.subject
+ else -> authentication.name
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package org.yapp.globalutils.annotation | ||
|
|
||
| @Target(AnnotationTarget.FUNCTION) | ||
| @Retention(AnnotationRetention.RUNTIME) | ||
| annotation class NoLogging |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,6 +9,7 @@ import org.springframework.web.HttpRequestMethodNotSupportedException | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.MethodArgumentNotValidException | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.ExceptionHandler | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.bind.annotation.RestControllerAdvice | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Global exception handler for the application. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -159,6 +160,27 @@ class GlobalExceptionHandler { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ResponseEntity(error, commonErrorCode.getHttpStatus()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 메서드 파라미터 타입 변환 실패 처리 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 주로 @RequestParam, @PathVariable 등에서 클라이언트가 잘못된 타입의 값을 전달했을 때 발생합니다. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 예: 문자열을 int 타입으로 변환 시도 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @ExceptionHandler(MethodArgumentTypeMismatchException::class) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| protected fun handleMethodArgumentTypeMismatch(ex: MethodArgumentTypeMismatchException): ResponseEntity<ErrorResponse> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val commonErrorCode = CommonErrorCode.INVALID_REQUEST | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| log.warn { "Method argument type mismatch: ${ex.name}, value: ${ex.value}, requiredType: ${ex.requiredType}" } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val error = ErrorResponse.builder() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .status(commonErrorCode.getHttpStatus().value()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .message("Invalid value '${ex.value}' for parameter '${ex.name}'. Expected type: ${ex.requiredType?.simpleName}") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .code(commonErrorCode.getCode()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .build() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ResponseEntity(error, commonErrorCode.getHttpStatus()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+163
to
+182
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 민감정보/대용량 값의 로그·응답 반영 가능성 — 값 마스킹/절단 및 널-세이프 처리 권장
아래와 같이 값 마스킹/절단 및 널-세이프 처리를 적용해 주세요. @@
- log.warn { "Method argument type mismatch: ${ex.name}, value: ${ex.value}, requiredType: ${ex.requiredType}" }
+ // 민감 값 마스킹/절단 및 널-세이프 타입명 구성
+ val valueStr = ex.value?.toString()?.let { if (it.length > 100) it.take(100) + "..." else it } ?: "null"
+ val requiredTypeName = ex.requiredType?.simpleName ?: "Unknown"
+ log.warn { "Method argument type mismatch: name=${ex.name}, value=$valueStr, requiredType=$requiredTypeName" }
@@
- .message("Invalid value '${ex.value}' for parameter '${ex.name}'. Expected type: ${ex.requiredType?.simpleName}")
+ .message("Invalid value '$valueStr' for parameter '${ex.name}'. Expected type: $requiredTypeName")위 수정으로
📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * 그 외 모든 예외 처리 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| package org.yapp.infra.aop.aspect | ||
|
|
||
| import jakarta.servlet.http.HttpServletRequest | ||
| import org.aspectj.lang.ProceedingJoinPoint | ||
| import org.aspectj.lang.annotation.Around | ||
| import org.aspectj.lang.annotation.Aspect | ||
| import org.slf4j.LoggerFactory | ||
| import org.springframework.stereotype.Component | ||
| import org.springframework.web.context.request.RequestContextHolder | ||
| import org.springframework.web.context.request.ServletRequestAttributes | ||
| import org.yapp.infra.aop.properties.LoggingAopProperties | ||
| import java.time.Duration | ||
| import java.time.Instant | ||
|
|
||
| @Aspect | ||
| @Component | ||
| class ControllerLoggingAspect( | ||
| private val properties: LoggingAopProperties | ||
| ) { | ||
| private val log = LoggerFactory.getLogger(ControllerLoggingAspect::class.java) | ||
|
|
||
| @Around("org.yapp.infra.aop.pointcut.CommonPointcuts.controller() && !org.yapp.infra.aop.pointcut.CommonPointcuts.noLogging()") | ||
| fun logController(joinPoint: ProceedingJoinPoint): Any? { | ||
move-hoon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (!properties.controller.enabled) { | ||
| return joinPoint.proceed() | ||
| } | ||
|
|
||
| val startTime = Instant.now() | ||
| logRequestStart(joinPoint) | ||
|
|
||
| try { | ||
| val result = joinPoint.proceed() | ||
| logRequestSuccess(startTime) | ||
| return result | ||
| } catch (e: Throwable) { | ||
| throw e | ||
| } | ||
move-hoon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private fun logRequestStart(joinPoint: ProceedingJoinPoint) { | ||
| val signature = joinPoint.signature | ||
| val className = signature.declaringType.simpleName | ||
| val methodName = signature.name | ||
|
|
||
| val request = getCurrentRequest() | ||
| val httpMethod = request?.method ?: "UNKNOWN" | ||
| val uri = request?.requestURI ?: "UNKNOWN" | ||
|
|
||
| log.info("[API-REQ] {} {} | Controller: {}.{}", httpMethod, uri, className, methodName) | ||
| } | ||
|
|
||
| private fun logRequestSuccess(startTime: Instant) { | ||
| val executionTimeMs = getExecutionTimeMs(startTime) | ||
| log.info("[API-RES] Logic Duration: {}ms", executionTimeMs) | ||
| } | ||
|
|
||
| private fun getCurrentRequest(): HttpServletRequest? = | ||
| (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request | ||
|
|
||
| private fun getExecutionTimeMs(startTime: Instant): Long = | ||
| Duration.between(startTime, Instant.now()).toMillis() | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
AOP 스타터 의존성 존재 여부 확인 필요
본 PR의 핵심인 AOP(애스펙트) 로깅이 실제 런타임에서 동작하려면 실행 모듈(예: apis)에
spring-boot-starter-aop가 필요합니다. 현재 제공된 빌드 파일들에서는 확인되지 않습니다.아래 스크립트로 리포지토리 전체에서 AOP 스타터 추가 여부를 점검해 주세요.
apis 모듈에 추가가 필요하다면 다음 예시처럼 선언해 주세요(프로젝트 상수로 관리 중이면 해당 상수를 사용하세요).
dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") }🏁 Script executed:
Length of output: 233
apis 모듈에 AOP 스타터 의존성 추가 필요
스크립트 결과, 전체
build*.gradle*파일에서spring-boot-starter-aop의존성이 미검출되었습니다. AOP(애스펙트) 기반 로깅이 런타임에서 동작하려면apis/build.gradle.kts의dependencies블록에 아래 의존성을 추가해주세요.apis/build.gradle.ktsdependencies { … }블록 내부dependencies { implementation("org.springframework.boot:spring-boot-starter-aop") // …기존 의존성 }🤖 Prompt for AI Agents