ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] request, response 로그 남기기1 - Console
    개발기록 2022. 9. 13. 16:15
    반응형
    728x90

     

    ※ 트러블슈팅

    - 문제 발생: 파라미터가 로깅에 사용되어 사라짐

    - 문제 발생 예상 파일: RequestResponseWrapperFilter.java

    - 해결(미봉책): https://jeongwoo.tistory.com/59

     

    개요

    api로 들어오는 request와 response에 대해 로그를 남기기 위해 코드를 작성하던 중 기록을 남긴다.

     

    이미지 출처: https://gngsn.tistory.com/153

    위 이미지는 Request와 Response의 흐름도이다. Filter를 거친 후 Dispatcher Servlet을 통과하게 된다.

    Filter ~ Dispatcher Servlet ~ Interceptor 순으로 실행되는 것을 알아두고 시작하도록 하자.

     

    참고 및 출처:

    https://www.baeldung.com/spring-http-logging

    https://hyunsoori.tistory.com/m/12

     

     

    Http Logging은 request와 response를 커스터마이징하는 방법과 SpringBoot에서 지원하는 SpringBoot Built-In request Logging이 있다.

    하지만 후자의 경우 response에 대해서는 지원해주지 않기때문에 우리는 커스터마이징하는 방법을 사용하도록 한다.

     

    개발 순서 및 코드

    커스터마이징할 CachingRequestWrapper 클래스에서 HttpServletRequestWrapper 클래스를 상속받기 위해 build.gralde 파일에 spring-boot-starter-web에 관한 dependency를 추가한다.

     

    ※ ' compileOnly javax.servelt:servlet-api:2.5'로 dependency 추가할 경우, CachingRequestWrapper.java에서 ServletInputStream을 상속 받아서 사용할 때 isFinished(), isReady(), setReadListener(ReadListener listener)를 오버라이드할 수 없는 이슈가 발생한다.

     

    ※ 다른 모듈에서 쓸 경우 implementation 말고 api 'org.springframework.~~~'로 써주면 된다.

     

    1. build.gradle

    // HttpServletRequestWrapper
    api 'org.springframework.boot:spring-boot-starter-web'

     

    CachingRequestWrapper.java에서

    cf) 아래에 해당 파일 완성 코드가 있다.

    package com.??.core.interceptor;
    
    import javax.servlet.http.HttpServletRequestWrapper;
    
    public class CachingRequestWrapper extends HttpServletRequestWrapper {
    
    }

     

    상속만 받으면 컴파일 에러가 뜬다.

    이를 해결하기 위해선 생성자를 작성해줘야한다.

    /* 
    public CachingRequestWrapper(HttpServletRequest request) {
        super(request);
    }
    */

     

    이렇게만 작성해도 컴파일 에러가 해결된다.

    하지만 우리는 커스터마이징할 것이기때문에 참고한 블로그대로 아래와 같이 작성한다.

     

    이때 IOUtils.toByteArray()를 사용하기 위해 build.gradle에 dependency를 추가해줬다.

    // IOUtils.toByteArray() 사용하기 위해 필요한 dependency
    // https://mvnrepository.com/artifact/commons-io/commons-io
    api 'commons-io:commons-io:2.5'

     

    참고했던 블로그 코드를 그대로 쓸 경우 StringUtils의 isEmpty()가 deprecated 되어서 밑줄 쳐져있을 것이다.

    따라서 대체할 수 있는 hasLength나 hasText 중 hasText()를 사용했다.

    참고: https://creampuffy.tistory.com/120

     

    2. CachingRequestWrapper.java

    request와 response에 대해서 Wrapper Class를 만드는 이유는 HttpServletRequest의 InputStream은 오직 한 번만 읽을 수 있기 때문이라고 한다.

    따라서 요청했던 데이터를 캐싱하여 여러 번 읽을 수 있도록 하기 위해 Wrapper Class를 만들어 사용한다.

    출처: https://hyunsoori.tistory.com/m/12

    package com.??.core.filter;
    
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.io.IOUtils;
    import org.springframework.util.StringUtils;
    
    import javax.servlet.ReadListener;
    import javax.servlet.ServletInputStream;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletRequestWrapper;
    import java.io.*;
    import java.nio.charset.Charset;
    import java.nio.charset.StandardCharsets;
    
    @Slf4j
    public class CachingRequestWrapper extends HttpServletRequestWrapper {
    
        // private final Charset encoding;
        private byte[] rawData;
    
        public CachingRequestWrapper(HttpServletRequest request) throws IOException {
            super(request);
    
    		/* 쓰레기 코드
            String characterEncoding = request.getCharacterEncoding();
            if (StringUtils.hasText(characterEncoding)) {
                characterEncoding = StandardCharsets.UTF_8.name();
            }
            this.encoding = Charset.forName(characterEncoding);
    		*/
            
            try (InputStream inputStream = request.getInputStream()) {
                this.rawData = IOUtils.toByteArray(inputStream);
            }
        }
    
        @Override
        public ServletInputStream getInputStream() {
            return new CachingRequestWrapper.CachedServletInputStream(this.rawData);
        }
    
        @Override
        public BufferedReader getReader() {
            // return new BufferedReader(new InputStreamReader(this.getInputStream(), this.encoding));
            return new BufferedReader(new InputStreamReader(this.getInputStream()));
        }
    
    
        private static class CachedServletInputStream extends ServletInputStream {
    
            private final ByteArrayInputStream buffer;
    
            public CachedServletInputStream(byte[] contents) {
                this.buffer = new ByteArrayInputStream(contents);
            }
    
            @Override
            public int read() throws IOException {
                return buffer.read();
            }
    
            @Override
            public boolean isFinished() {
                return buffer.available() == 0;
            }
    
            @Override
            public boolean isReady() {
                return true;
            }
    
            @Override
            public void setReadListener(ReadListener listener) {
                throw new UnsupportedOperationException("not support");
            }
        }
    }

     

    3. CachingResponseWrapper.java

    package com.??.core.filter;
    
    import org.apache.commons.io.output.TeeOutputStream;
    import org.springframework.util.FastByteArrayOutputStream;
    
    import javax.servlet.ServletOutputStream;
    import javax.servlet.WriteListener;
    import javax.servlet.http.HttpServletResponse;
    import javax.servlet.http.HttpServletResponseWrapper;
    import java.io.*;
    
    public class CachingResponseWrapper extends HttpServletResponseWrapper {
    
        private final FastByteArrayOutputStream content = new FastByteArrayOutputStream(1024);
        private ServletOutputStream outputStream;
        // private PrintWriter writer; // 쓰레기 코드
    
        public CachingResponseWrapper(HttpServletResponse response) {
            super(response);
        }
    
        @Override
        public ServletOutputStream getOutputStream() throws IOException {
            if (this.outputStream == null) {
                this.outputStream = new CachingResponseWrapper.CachedServletOutputStream(getResponse().getOutputStream(), this.content);
            }
            return this.outputStream;
        }
    	
        /* 쓰레기 코드
        @Override
        public PrintWriter getWriter() throws IOException {
            if (writer == null) {
                writer = new PrintWriter(new OutputStreamWriter(content, this.getCharacterEncoding()), true);
            }
            return writer;
        }
        */
    
        public InputStream getContentInputStream() { return this.content.getInputStream(); }
    
        private class CachedServletOutputStream extends ServletOutputStream {
    
            private final TeeOutputStream targetStream;
    
            public CachedServletOutputStream(OutputStream one, OutputStream two) {
                targetStream = new TeeOutputStream(one, two);
            }
    
            @Override
            public void write(int arg) throws IOException {
                this.targetStream.write(arg);
            }
    
            @Override
            public void write(byte[] buf, int off, int len) throws IOException {
                this.targetStream.write(buf, off, len);
            }
    
            @Override
            public void flush() throws IOException {
                super.flush();
                this.targetStream.flush();
            }
    
            @Override
            public void close() throws IOException {
                super.close();
                this.targetStream.close();
            }
    
            @Override
            public boolean isReady() {
                return false;
            }
    
            @Override
            public void setWriteListener(WriteListener writeListener) {
                throw new UnsupportedOperationException("not support");
            }
        }
    }

     

    4. RequestResponseWrapperFilter.java

    상속받는 OncePerRequestFilter는 "단 한 번만 처리가 수행되도록 보장"해주는 필터라고 한다.

    출처: https://hyunsoori.tistory.com/m/12

    package com.??.core.filter;
    
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import org.springframework.web.filter.OncePerRequestFilter;
    
    import javax.servlet.FilterChain;
    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    
    @Slf4j
    @Component
    public class RequestResponseWrapperFilter extends OncePerRequestFilter {
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
            if (isAsyncDispatch(request)) {
                filterChain.doFilter(request, response);
            } else {
                filterChain.doFilter(
                        new CachingRequestWrapper(request),
                        new CachingResponseWrapper(response)
                );
            }
        }
    }

     

    5. HttpLogInterceptor.java

    Interceptor에는 preHandle, postHandle, afterCompletion과 같이 3개의 메소드가 있다.

    preHandle
     - 컨트롤러의 핸들러 메서드를 실행하기전에 호출
     - 핸들러 메서드가 호출되지 않게 하고 싶을 때 메서드 반환값으로 false

    postHandle
     - 컨트롤러의 핸들러 메서드가 정상적으로 종료된 후에 호출
     - 핸들러 메서드에서 예외가 발생하면 호출 안됨

    afterHandle
     - 컨트롤러의 핸들러 메서드의 처리가 종료된 후에 호출
     - 예외가 발생해도 호출

    출처: https://hyunsoori.tistory.com/m/12

     

    참고했던 블로그 코드를 그대로 쓸 경우 HanlerInterceptorAdapter가 deprecated 되어서 밑줄 쳐져있을 것이다.

    HandlerInterceptor나 AsyncHandlerInterceptor를 implement하라고 명시되어있다.

    따라서 우리는 HandlerInterceptor를 implement를 해서 사용하자.

    참고: https://oingdaddy.tistory.com/399

    또한

    String res = IOUtils.toString(((ChildCachingResponseWrapper) response).getContentInputStream(), response.getCharacterEncoding());
    

     

    위 코드를 블로그처럼 그대로 쓰면 ISO-8859-1으로 인코딩 되어 있기 때문에 한글이 깨진다.

    이를 해결하기 위해 StringWriter 클래스와 IOUtils 클래스의 copy 메소드와 UTF-8을 하드코딩하여 사용했다.

    참고: https://tiqndjd12.tistory.com/25

     

    package com.??.core.interceptor;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.??.core.filter.CachingRequestWrapper;
    import com.??.core.filter.CachingResponseWrapper;
    import lombok.extern.slf4j.Slf4j;
    import org.apache.commons.io.IOUtils;
    import org.springframework.stereotype.Component;
    import org.springframework.web.servlet.HandlerInterceptor;
    
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.InputStream;
    import java.io.StringWriter;
    
    @Slf4j
    @Component
    public class HttpLogInterceptor implements HandlerInterceptor {
        private final ObjectMapper objectMapper;
    
        public HttpLogInterceptor(ObjectMapper objectMapper) {
            this.objectMapper = objectMapper;
        }
    
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
            if (request instanceof CachingRequestWrapper) {
                log.info("uri: " + request.getRequestURI());
                String req = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
                // 특수문자들이 URL 인코딩되어 있기때문에 URL 디코더를 사용
                log.info("request - {}", URLDecoder.decode(req, request.getCharacterEncoding()));
            }
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
                throws Exception {
    
            if (response instanceof CachingResponseWrapper) {
                InputStream tmpIs = ((CachingResponseWrapper) response).getContentInputStream();
                StringWriter writer = new StringWriter();
                // IOUtils.copy(tmpIs, writer, response.getCharacterEncoding()); // ISO-8859-1이기때문에 UTF-8로 하드코딩
                IOUtils.copy(tmpIs, writer, "UTF-8");
                String res = writer.toString();
    
                log.info("response - {}", res);
            }
        }
    }

     

    6. WebMvcConfig.java

    WebMvcConfig에서 HttpLogInterceptor httpLongInterceptor를 final로 선언하면 컴파일 에러가 떠서 @Autowired로 선언했다.

    또한 모든 요청에 대해서 Interceptor가 request를 가로채서 로깅하도록 PathPattern을 "/**"로 설정했다.

    package com.??.core.config;
    
    import com.??.core.interceptor.HttpLogInterceptor;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
    import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
    
    @Configuration
    @ComponentScan(basePackages={"com.??.core.interceptor"})
    public class WebMvcConfig implements WebMvcConfigurer {
        @Autowired
        private HttpLogInterceptor httpLogInterceptor;
    
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            registry.addInterceptor(httpLogInterceptor)
                    .addPathPatterns("/**");
        }
    }

     

    포스트맨을 통해서 request를 보내면 아래와 같이 이제 콘솔에 로그가 찍힌다.

    이때 HttpLogInterceptor.java의 preHandle 메소드에서 URL 디코더를 해주지 않을 경우, request에 담긴 데이터들 중 특수문자들은 아래처럼 URL 인코딩된 채로 출력된다.

     

     

    내가 수정한 코드처럼 URLDecoder를 해주면 아래와 같이 이를 해결할 수 있다.

    참고: https://arabiannight.tistory.com/151

     

     

    728x90
Designed by Tistory.