이전부터 키오스쿨에 로깅 시스템은 필요하다고 생각하긴 했다.

아무래도 아무런 정보없이 이런 저런 문제를 해결하기는 쉽지 않으니깐…

그래서 이번에 로깅 시스템을 추가해보려고 하는데… 사실 이 분야에 대해서는 아는 지식이 전무해서… 이번에 새로 나온 gemini-cli를 활용해서 시스템을 구축해보았다!

일단 기본적으로 프롬프트도 잘 못짜기도 하고!! 이전에 gemini한테 로그와 모니터링 시스템으로 상담을 한 적이 있어서 그걸 토대로 프롬프트를 부탁했다.


저는 Spring Boot 프로젝트를 위한 **자동화된 로그 시스템** 구축 방안을 찾고 있습니다. 제가 원하는 시스템은 다음과 같은 핵심 기능을 갖춰야 합니다:

1.  **자동 로깅 (Auto-Logging):**
    * 개발자가 Logger 객체를 직접 주입받아 로그를 남기지 않더라도, **주요 애플리케이션 이벤트 (예: 컨트롤러 엔드포인트 호출, 서비스 메서드 실행, 데이터베이스 접근 등)가 자동으로 기록**되길 원합니다.
    * **새로운 엔드포인트나 메서드가 추가될 때 별도의 코드 수정 없이** 해당 호출 정보(메서드명, 인자, 반환 값, 실행 시간 등)가 자동으로 로깅되어야 합니다.

2.  **민감 정보 자동 마스킹 (Automatic Data Masking):**
    * 개발자가 로그 레벨에서 개별 필드를 수동으로 마스킹하는 대신, 특정 필드에 **간단한 어노테이션 (`@Masked`, `@Sensitive` 등)을 추가**하는 것만으로 해당 필드의 값이 로그에 노출되지 않도록 (예: `****` 또는 `[MASKED]`) 처리되길 원합니다.
    * 이 마스킹 기능은 **로그 출력 시점에 동적으로 적용**되어야 합니다.

이러한 요구사항을 만족하는 로그 시스템 아키텍처와 구체적인 Spring Boot 구현 방안, 그리고 필요한 라이브러리 및 설정에 대해 상세히 설명해 주세요. **각 기능별 코드 예시도 함께 제공**해 주시면 좋겠습니다.

백엔드에는 **Loki**를 로그 저장소로, **Promtail**을 로그 수집 에이전트로, **Grafana**를 시각화 도구로 활용하는 스택을 고려하고 있습니다. Spring Boot 애플리케이션의 로그가 이 스택으로 효과적으로 연동될 수 있는 방안도 함께 제안해 주세요. 로그는 **JSON 형식**으로 출력되어 Loki에서 분석하기 용이하도록 구성되어야 합니다.

이전에 내가 원했던 구상으로는

  1. Logger와 같이 개발자가 직접 하나하나 치는 방식은 안된다.
  2. http 요청과 응답 위주의 로그가 필요하다
  3. 민감 정보 마스킹도 있어야하며 해당 기능은 DX가 좋아야한다.

이런 방식이었다.

그렇게 gemini-cli에게 일을 시켜본 결과… 아주 만족스럽게 잘하더라… 내 일자리에 위협을 아주 강하게 주는….

우리 프로젝트에서는 아래와 같이 구성되었다.

LoggingAspect

package com.kioschool.kioschoolapi.global.common.annotation

import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.servlet.ServletRequest
import jakarta.servlet.ServletResponse
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.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.util.StopWatch
import org.springframework.web.multipart.MultipartFile

@Aspect
@Component
class LoggingAspect(private val objectMapper: ObjectMapper) {

    private val log = LoggerFactory.getLogger(this.javaClass)

    // Pointcut: com.kioschool.kioschoolapi 하위의 모든 컨트롤러와 서비스에 적용
    @Pointcut(
        "within(@org.springframework.web.bind.annotation.RestController *)"
    )
    fun isApiService() {
    }

    @Around("isApiService()")
    fun logExecutionTime(joinPoint: ProceedingJoinPoint): Any? {
        val stopWatch = StopWatch()
        stopWatch.start()

        val className = joinPoint.signature.declaringTypeName
        val methodName = joinPoint.signature.name
        val args = joinPoint.args

        // Filter out web-related objects from arguments
        val filteredArgs = filterWebObjects(args)

        // 메서드 실행 전 로그
        log.info(
            "--> {}#{}() called with args: {}",
            className,
            methodName,
            objectMapper.writeValueAsString(filteredArgs)
        )

        var result: Any? = null
        try {
            result = joinPoint.proceed()
            return result
        } catch (e: Exception) {
            log.error("<-- {}#{}() threw exception: {}", className, methodName, e.message)
            throw e // 예외를 다시 던져서 트랜잭션 등에 영향을 주지 않도록 함
        } finally {
            stopWatch.stop()
            val executionTime = stopWatch.totalTimeMillis

            // Filter out web-related objects from result
            val filteredResult = filterWebObjects(result)

            // 메서드 실행 후 로그
            log.info(
                "<-- {}#{}() returned: {} (execution time: {}ms)",
                className,
                methodName,
                objectMapper.writeValueAsString(filteredResult),
                executionTime
            )
        }
    }

    private fun filterWebObjects(obj: Any?): Any? {
        return when (obj) {
            is Array<*> -> obj.map { filterWebObjects(it) }.toTypedArray()
            is Collection<*> -> obj.map { filterWebObjects(it) }
            is ServletRequest, is ServletResponse, is MultipartFile -> "[WEB_OBJECT]"
            else -> obj
        }
    }
}

MaskingSerializer

package com.kioschool.kioschoolapi.global.common.util

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider

class MaskingSerializer : JsonSerializer<Any>() {
    override fun serialize(value: Any?, gen: JsonGenerator, serializers: SerializerProvider) {
        if (value != null) {
            gen.writeString("****")
        } else {
            gen.writeNull()
        }
    }
}