본문 바로가기

백엔드 개발일지

[Spring] SpringBoot에서 AOP와 MDC를 활용한 로깅 시스템 구축하기 (feat. 민감 정보 마스킹)

반응형

1. 개요

YAPP에서 진행 중인 ‘그라밋’ 프로젝트에서는 개발 및 운영 환경에서 발생하는 로그를 AWS CloudWatch에 기록하고 있습니다.
 
지금까지는 스케줄링 작업이나 예외 로그만 수집해왔지만, 서비스 고도화 과정에서 요청과 응답도 로깅할 필요성이 커졌습니다.
 

@RestController
@RequestMapping("/api")
class ExampleController {

    private val log: Logger = LoggerFactory.getLogger(this::class.java)

    @GetMapping("/example")
    fun getExample(@RequestParam name: String): ResponseEntity<String> {
        log.info("[Request] GET /api/example | name = {}", name)

        val response = "Hello, $name"
        log.info("[Response] GET /api/example | result = {}", response)

        return ResponseEntity.ok(response)
    }
}

 
이를 위해 컨트롤러에서 직접 로그를 남기는 방법도 고려할 수 있습니다. 하지만, 수십 개의 컨트롤러 메서드마다 중복된 로그 코드를 작성하는 것은 비효율적이며, 유지보수에도 부담이 됩니다.
 
이러한 반복적인 로깅 작업을 효율적으로 해결할 방법이 바로 AOP(Aspect-Oriented Programming)입니다.
AOP를 활용하면 모든 컨트롤러에 일관된 방식으로 요청/응답 로그를 자동으로 남길 수 있으며, 중복 코드 없이 로깅을 통합 관리할 수 있습니다.
 
 
 


2. AOP

1) 개념

AOP(Aspect-Oriented Programming, 관점 지향 프로그래밍)는 핵심 비즈니스 로직과 공통 관심 사항(Cross-Cutting Concern)을 분리하는 프로그래밍 기법입니다.
 
어떤 애플리케이션이든 로깅, 트랜잭션 관리, 보안 검증, 성능 모니터링 같은 반복적으로 필요한 기능들이 존재합니다.
하지만, 이런 기능을 모든 클래스와 메서드에 직접 구현하면 코드 중복이 발생하고 유지보수가 어려워지는 문제가 생깁니다.
AOP는 이러한 문제를 해결하기 위해, 공통 관심 사항을 핵심 비즈니스 로직과 분리하여 일괄적으로 적용할 수 있도록 도와줍니다.

 

2) 구성 요소

개념설명
Aspect여러 부분에 걸쳐 공통적으로 적용되는 기능 (ex. 로깅, 트랜잭션, 보안)
JoinPointAOP가 적용될 수 있는 모든 실행 지점 (ex. 메서드 호출)
PointcutAOP를 적용할 특정 지점(메서드, 클래스 등)을 지정하는 표현식
Advice실제 실행될 로직 (Before, After, Around 등)
WeavingAdvice를 핵심 로직에 결합하는 과정

 
 
 
 

3. AOP와 MDC를 활용한 로깅 시스템 구축

1) AOP를 활용한 요청 및 응답 로깅

기존에는 컨트롤러에서 직접 log.info()를 호출해 요청과 응답을 로깅했습니다.
하지만, 모든 컨트롤러에 중복된 코드를 작성해야 하고, 유지보수가 어려운 단점이 있었습니다.
이를 해결하기 위해 Spring AOP를 활용하여 모든 컨트롤러의 요청과 응답을 자동으로 로깅하는 시스템을 구축했습니다.
 
 

allController

allController는 AOP를 적용할 대상을 정의하는 Pointcut 역할을 수행합니다.
 
Pointcut을 활용하면 특정 패키지나 어노테이션이 붙은 메서드만 골라서 AOP 적용 가능합니다. 밑에서 설명할 `@Around("allController()")`와 `@AfterReturning("allController()")`에서 이 Pointcut을 사용해 컨트롤러의 요청과 응답을 로깅할 것입니다.

@Pointcut("execution(* com.dobby.backend.presentation.api.controller..*Controller.*(..))")
private fun allController() {}
  • 특정한 실행 지점(Pointcut)을 정의
  • `com.dobby.backend.presentation.api.controller` 패키지 아래의 모든 컨트롤러 메서드 실행을 감지해 로깅을 적용

 
 

logRequest

모든 컨트롤러의 요청을 가로채어(taskId 생성, 로깅) 처리하는 함수입니다.
 
`@Around`는 대상 메서드(JoinPoint) 실행 전후로 추가 로직을 실행할 수 있습니다. 즉, `joinPoint.proceed()`를 호출하면 원래의 컨트롤러 메서드를 실행하고, 호출하지 않으면 실행되지 않습니다.

@Around("allController()")
fun logRequest(joinPoint: ProceedingJoinPoint): Any? {
    val taskId = generateTaskId()
    MDC.put("taskId", taskId)

    val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
    val method = request?.method ?: "UNKNOWN_METHOD"
    val requestURI = request?.requestURI ?: "UNKNOWN_URI"
    val decodedURI = URLDecoder.decode(requestURI, "UTF-8")

    val args = joinPoint.args.map { SensitiveDataMasker.mask(it) }

    log.info("[{}] {} -> {} args={}", taskId, method, decodedURI, args)

    return joinPoint.proceed()
}
  • 요청이 들어오면 실행되며, 해당 요청에 대한 taskId를 생성
  • HTTP 요청 정보를 가져와서 로깅
  • 컨트롤러의 원래 메서드(joinPoint.proceed())를 실행하여 정상적인 흐름 유지

 
@Around의 동작 흐름

  1. 요청이 들어오면 @Around가 실행됨
  2. AOP 로직이 먼저 실행되면서 요청 정보를 가져와 로깅
  3. joinPoint.proceed()를 호출하면 실제 컨트롤러 메서드가 실행됨
  4. 컨트롤러 실행 후에도 추가적인 작업(응답 가공, 후처리 등)이 가능

 
로그 예시

[20250226093045-4R6T7F] POST -> /api/user/login args={contactEmail=c*****@example.com}

 
 
 

logResponse

컨트롤러의 메서드가 정상적으로 실행된 후, 응답을 로깅하는 함수입니다.
 

@AfterReturning(
    value = "execution(* com.dobby.backend.presentation.api.controller..*Controller.*(..))",
    returning = "result"
)
fun logResponse(joinPoint: JoinPoint, result: Any?) {
    val taskId = MDC.get("taskId") ?: generateTaskId()
    val methodSignature = joinPoint.signature as MethodSignature
    val controllerMethodName = methodSignature.method.name

    log.info("[{}] <- method: {}, result: {}", taskId, controllerMethodName, SensitiveDataMasker.mask(result))
    MDC.remove("taskId") // 요청이 끝난 후 MDC에서 taskId 제거 (메모리 누수 방지)
}
  • 컨트롤러 메서드가 정상적으로 실행된 후 실행되며, 응답 데이터를 로깅
  • taskId를 가져와 요청과 응답을 연관시킴
  • 응답 데이터에서도 민감 정보를 마스킹하여 기록
  • MDC에서 taskId를 제거하여 메모리 누수를 방지 → 2번에서 자세히 설명

 
로그 예시

[20250226093045-4R6T7F] <- method: login, result={name=김*****, contactEmail=c*****@example.com}

 
 

2) MDC를 활용한 taskId 유지 방식

서비스 운영에서는 요청과 응답을 하나의 흐름으로 추적하는 것이 중요합니다. 하지만, 멀티스레드 환경에서는 각 요청이 어떤 응답과 연결되는지 식별하기 어렵습니다.
 
이를 해결하기 위해 MDC(Mapped Diagnostic Context)를 활용하여 각 요청에 taskId를 부여하고, 해당 ID를 로그에 함께 기록합니다. 이를 통해, 모든 로그에서 동일한 taskId를 사용하여 요청과 응답을 쉽게 추적할 수 있습니다.
 
MDC를 활용한 이유는 프로젝트에서 비동기 처리를 수행하고 있기 때문입니다. Spring의 기본적인 MDC는 ThreadLocal 기반으로 동작하기 때문에, 비동기 환경에서 컨텍스트가 변경되면 taskId가 유지되지 않을 수 있는 문제가 발생합니다.
이를 해결하기 위해 taskId를 유지할 수 있도록 MDC를 활용했습니다.
 
 
taskId 생성 및 MDC 저장 (요청 시점)

@Around("allController()")
fun logRequest(joinPoint: ProceedingJoinPoint): Any? {
    val taskId = generateTaskId()
    MDC.put("taskId", taskId)  // 요청이 들어올 때 taskId를 MDC에 저장

    // ...

    log.info("[{}] {} -> {} args={}", taskId, method, decodedURI, args)

    return joinPoint.proceed()
}

 

taskId 유지 및 응답 로깅 (응답 시점)

@AfterReturning(
    value = "execution(* com.dobby.backend.presentation.api.controller..*Controller.*(..))",
    returning = "result"
)
fun logResponse(joinPoint: JoinPoint, result: Any?) {
    val taskId = MDC.get("taskId") ?: generateTaskId()  // taskId가 없으면 새로 생성 (비동기 문제 대비)
    // ...

    log.info("[{}] <- method: {}, result: {}", taskId, controllerMethodName, SensitiveDataMasker.mask(result))
    MDC.remove("taskId") // 요청이 끝난 후 MDC에서 taskId 제거 (메모리 누수 방지)
}

 

 

3) 민감 정보 마스킹을 위한 SensitiveDataMasker 구현

현재 모든 로그는 AWS CloudWatch로 전송되고 있기 때문에, 사용자의 민감 정보가 그대로 기록되지 않도록 마스킹 처리가 필요하다고 판단했습니다.
 
이를 위해, 사전에 정의된 민감 정보를 자동으로 마스킹할 수 있도록 SensitiveDataMasker를 구현하겠습니다.

마스킹 전 (위험)마스킹 후 (안전)
{ "name": "김철수", "contactEmail": "chulsoo@example.com" }{ "name": "김*****", "contactEmail": "c*****@example.com" }

 
 

object SensitiveDataMasker {
    private val objectMapper: ObjectMapper = jacksonObjectMapper()

    private val maskPatterns: Map<String, (String) -> String> = mapOf(
        "name" to { value -> value.take(1) + "*****" },
        "oauthEmail" to { value -> value.first() + "*****@" + value.substringAfter("@") },
        "univEmail" to { value -> value.first() + "*****@" + value.substringAfter("@") },
        "contactEmail" to { value -> value.first() + "*****@" + value.substringAfter("@") },
        "univName" to { value -> value.take(1) + "*******" },
        "major" to { value -> value.take(1) + "********" },
        "labInfo" to { _ -> "*****" }
    )

    /**
     * 특정 키워드가 포함된 민감 정보를 마스킹 처리
     */
    fun mask(data: Any?): Any? {
        if (data == null) return null

        return try {
            val jsonString = objectMapper.writeValueAsString(data)
            var maskedString = jsonString
            for ((key, maskFunction) in maskPatterns) {
                maskedString = maskedString.replace(Regex("(?<=\"$key\":\\s?\")[^\"]+")) {
                    maskFunction(it.value)
                }
            }

            objectMapper.readValue<Map<String, Any>>(maskedString)
        } catch (e: Exception) {
            maskPatterns.mapValues { it.value("*****") }
        }
    }
}

 
name, oauthEmail과 같은 민감 정보들은 *로 마스킹하도록 구현한 object입니다.
 

@Around("allController()")
fun logRequest(joinPoint: ProceedingJoinPoint): Any? {
    val taskId = generateTaskId()
    MDC.put("taskId", taskId)

    val request = (RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)?.request
    val method = request?.method ?: "UNKNOWN_METHOD"
    val requestURI = request?.requestURI ?: "UNKNOWN_URI"
    val decodedURI = URLDecoder.decode(requestURI, "UTF-8")

    // SensitiveDataMasker 호출
    val args = joinPoint.args.map { SensitiveDataMasker.mask(it) }

    log.info("[{}] {} -> {} args={}", taskId, method, decodedURI, args)
}

 
LoggingAspect 클래스 안에서는 위와 같이 SensitiveDataMasker를 호출할 수 있습니다.
 
 

마스킹 적용 결과

적용 후에는 사용자의 민감 정보가 마스킹된 상태로 로그에 기록됩니다. 이제 AWS CloudWatch로 전송되는 로그에서도 개인정보가 그대로 노출되지 않으므로, 보안이 한층 강화됩니다. 👍

반응형