티스토리 뷰

반응형

# 서론

사용자 요청 JSON과, REST API 서버의 경우 Response body를 로깅하고 싶은 요구사항이 생겨서 찾아보다가 구현한 방법을 정리한다.

 

# 사전지식 - 서블릿 Reqeust, Response는 단 한번만 읽을 수 있다

로깅을 하기 위해서는 필터나 인터셉터를 통해서 서블릿 Request, Response를 접근해서 그 content를 읽어야한다.

그런데 그냥 읽으면 컨트롤러에서 해당 요청을 처리 못 한다. (아마 예외를 맞이할 것이다.)

이는 서블릿 구조상 생기는 문제인데, 서블릿은 요청 응답 객체를 단 한번만 읽을 수 있도록 설계되어 있기 때문이다.

 

그래서 로깅을 위해서 Reqeust, Response 객체를 여러번 읽기 위한 Wrapper 클래스로 한번 감싸주는 작업을 수행해야 한다.

 

# Wrapping 필터 만들기

@Component
class ServletWrappingFilter : OncePerRequestFilter() {
    // OncePerRequestFilter 는 모든 서블릿 요청에 일관된 필터를 적용하기 위해서 사용한다.

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        val wrapRequest = MultiAccessRequestWrapper(request) // 커스텀으로 생성한 Wrapper
        val wrapResponse = ContentCachingResponseWrapper(response)
        filterChain.doFilter(wrapRequest, wrapResponse)
        wrapResponse.copyBodyToResponse() // 이 부분이 핵심이다. 이를 통해 response 를 다시 읽을 수 있다.
    }

}

감싸주는 작업은 필터 단에서 진행하도록 한다. 그래야 필터 체인을 통해서 우리가 감싼 Request와 Response가 컨트롤러까지 잘 전달될 것이다.

 

OncePerRequestFilter 를 상속받아서 구현해 준다. (해당 필터는 따로 등록 안해도 작동한다.)

 

OncePerRequestFilter는 일반 Filter와 다르게, 모든 서블릿 요청에 대해서 아래 로직을 수행해 주는 차이점이 있다.

 

응답 객체는 ContentCachingResponseWrapper 로 감싸준 뒤, copyBodyToResponse 메서드를 통해서 내용을 캐싱해준다.

이를 통해서 여러번 Response body의 내용을 액세스 할 수 있게 되었다.

 

Request 도 ContentCachingRequestWrapper 클래스가 존재하긴 하나, copyBodyTo~ 메소드가 없다.

(아마 요청 content-type 에는 여러 종류가 있기 때문에 안 만들어 놓은 것 같다.)

 

그래서 Reqeust body 내용도 캐싱할 Wrapper 클래스를 커스터마이징 해서 제작한다.

 

# 커스텀 Request Wrapper 제작

class MultiAccessRequestWrapper(request: HttpServletRequest) : HttpServletRequestWrapper(request) {
    private var contents = ByteArrayOutputStream() // request content 를 여기다 저장한다. (캐싱)

    // 이 메소드를 통해서 request body 의 내용을 inputStream 으로 읽는다.
    override fun getInputStream(): ServletInputStream {
        IOUtils.copy(super.getInputStream(), contents) // request content 를 복사

        // read 를 호출하면 buffer (저장된 내용)을 보내주는 커스텀 ServletInputStream 객체를 생성해서 반환
        return object : ServletInputStream() {
            private var buffer = ByteArrayInputStream(contents.toByteArray())
            override fun read(): Int = buffer.read()
            override fun isFinished(): Boolean = buffer.available() == 0
            override fun isReady(): Boolean = true
            override fun setReadListener(listener: ReadListener?) =
                throw java.lang.RuntimeException("Not implemented")
        }
    }

    // contents 를 byteArray 로 반환
    fun getContents(): ByteArray = this.inputStream.readAllBytes()
}

HttpServletRequestWrapper를 상속받아서 커스텀 Wrapper 클래스를 제작한다. 용도는 Request body 내용 캐싱이다.

 

getInputStream() 메소드를 통해서 Reqeust 객체의 contents를 조회할 수 있는데, 우리의 목적은 이를 여러번 사용가능하게 하는 것이다.

따라서 contents를 복사하고, 이를 inputStream 으로 내보내줄 수 있는 ServletInputStream 객체를 생성해서 반환한다.

 

코틀린으로 작성하여, object 문법을 통해 익명객체로 작성했다.

 

buffer에 Request content를 inputStream 형태로 넣어놓고, read() 가 호출되면 이를 읽어주도록 구현한다.

 

이제 getContents() 메소드를 제작하여 복사된 contents 를 ByteArray 형태로 내보내 줄 수 있도록 한다.

 

# 인터셉터 설정

@Component
class LoggingInterceptor(
    private val converter: PrettyConverter // NormalConverter, PrettyConverter 를 갈아끼우기만 하면 된다.
) : HandlerInterceptor {
    val log = logger()

    override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
        // form-data 를 담는 request 객체는 타입 캐스팅을 할 수 없어서 처리해준다
        if (!request.contentType.startsWith("multipart/form-data")) {
            val wrapRequest = request as MultiAccessRequestWrapper
            val body = converter.convert(wrapRequest.getContents())
            log.info(
                "-------------> [REQUEST] {} {} {} BODY\n{}",
                request.remoteAddr,
                request.method,
                request.requestURL,
                body
            )
        } else {
            log.info(
                "-------------> [REQUEST] {} {} {}",
                request.remoteAddr,
                request.method,
                request.requestURL,
            )
        }
        return super.preHandle(request, response, handler)
    }

    // 이래야 핸들러에서 예외가 발생해도 수행 됨
    override fun afterCompletion(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
        ex: Exception?
    ) {
        val wrapResponse = response as ContentCachingResponseWrapper
        val body = converter.convert(wrapResponse.contentAsByteArray)
        log.info("<------------ [RESPONSE] {} JSON {}", response.status, body)
        super.afterCompletion(request, response, handler, ex)
    }

}

Request body는, POST 요청시 application/json 형태일 때만 로깅하도록 하고, 나머지는 하지 않는다.

특히 multipart/form-data 를 처리해준다.

(이는 다른 서블릿으로 캐스팅되서 request 객체가 돌아오기 때문에, 커스텀 Wrapper로 Wrapping이 불가능하다. 처리를 안 해주면, ClassCastException을 마주할 것이다.)

 

Response body를 로깅할때는, 컨트롤러에서 예외가 발생하더라도 호출 되도록 afterCompletion 부분으로 작성한다.

 

여담이지만, 요청/응답 JSON 을 예쁘게 출력하고 싶으면 아래 코드를 추가하면 된다.

 

# JSON 이쁘게 출력하기

/**
 * 의존성 주입을 위해 다형성을 활용한다.
 */
interface JsonViewConverters {
    fun convert(obj: ByteArray): String
}

/**
 * 인코딩만 해서 한 줄로 출력해준다.
 */
@Component
class NormalConverter : JsonViewConverters {
    override fun convert(obj: ByteArray): String {
        return String(obj, Charsets.UTF_8)
    }
}

/**
 * JSON 형태로 예쁘게 출력해준다.
 * GSON 의존성이 필요하다.
 */
@Component
class PrettyConverter : JsonViewConverters {
    private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
    private val jsonParser = JsonParser()

    override fun convert(obj: ByteArray): String {
        return gson.toJson(jsonParser.parse(String(obj, Charsets.UTF_8)))
    }

}

이쁘게 출력하는 함수는 GSON 의존성이 필요하다.

 

 

잘 로깅 된다.

반응형
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크