Skip to content

Commit 38ff6ce

Browse files
authored
feat: Spring AOP를 활용한 MDC 로깅 아키텍처 구축 (#96)
* [BOOK-93] feat: infra - AOP 설정을 위한 AopConfig 클래스 추가 * [BOOK-93] feat: apis, infra - InfraBaseConfigGroup에 AOP 설정 클래스 추가 및 apis 모듈에서 사용 가능하도록 추가 * [BOOK-93] feat: infra, global-utils - 서비스 및 컨트롤러 레이어에 대한 로깅을 위한 Aspect 및 Pointcut 설정 추가 * [BOOK-93] chore: domain, infra - Spring Data Commons 및 Spring Web 추가 및 불필요한 의존성 정리 * [BOOK-93] chore: logback 의존성 제외 후 Log4j2 추가 및 서비스 레이어 AOP 로깅 시 파라미터 이름도 로깅할 수 있도록 kotlinOptions.javaParameters = true 옵션 추가 * [BOOK-93] chore: Dependencies.kt에 Spring Data Commons, Spring Web, Log4j2 의존성 추가 * [BOOK-93] delete: domain - 사용하지 않는 CoreDomainConfig.kt 파일 삭제 * [BOOK-93] feat: gateway - MDC 기반 요청 로깅 필터 구현 및 사용자/클라이언트 정보 기록 - HTTP 요청 시 traceId, client IP, 요청 정보, 사용자 ID를 MDC에 저장 - JWT 인증 사용자의 subject를 userId로 기록, 인증되지 않은 경우 "GUEST"로 처리 - 요청 처리 완료 후 MDC 클리어하여 다음 요청에 영향 없음 - OncePerRequestFilter 상속으로 각 요청마다 한 번만 실행 * [BOOK-93] feat: gateway - MdcLoggingFilter를 BearerTokenAuthenticationFilter 이후에 배치하여 JWT 인증 후 사용자 정보 MDC에 기록하도록 구현 * [BOOK-93] feat: infra - Log4j2 설정 파일 추가로 로깅 구성 및 패턴 정의 * [BOOK-93] feat: infra - 비동기 MDC 전파 작업을 위한 MdcTaskDecorator 클래스 추가 및 AsyncConfig에 등록 * [BOOK-93] chore: infra - 사용하지 않는 import문 제거 * [BOOK-93] feat: global-utils - MethodArgumentTypeMismatchException 전역 예외 처리기에 등록 * [BOOK-93] refactor: infra - mdc에서 이미 로깅하기 때문에 시간 로깅 제외 * [BOOK-93] chore: kapt 관련 의존성 정리 및 코드레빗 리뷰 반영 * [BOOK-93] chore: infra - datetime(6)로 로그 패턴 수정 * [BOOK-93] refactor: gateway - 신뢰할 수 있는 프록시 서버 뒤에서만 XFF를 신뢰할 수 있도록 변경 * [BOOK-93] chore: global-utils - 필요하지 않는 괄호 제외 * [BOOK-93] chore: buildSrc - KAPT 플러그인 추가 * [BOOK-93] fix: infra - 함수/메서드 오버라이드 관련 경고 해결 * [BOOK-93] refactor: infra - 마스킹 로직 리팩토링 * [BOOK-93] refactor: infra - 실행 전 MDC를 명시적으로 clear하여 이전 작업의 MDC 완전히 제거 * [BOOK-93] chore: apis - 디버깅용 로그 주석처리 * [BOOK-93] refactor: infra - prod 환경에서만 마스킹처리하도록 리팩토링 * [BOOK-93] refactor: infra - 배열의 타입까지 로그에 남길 수 있도록 리팩토링
1 parent f0c7431 commit 38ff6ce

File tree

25 files changed

+483
-32
lines changed

25 files changed

+483
-32
lines changed

apis/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ dependencies {
2222
implementation(Dependencies.BouncyCastle.BC_PROV)
2323
implementation(Dependencies.BouncyCastle.BC_PKIX)
2424

25-
annotationProcessor(Dependencies.Spring.CONFIGURATION_PROCESSOR)
25+
kapt(Dependencies.Spring.CONFIGURATION_PROCESSOR)
2626

2727
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
2828
testImplementation(Dependencies.TestContainers.MYSQL)

apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class AladinBookQueryService(
2525
private val MAX_ALADIN_RESULTS = 200 // Added constant
2626

2727
override fun searchBooks(request: BookSearchRequest): BookSearchResponse {
28-
log.info { "Service - Converting BookSearchRequest to AladinBookSearchRequest and calling Aladin API for book search." }
28+
// log.info { "Service - Converting BookSearchRequest to AladinBookSearchRequest and calling Aladin API for book search." }
2929
val aladinSearchRequest = AladinBookSearchRequest.of(
3030
request.validQuery(),
3131
request.queryType,
@@ -44,7 +44,7 @@ class AladinBookQueryService(
4444
throw BookException(BookErrorCode.ALADIN_API_SEARCH_FAILED, exception.message)
4545
}
4646

47-
log.info { "Before filtering - Full Response: $response" }
47+
// log.info { "Before filtering - Full Response: $response" }
4848

4949
val transformedItems = response.item.mapNotNull { item ->
5050
val validIsbn13 = getValidAndFilteredIsbn13(item)
@@ -75,13 +75,13 @@ class AladinBookQueryService(
7575
searchCategoryName = response.searchCategoryName,
7676
item = transformedItems
7777
)
78-
log.info { "After filtering - Full Response: $filteredResponse" }
78+
// log.info { "After filtering - Full Response: $filteredResponse" }
7979

8080
return BookSearchResponse.of(filteredResponse, isLastPage) // Passed isLastPage
8181
}
8282

8383
override fun getBookDetail(request: BookDetailRequest): BookDetailResponse {
84-
log.info("Service - Converting BookDetailRequest to AladinBookLookupRequest and calling Aladin API for book detail lookup.")
84+
// log.info("Service - Converting BookDetailRequest to AladinBookLookupRequest and calling Aladin API for book detail lookup.")
8585
val aladinLookupRequest = AladinBookLookupRequest.from(request.validIsbn13())
8686
val response: AladinBookDetailResponse = aladinApi.lookupBook(aladinLookupRequest)
8787
.onSuccess { response ->

apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import org.yapp.infra.InfraBaseConfigGroup
1313
InfraBaseConfigGroup.REST_CLIENT,
1414
InfraBaseConfigGroup.QUERY_DSL,
1515
InfraBaseConfigGroup.PAGE,
16+
InfraBaseConfigGroup.AOP
1617
]
1718
)
1819
class InfraConfig

build.gradle.kts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,22 @@ subprojects {
4848
apply(plugin = Plugins.Kotlin.JPA)
4949
apply(plugin = Plugins.Kotlin.JVM)
5050
apply(plugin = Plugins.JACOCO)
51+
apply(plugin = Plugins.Kotlin.KAPT)
5152

5253
java {
5354
toolchain {
5455
languageVersion.set(JavaLanguageVersion.of(Versions.JAVA_VERSION.toInt()))
5556
}
5657
}
5758

59+
configurations.all {
60+
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
61+
}
62+
63+
dependencies {
64+
implementation(Dependencies.Spring.STARTER_LOG4J2)
65+
}
66+
5867
plugins.withId(Plugins.Kotlin.ALLOPEN) {
5968
extensions.configure<org.jetbrains.kotlin.allopen.gradle.AllOpenExtension> {
6069
annotation("jakarta.persistence.Entity")
@@ -71,6 +80,7 @@ subprojects {
7180
"-Xconsistent-data-class-copy-visibility"
7281
)
7382
jvmTarget = Versions.JAVA_VERSION
83+
javaParameters = true
7484
}
7585
}
7686
}

buildSrc/src/main/kotlin/Dependencies.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ object Dependencies {
1212
const val BOOT_STARTER_OAUTH2_CLIENT = "org.springframework.boot:spring-boot-starter-oauth2-client"
1313
const val KOTLIN_REFLECT = "org.jetbrains.kotlin:kotlin-reflect"
1414
const val CONFIGURATION_PROCESSOR = "org.springframework.boot:spring-boot-configuration-processor"
15+
const val SPRING_DATA_COMMONS = "org.springframework.data:spring-data-commons"
16+
const val SPRING_WEB = "org.springframework:spring-web"
17+
const val STARTER_LOG4J2 = "org.springframework.boot:spring-boot-starter-log4j2"
1518
}
1619

1720
object Database {

buildSrc/src/main/kotlin/Plugins.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ object Plugins {
1111
const val SPRING = "org.jetbrains.kotlin.plugin.spring"
1212
const val JPA = "org.jetbrains.kotlin.plugin.jpa"
1313
const val JVM = "org.jetbrains.kotlin.jvm"
14+
const val KAPT = "org.jetbrains.kotlin.kapt"
1415

1516
object Short {
1617
const val KAPT = "kapt"

domain/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar
22

33
dependencies {
44
implementation(project(Dependencies.Projects.GLOBAL_UTILS))
5-
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
6-
implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA)
75
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
6+
implementation(Dependencies.Spring.SPRING_DATA_COMMONS) // Pageable, Page용
7+
implementation(Dependencies.Spring.SPRING_WEB) // HttpsStatus용으로 추가한 것이기 때문에 추후 Global-utils 모듈에 httpStatus를 정의하고 이를 의존해서 사용하도록 바꾸면 해당 의존성 삭제 가능
88
}
99

1010
tasks {

domain/src/main/kotlin/org/yapp/domain/config/CoreDomainConfig.kt

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package org.yapp.gateway.filter
2+
3+
import jakarta.servlet.FilterChain
4+
import jakarta.servlet.http.HttpServletRequest
5+
import jakarta.servlet.http.HttpServletResponse
6+
import org.slf4j.MDC
7+
import org.springframework.security.core.context.SecurityContextHolder
8+
import org.springframework.security.oauth2.jwt.Jwt
9+
import org.springframework.stereotype.Component
10+
import org.springframework.web.filter.OncePerRequestFilter
11+
import java.util.*
12+
13+
@Component
14+
class MdcLoggingFilter : OncePerRequestFilter() {
15+
companion object {
16+
private const val TRACE_ID_HEADER = "X-Request-ID"
17+
private const val XFF_HEADER = "X-Forwarded-For"
18+
private const val X_REAL_IP_HEADER = "X-Real-IP"
19+
private const val TRACE_ID_KEY = "traceId"
20+
private const val USER_ID_KEY = "userId"
21+
private const val CLIENT_IP_KEY = "clientIp"
22+
private const val REQUEST_INFO_KEY = "requestInfo"
23+
private const val DEFAULT_GUEST_USER = "GUEST"
24+
}
25+
26+
override fun doFilterInternal(
27+
request: HttpServletRequest,
28+
response: HttpServletResponse,
29+
filterChain: FilterChain
30+
) {
31+
val traceId = resolveTraceId(request)
32+
populateMdc(request, traceId)
33+
34+
try {
35+
filterChain.doFilter(request, response)
36+
} finally {
37+
MDC.clear()
38+
}
39+
}
40+
41+
private fun resolveTraceId(request: HttpServletRequest): String {
42+
val incomingTraceId = request.getHeader(TRACE_ID_HEADER)
43+
return incomingTraceId?.takeIf { it.isNotBlank() }
44+
?: UUID.randomUUID().toString().replace("-", "")
45+
}
46+
47+
private fun populateMdc(request: HttpServletRequest, traceId: String) {
48+
MDC.put(TRACE_ID_KEY, traceId)
49+
MDC.put(CLIENT_IP_KEY, extractClientIp(request))
50+
MDC.put(REQUEST_INFO_KEY, "${request.method} ${request.requestURI}")
51+
52+
val userId = resolveUserId()
53+
MDC.put(USER_ID_KEY, userId ?: DEFAULT_GUEST_USER)
54+
}
55+
56+
private fun extractClientIp(request: HttpServletRequest): String {
57+
val xffHeader = request.getHeader(XFF_HEADER)
58+
if (!xffHeader.isNullOrBlank()) {
59+
return xffHeader.split(",").first().trim()
60+
}
61+
62+
val xRealIp = request.getHeader(X_REAL_IP_HEADER)
63+
if (!xRealIp.isNullOrBlank()) {
64+
return xRealIp.trim()
65+
}
66+
67+
return request.remoteAddr
68+
}
69+
70+
private fun resolveUserId(): String? {
71+
val authentication = SecurityContextHolder.getContext().authentication ?: return null
72+
73+
return when (val principal = authentication.principal) {
74+
is Jwt -> principal.subject
75+
else -> principal?.toString()
76+
}
77+
}
78+
}
79+

gateway/src/main/kotlin/org/yapp/gateway/security/SecurityConfig.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@ package org.yapp.gateway.security
22

33
import org.springframework.context.annotation.Bean
44
import org.springframework.context.annotation.Configuration
5+
import org.springframework.core.convert.converter.Converter
6+
import org.springframework.security.authentication.AbstractAuthenticationToken
57
import org.springframework.security.config.annotation.web.builders.HttpSecurity
68
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
79
import org.springframework.security.config.http.SessionCreationPolicy
8-
import org.springframework.core.convert.converter.Converter
9-
import org.springframework.http.HttpMethod
10-
import org.springframework.security.authentication.AbstractAuthenticationToken
1110
import org.springframework.security.oauth2.jwt.Jwt
11+
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter
1212
import org.springframework.security.web.SecurityFilterChain
13+
import org.yapp.gateway.filter.MdcLoggingFilter
1314

1415
@Configuration
1516
@EnableWebSecurity
1617
class SecurityConfig(
1718
private val jwtAuthenticationConverter: Converter<Jwt, out AbstractAuthenticationToken>,
1819
private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint,
19-
private val customAccessDeniedHandler: CustomAccessDeniedHandler
20+
private val customAccessDeniedHandler: CustomAccessDeniedHandler,
21+
private val mdcLoggingFilter: MdcLoggingFilter
2022
) {
2123
companion object {
2224
private val WHITELIST_URLS = arrayOf(
@@ -53,6 +55,7 @@ class SecurityConfig(
5355
it.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
5456
it.anyRequest().authenticated()
5557
}
58+
.addFilterAfter(mdcLoggingFilter, BearerTokenAuthenticationFilter::class.java)
5659

5760
return http.build()
5861
}

0 commit comments

Comments
 (0)