티스토리 뷰

GPT가 뽑아준 제목과 썸네일. 날이 갈수록 발전하네

개요

난 꽤 오랫동안 "서비스 로그를 어떻게 남겨야 할까?"라는 고민을 해왔고, 이 고민은 다음의 블로그 글들로 이어져 왔다.

스프링부트 서비스에 LOG 남기기 (with. Logback)

스프링부트 AOP를 이용해 로깅 처리하기

스프링부트에서 Multipart/form-data 요청의 MultipartFile 정보 로그 남기기

Terraform으로 EKS 배포하기 11. Grafana Loki와 로그 모니터링

 

로그를 남길 때 가장 많이 고민한 건 다음과 같은 질문들이었다.

 

  • API마다 어떤 로그를 남겨야 하는가?
  • 예외 발생 시 어떤 정보를 남겨야 대응하기 좋을까?
  • known 에러는 어떻게 표기할까?
  • 서버 에러는 어떻게, 또 알람은 어떤 기준으로 발생시킬까?

이번 서비스 개발을 계기로, 어느 정도 최종안을 정리할 수 있었다. 이벤트 기반 아키텍처(EDA)를 사용하든, MSA 환경이든 "하나의 모듈에서 로그를 어떻게 남겨야 할까"라는 질문에 대한 나름의 답을 제시하는 글이다.

어떤 로그를 남겨야할까?

내가 생각하는 로그의 가장 기본적인 요소는 규격화다.

 

로그는 성공/실패 여부와 상관없이 일관된 구조로 남겨져야 하며, 그래야 추후 검색, 분석, 필터링, 경보 설정 등이 가능하다.

 

그리고 로그는 단순히 에러 추적용이 아니다. "사용자가 어떤 행동을 했는가" 를 기록하는 수단이기도 하다.
그래서 최소한 아래 정보는 반드시 포함돼야 한다고 생각한다:

 

 

  • Timestamp – 언제 일어났는가
  • HTTP Method – GET / POST / PUT / DELETE...
  • API 경로 – 어떤 API에 대한 요청인가
  • 사용자 ID – 누가 요청했는가
  • RequestBody / QueryString – 어떤 요청을 했는가
  • HTTP Status Code – 요청이 성공했는지 실패했는지
  • Custom Code – 응답에 담긴 커스텀 응답코드
  • Stack Trace – 예외 발생 시에만

 

사용자가 어떤 요청을 보냈는가는 반드시 남겨야한다. API명과 RequestBody와 QueryString은 반드시 남아야한다는 것이다. 그리고, 요청이 성공했는지 실패했는지를 알아보려면 HTTP Status Code도 남겨주는게 좋다. 

 

그리고 커스텀한 응답을 내려주기 위한 커스텀 코드와 익셉션이 발생했을 때 스택 트레이스를 남겨주는 것도 좋다. 

 

추가로, MSA나 EDA 환경이라면 traceId도 매우 중요하지만, 이는 로그 수집기나 게이트웨이에서 붙이므로 여기서는 다루지 않는다.

어디에 구현해야할까?

스프링부트 프로젝트에서 로그가 위치하는 부분은 대표적으로 세군데가 추천된다.

 

1. AOP 

2. Filter

3. Interceptor

 

이전 AOP글을 썼을 땐 AOP 부분에 로그 출력부를 두는게 좋다 생각했다. 지금도 그 생각은 변함이 없지만 단점이 딱 하나 있다면, AfterReturning으로 구현을 하게 되면 어느 부분을 Aspect할지 고정적으로 명시해줘야 한다는 것이다. 만약 내가 컨트롤러 명을 다르게 쓰고 싶거나 다른 곳에 위치 시키고 싶을 경우, 이때마다 Aspect에 경로를 추가해야 한다.

 

Filter는 요청과 응답 정보를 쉽게 가져올 수 있어 유용하지만, 어플리케이션 레이어에서 발생한 작업들에 대한 로그를 남길 수 없다. 위의 예시를 보면, 커스텀 코드를 남기지 못한다. 현재 서비스처럼 커스텀 로그가 다양하다면, Filter에 로그를 남기는건 아쉬울 수 밖에 없다.

 

Interceptor는 요청/응답 모두 접근할 수 있고, 커스텀 코드도 함께 다룰 수 있다. 따라서 이번 구현에서는 Interceptor를 채택했다.

 

구현

1. ContentCaching 필터

서론은 길었지만, 구현은 매우 간단하다. 가장 먼저해야할 것은 RequestBody를 재사용할 수 있도록 OncePerRequestFilter를 만들어, ContentCachingRequestWrapper를 만들어주는 것이다. ContentCachingResponseWrapper도 만들어서 커스텀 응답 코드도 가져올 수 있도록 했다.

@Component
class GlobalCachingFilter : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val wrappedRequest = ContentCachingRequestWrapper(request)
        val wrappedResponse = ContentCachingResponseWrapper(response)
        filterChain.doFilter(wrappedRequest, wrappedResponse)
        wrappedResponse.copyBodyToResponse()
    }
}

 

2. AccessLogInterceptor – 응답 완료 시 로깅

다음은 로그를 남기는 인터셉터를 만들어야한다. 인터셉터에서는 afterCompletion 매서드를 overrride했다. afterCompletion를 이용하면 응답을 캐치해서 사용할 수 있다.

 

성공/커스텀 익셉션 로그부터 정리해보면 다음과 같다. 

@Component
class AccessLogInterceptor(
    private val accessLogWriter: AccessLogWriter,
) : HandlerInterceptor {

    override fun afterCompletion(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
        ex: Exception?
    ) {
        val isHandled = request.getAttribute("exceptionHandled") as? Boolean ?: false
        if (!isHandled) {
            accessLogWriter.write(request, response)
        }
    }
}

여기서 중요한건, exceptionHandled를 HttpServletRequest에 심어두는 것이다. 이 이유는 나중에 익셉션을 처리할 때 사용한다.

 

3. AccessLogWriter – 요청 성공/실패 로깅 공통 처리

@Component
class AccessLogWriter(
    private val jwtTokenProvider: JwtTokenProvider,
    private val objectMapper: ObjectMapper
) {
    private val logger = LoggerFactory.getLogger(AccessLogWriter::class.java)

    fun write(
        request: HttpServletRequest,
        response: HttpServletResponse,
    ) {
         val userId = jwtTokenProvider.getSocialLoginIdWithoutException()

        val responseBodyText = if (response is ContentCachingResponseWrapper) {
            String(response.contentAsByteArray, Charsets.UTF_8)
        } else {
            "Empty ResponseBody"
        }

        val requestBodyText = if (
            request is ContentCachingRequestWrapper &&
            request.method == "POST"
        ) {
            val requestBodyBytes = request.contentAsByteArray
            if (requestBodyBytes.isNotEmpty()) {
                String(requestBodyBytes, Charsets.UTF_8).take(500)
            } else null
        } else null

        val responseCode = try {
            val responseJson = objectMapper.readTree(responseBodyText)
            responseJson["code"]?.asText() ?: "UNKNOWN"
        } catch (e: Exception) {
            "UNKNOWN"
        }

        val logData = mapOf(
            "time" to LocalDateTime.now(),
            "method" to request.method,
            "userId" to userId,
            "uri" to extractFullUri(request),
            "requestBody" to requestBodyText,
            "status" to response.status,
            "code" to responseCode,
        )

        logger.info(objectMapper.writeValueAsString(logData))
    }
    
    private fun extractFullUri(request: HttpServletRequest): String {
        return request.requestURI + (request.queryString?.let { "?$it" } ?: "")
    }
}

스트림 형태로 저장되어있는 요청과 응답을 추출하는 과정이 조금 특이할 뿐이지 로그를 앞서 언급했던 로그를 그대로 남겼다. GET방식에서는 요청 본문(RequestBody)가 없으니 null이 나온다.

 

4. GlobalExceptionHandler – 예외 로깅 처리

마지막으로는 익셉션 처리다. 

@RestControllerAdvice
class GlobalExceptionHandler(
    private val accessLogWriter: AccessLogWriter
) {
    @ExceptionHandler(ResponseStatusException::class)
    fun handledResponseStatusException(
        e: ResponseStatusException,
        request: HttpServletRequest
    ): ResponseEntity<ResultVo<String>> {
        val code = e.reason ?: ResultCode.INTERNAL_SERVER_ERROR.code
        val resultCode = ResultCode.entries.first { it.code == code }

        accessLogWriter.writeError(request, e.statusCode, resultCode, e)

        return ResponseEntity
            .status(e.statusCode)
            .body(ResultVo<String>().updateFromResultCode(resultCode))
    }
}

@RestControllerAdvice를 거쳐 익셉션 로그를 남기는데, 별다른 설정없이 로그를 남기면 인터셉터에서 한번 더 캐치되어 로그가 두번 남게 된다. 그래서 exceptionHandled 처리를 추가했다. 커스텀 코드는 @RestControllerAdvice에서 ResultVo를 만들어 심어줬다. 이번 개발에서는 ResponseStatusException을 만들어 기본 에러 로그를 남겼다.

fun writeError(
    request: HttpServletRequest,
    status: HttpStatus,
    resultVo: ResultVo<String>,
    e: Exception
) {
    request.setAttribute("exceptionHandled", true) // 여기서 처리해줘야 중복 로그를 안남김

    val requestBodyText = if (
        request is ContentCachingRequestWrapper &&
        request.method == "POST"
    ) {
        val requestBodyBytes = request.contentAsByteArray
        if (requestBodyBytes.isNotEmpty()) {
            String(requestBodyBytes, Charsets.UTF_8).take(500)
        } else null
    } else null

    val userId = jwtTokenProvider.getSocialLoginIdWithoutException()

    val logData = mapOf(
        "time" to LocalDateTime.now().plusHours(9),
        "method" to request.method,
        "userId" to userId,
        "uri" to extractFullUri(request),
        "requestBody" to requestBodyText,
        "status" to status.value(),
        "code" to resultVo.code,
        "stackTrace" to e.stackTrace.take(10).map { it.toString() }
    )

    logger.error(objectMapper.writeValueAsString(logData))
}

이러면 다음과 같은 형식의 에러가 남는다.

 

4. 결과 예시

{
  "time": "2025-06-13T14:52:36.4830733"
  "method": "GET",
  "userId": "userId",
  "uri": "/api/hello?id=bye",
  "requestBody": null,
  "status": 500,
  "code": "50000"
}

마치며

이전 글과 함께 보면, 인프라부터 애플리케이션까지 연결된 로그 파이프라인이 어느 정도 완성된 셈이다.

 

아키텍처가 변경되거나 k8s가 도입되거나 새로운 요구사항이 들어온다해도 어플리케이션 코드는 딱히 건드릴게 없을 것 같다.

 

사실 Spring 3.4부터 지원되는 StructuredLogging도 고려했지만, 결국 Map 기반 JSON 로깅이 훨씬 직관적이고 통제도 쉬워 그대로 유지했다.

 

이제 남은 건 멀티 모듈 구조와 테스트 코드 아키텍처인데... 개발 일정이 바빠서 언제 정리할 수 있을지 모르겠다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/06   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함