아무거나

[Spring boot] MDC 를 활용한 Logback 에 Request Payload 항목 추가하여 로그 세분화 본문

Java & Kotlin/Spring

[Spring boot] MDC 를 활용한 Logback 에 Request Payload 항목 추가하여 로그 세분화

전봉근 2022. 11. 16. 13:03
반응형

MDC(Mapped Diagnostic Context)는 현재 실행중인 쓰레드에 메타 정보를 넣고 관리하는 공간이다. MDC는 내부적으로 Map을 관리하고 있어 (Key, Value) 형태로 값을 저장할 수 있다.

 

Application 에서 로그를 남길시에 Request 관련 값들을 좀 더 상세하게 다루고 싶어 해당 포스팅을 작성하게 되었다. (Ex: url, parameter 등)

하기 코드만 그대로 사용하면 되므로 활용해보자.

 

https://github.com/bkjeon1614/java-example-code/tree/develop/bkjeon-mybatis-codebase/base-api

 

  • 의존성 추가
    [build.gradle]
    ...
    // LoggingFilter (Spring Version 에 따라 기본으로 제공해주는 경우도 있음)
    implementation group: 'javax.ws.rs', name: 'javax.ws.rs-api', version: '2.0'
    implementation group: 'javax.annotation', name: 'javax.annotation-api', version: '1.3.2'    
    ...
    
  • 로깅 필터 클래스 작성
    [LoggingFilter.java]
    package com.example.bkjeon.base.filter;
    
    import static javax.ws.rs.core.MediaType.*;
    
    import java.io.IOException;
    import java.util.Arrays;
    import java.util.List;
    
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    
    import org.slf4j.MDC;
    import org.springframework.http.MediaType;
    import org.springframework.lang.Nullable;
    import org.springframework.stereotype.Component;
    import org.springframework.util.StreamUtils;
    import org.springframework.web.filter.OncePerRequestFilter;
    import org.springframework.web.util.ContentCachingResponseWrapper;
    
    import lombok.extern.slf4j.Slf4j;
    
    /**
    * 로깅 필터
    * [MDC]
    * 실행 쓰레드들에 공통값을 주입하여 의미있는 정보를 추가해 로깅 할 수 있도록 제공
    * ( Ex: 멀티 스레딩 환경시 실행되는 task 는 로그가 섞여 제대로 확인하기 힘들어서
    * 스레드로컬 변수에 값을 할당하여 트래킹에 용이하게 만드나 매번 해당 값을 주입하기는 번거로워 logback, log4j 등 MDC 를 제공)
    *
    * [doFilterInternal]
    * doFilter 와 동일하지만 단일 요청 스레드 내에서 요청당 한 번만 호출되도록 보장된다.
    */
    @Slf4j
    @Component
    public class LoggingFilter extends OncePerRequestFilter {
    
      @Override
      protected void doFilterInternal(
        HttpServletRequest request,
        @Nullable HttpServletResponse response,
        @Nullable FilterChain filterChain
      ) throws ServletException, IOException {
        MDC.put("method", request.getMethod());
        MDC.put("uri", request.getQueryString() == null
          ? request.getRequestURI()
          : request.getRequestURI() + "?" + request.getQueryString());
    
        if (filterChain != null) {
          doFilterWrapped(new RequestWrapper(request), new ResponseWrapper(response), filterChain);
        }
    
        MDC.clear();
      }
    
      protected void doFilterWrapped(
        RequestWrapper request,
        ContentCachingResponseWrapper response,
        FilterChain filterChain
      ) throws ServletException, IOException {
        try {
          logRequest(request);
          filterChain.doFilter(request, response);
        } finally {
          response.copyBodyToResponse();
        }
      }
    
      private static void logRequest(RequestWrapper request) throws IOException {
        boolean mediaTypeChk = isMediaType(MediaType.valueOf(request.getContentType() == null
          ? APPLICATION_JSON
          : request.getContentType()));
    
        // inputStream 을 byte 배열로 반환
        byte[] content = StreamUtils.copyToByteArray(request.getInputStream());
        if (mediaTypeChk && content.length > 0) {
          MDC.put("payload", new String(content));
        }
      }
    
      private static boolean isMediaType(MediaType mediaType) {
        final List<MediaType> mediaTypeList = Arrays.asList(
          MediaType.valueOf("text/*"),
          MediaType.APPLICATION_FORM_URLENCODED,
          MediaType.APPLICATION_JSON,
          MediaType.APPLICATION_XML,
          MediaType.valueOf("application/*+json"),
          MediaType.valueOf("application/*+xml"),
          MediaType.MULTIPART_FORM_DATA
        );
    
        return mediaTypeList.stream().anyMatch(visibleType -> visibleType.includes(mediaType));
      }
    
    }    
    
  • 요청된 HTTP 접근 클래스 작성
    [RequestWrapper.java]
    package com.example.bkjeon.base.filter;
    
    import java.io.ByteArrayInputStream;
    import java.io.IOException;
    import java.io.InputStream;
    
    import javax.servlet.ReadListener;
    import javax.servlet.ServletInputStream;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletRequestWrapper;
    
    import org.springframework.util.StreamUtils;
    
    /**
    * 요청된 HTTP 접근
    * [HttpServletRequestWrapper]
    * Servlet 관련 인터페이스 제공
    */
    public class RequestWrapper extends HttpServletRequestWrapper {
    
      private final byte[] cachedInputStream;
    
      public RequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        InputStream requestInputStream = request.getInputStream();
        this.cachedInputStream = StreamUtils.copyToByteArray(requestInputStream);
      }
    
      /**
      * binary data로 Request Body 정보를 담은 ServletInputStream(inputstream)을 반환한다.
      * @return
      */
      @Override
      public ServletInputStream getInputStream() {
        return new ServletInputStream() {
          private final InputStream cachedBodyInputStream = new ByteArrayInputStream(cachedInputStream);
    
          @Override
          public boolean isFinished() {
            try {
              // 더 이상 읽을 byte 가 없을 때 return
              return cachedBodyInputStream.available() == 0;
            } catch (IOException e) {
              e.printStackTrace();
            }
            return false;
          }
    
          @Override
          public boolean isReady() {
            return true;
          }
    
          @Override
          public void setReadListener(ReadListener readListener) {
            throw new UnsupportedOperationException();
          }
    
          @Override
          public int read() throws IOException {
            return cachedBodyInputStream.read();
          }
        };
      }
    
    }
    
  • HTTP 응답 캐싱 클래스 작성
    [ResponseWrapper.java]
    package com.example.bkjeon.base.filter;
    
    import javax.servlet.http.HttpServletResponse;
    
    import org.springframework.web.util.ContentCachingResponseWrapper;
    
    /**
    * HTTP 응답 캐싱
    * [ContentCachingResponseWrapper]
    * httpServletRequest의 getInputStream()은 한번 밖에 사용 못하므로 ContentCachingResponseWrapper 를 사용해야함
    * ContentCachingResponseWrapper 는 출력 스트림 및 기록된 모든 콘텐츠를 캐시하고 바이트 배열을 통해 이 콘텐츠를 검색할 수 있도록 하는
    * HttpServletResponse Wrapper 이다.
    */
    public class ResponseWrapper extends ContentCachingResponseWrapper {
      public ResponseWrapper(HttpServletResponse response) {
        super(response);
      }
    }    
    
  • MDC 에 적용된 logback pattern 항목값 추가 ([Request: %X{method} uri=%X{uri} payload=%X{payload}])
    [logback-spring.xml]
    ...
    <appender name="stdout" class="ch.qos.logback.core.ConsoleAppender">
        <layout class="ch.qos.logback.classic.PatternLayout">
            <Pattern>
                %d{yyyy-MM-dd HH:mm:ss} [%thread] [Request: %X{method} uri=%X{uri} payload=%X{payload}] %-5level %logger{36} - %msg %n
            </Pattern>
        </layout>
    </appender>
    
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>[%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread]64 [Request: %X{method} uri=%X{uri} payload=%X{payload}] %-5level %logger{35} - %msg%n</pattern>
        </encoder>
        <file>${LOG_FILE_APPLICATION}</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_FILE_APPLICATION}.%d{yyyyMMdd}</fileNamePattern>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>    
    ...
    

 

끝.

반응형
Comments