Skip to content
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ val bouncyCastleVersion = "1.78"
val awsSdkVersion = "2.27.0"
val springDocVersion = "2.6.0"
val jasyptVersion = "3.0.5"
val logstashEncoderVersion = "8.0"
val kotestVersion = "5.9.1"
val kotestExtensionsVersion = "1.3.0"
val mockkVersion = "1.13.10"
Expand Down Expand Up @@ -77,6 +78,9 @@ dependencies {
// Jasypt (암호화)
implementation("com.github.ulisesbocchio:jasypt-spring-boot-starter:$jasyptVersion")

// Logback JSON Encoder (구조화된 로그 출력)
implementation("net.logstash.logback:logstash-logback-encoder:$logstashEncoderVersion")

// JPA
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.postgresql:postgresql")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.yapp2app.auth.infra.security.config

import com.yapp2app.auth.infra.security.filter.AuthMdcFilter
import com.yapp2app.auth.infra.security.filter.JwtAuthenticationFilter
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
Expand Down Expand Up @@ -52,5 +53,6 @@ class SecurityConfig(private val corsConfigurationSource: CorsConfigurationSourc
it.anyRequest().authenticated()
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java)
.addFilterAfter(AuthMdcFilter(), JwtAuthenticationFilter::class.java)
.build()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.yapp2app.auth.infra.security.filter

import com.yapp2app.auth.infra.security.token.UserPrincipal
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.web.filter.OncePerRequestFilter

/**
* fileName : AuthMdcFilter
* author : koo
* date : 2025. 12. 31.
* description : 인증 후에 실행되어 인증된 사용자 정보를 MDC에 추가하는 필터
* SecurityContext에서 인증 정보를 가져와 userId를 MDC에 설정
* SecurityFilterChain 내부에서 JwtAuthenticationFilter 다음에 실행
*/
class AuthMdcFilter : OncePerRequestFilter() {

companion object {
const val USER_ID = "userId"
}

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
// SecurityContext에서 인증 정보 가져오기
val authentication = SecurityContextHolder.getContext().authentication

// 인증된 사용자인 경우 MDC에 userId 추가
if (authentication != null && authentication.isAuthenticated && authentication.principal != "anonymousUser") {
val userPrincipal = authentication.principal as UserPrincipal

// DB상 name을 가져오는 코드 (택 1)
val userId = userPrincipal.id.toString()

MDC.put(USER_ID, userId)
}

filterChain.doFilter(request, response)
}
}
86 changes: 86 additions & 0 deletions src/main/kotlin/com/yapp2app/common/filter/RequestMdcFilter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.yapp2app.common.filter

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 java.util.UUID

/**
* fileName : RequestMdcFilter
* author : koo
* date : 2025. 12. 31.
* description : 인증 전에 실행되어 요청별 기본 정보를 MDC에 설정하는 필터
* Request ID, URI, Method, Client IP 등을 추가
*/
class RequestMdcFilter : OncePerRequestFilter() {

companion object {
const val REQUEST_ID = "requestId"
const val REQUEST_URI = "requestUri"
const val REQUEST_METHOD = "requestMethod"
const val CLIENT_IP = "clientIp"
}

override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
try {
// Request ID 설정 (헤더에서 가져오거나 새로 생성)
val requestId = request.getHeader("X-Request-ID") ?: generateRequestId()
MDC.put(REQUEST_ID, requestId)

// 요청 정보 설정
MDC.put(REQUEST_URI, request.requestURI)
MDC.put(REQUEST_METHOD, request.method)
MDC.put(CLIENT_IP, getClientIp(request))

// Response 헤더에 Request ID 추가
response.setHeader("X-Request-ID", requestId)

filterChain.doFilter(request, response)
} finally {
// MDC 정리 (메모리 누수 방지)
// 가장 먼저 실행되고 가장 마지막에 종료되므로 여기서 전체 MDC 정리
MDC.clear()
}
}

/**
* 고유한 Request ID 생성
*/
private fun generateRequestId(): String = UUID.randomUUID().toString().replace("-", "")

/**
* 클라이언트 IP 주소 추출
* 프록시나 로드밸런서를 거치는 경우 X-Forwarded-For 헤더에서 실제 클라이언트 IP를 추출
*/
private fun getClientIp(request: HttpServletRequest): String {
val headers = listOf(
"X-Forwarded-For",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_X_FORWARDED_FOR",
"HTTP_X_FORWARDED",
"HTTP_X_CLUSTER_CLIENT_IP",
"HTTP_CLIENT_IP",
"HTTP_FORWARDED_FOR",
"HTTP_FORWARDED",
"HTTP_VIA",
"REMOTE_ADDR",
)

for (header in headers) {
val ip = request.getHeader(header)
if (!ip.isNullOrBlank() && ip != "unknown") {
// X-Forwarded-For는 여러 IP가 콤마로 구분될 수 있음 (첫 번째가 실제 클라이언트 IP)
return ip.split(",").firstOrNull()?.trim() ?: ip
}
}

return request.remoteAddr ?: "unknown"
}
}
25 changes: 25 additions & 0 deletions src/main/kotlin/com/yapp2app/common/filter/ServletFilterConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.yapp2app.common.filter

import org.springframework.boot.autoconfigure.security.SecurityProperties
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

/**
* fileName : ServletFilterConfig
* author : koo
* date : 2026. 1. 8. 오후 3:44
* description :
*/
@Configuration
class ServletFilterConfig {

@Bean
fun requestMdcFilter(): RequestMdcFilter = RequestMdcFilter()

@Bean
fun requestMdcFilterRegistration(filter: RequestMdcFilter): FilterRegistrationBean<RequestMdcFilter> =
FilterRegistrationBean(filter).apply {
order = SecurityProperties.DEFAULT_FILTER_ORDER - 1
}
}
106 changes: 106 additions & 0 deletions src/main/resources/logback-spring.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>

<!-- 로그 패턴 정의 -->
<!-- 개발 환경 (local, test): 색상이 포함된 읽기 쉬운 형식 -->
<property name="CONSOLE_LOG_PATTERN_DEV"
value="%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} [%X{requestId:-NO_REQUEST_ID}] [%X{userId:-ANONYMOUS}] %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

<!-- 개발 환경용 콘솔 출력 (사람이 읽기 좋은 형식) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_LOG_PATTERN_DEV}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>

<!-- 운영 환경용 JSON 콘솔 출력 (Loki 최적화) -->
<appender name="CONSOLE_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<!-- 타임스탬프 형식 -->
<timestampPattern>yyyy-MM-dd'T'HH:mm:ss.SSSXXX</timestampPattern>

<!-- MDC 값을 JSON 필드로 포함 -->
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
<includeMdcKeyName>requestMethod</includeMdcKeyName>
<includeMdcKeyName>requestUri</includeMdcKeyName>
<includeMdcKeyName>clientIp</includeMdcKeyName>

<!-- 기본 필드명 커스터마이징 -->
<fieldNames>
<timestamp>timestamp</timestamp>
<version>[ignore]</version>
<levelValue>[ignore]</levelValue>
</fieldNames>

<!-- 추가 정적 필드 -->
<customFields>{"application":"yapp"}</customFields>

<!-- 스택 트레이스 포함 -->
<throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
<maxDepthPerThrowable>30</maxDepthPerThrowable>
<maxLength>2048</maxLength>
<shortenedClassNameLength>20</shortenedClassNameLength>
<exclude>^sun\.reflect\..*\.invoke</exclude>
<exclude>^net\.sf\.cglib\.proxy\.MethodProxy\.invoke</exclude>
<rootCauseFirst>true</rootCauseFirst>
</throwableConverter>
</encoder>
</appender>

<!-- 프로파일별 설정 -->

<!-- local 프로파일: 개발 환경 (색상 포함, 상세 로그) -->
<springProfile name="local">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<!-- 개발 시 디버그 로그 활성화 -->
<logger name="com.yapp2app" level="DEBUG"/>
<logger name="org.springframework.web" level="DEBUG"/>
<logger name="org.springframework.security" level="DEBUG"/>
</springProfile>

<!-- test 프로파일: 테스트 환경 -->
<springProfile name="test">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<logger name="com.yapp2app" level="DEBUG"/>
</springProfile>

<!-- staging 프로파일: 스테이징 환경 (k8s + Loki, JSON 로그) -->
<springProfile name="staging">
<root level="INFO">
<appender-ref ref="CONSOLE_JSON"/>
</root>

<logger name="com.yapp2app" level="INFO"/>
<logger name="org.springframework" level="WARN"/>
</springProfile>

<!-- production 프로파일: 운영 환경 (k8s + Loki, JSON 로그) -->
<springProfile name="prod,production">
<root level="INFO">
<appender-ref ref="CONSOLE_JSON"/>
</root>

<logger name="com.yapp2app" level="INFO"/>
<logger name="org.springframework" level="WARN"/>
<logger name="org.hibernate" level="WARN"/>
</springProfile>

<!-- 기본 설정 (프로파일 미지정) -->
<springProfile name="!local &amp; !test &amp; !staging &amp; !prod &amp; !production">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<logger name="com.yapp2app" level="DEBUG"/>
</springProfile>

</configuration>
Loading