티스토리 뷰
[Spring Boot] Request body, Response body 로깅 하는 법 (with 코틀린)
mopil 2022. 8. 17. 22:17# 서론
사용자 요청 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 의존성이 필요하다.