[Spring] Request Body 를 직접 읽을 때 주의할 점
들어가며
Filter 를 이용해 로그에 Request Body 를 기록하는 기능을 추가하자
기존 API의 Request Body가 비어있는 오류가 발생했다. 원인과 해결방안에 대해서 알아보자
예시 HTTP 코드
다음과 같은 테스트 HTTP API 코드가 있다고 하자
@RestController
class TestController {
companion object{
val log:Logger=LoggerFactory.getLogger(this::class.java)
}
@PostMapping("/test/requestBody")
fun test(@RequestBody message: Message): Message {
return message
}
data class Message(
val id: Long,
val message: String
)
}
이 경우 아래와 같은 테스트 코드를 실행하면 당연히 성공해야한다.
@SpringBootTest
@AutoConfigureMockMvc
class TestControllerTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var objectMapper: ObjectMapper
@Test
fun `test endpoint should return correct message`() {
val message = TestController.Message(1, "test")
mockMvc.perform(
post("/test/requestBody")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(message)
)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.id", Matchers.`is`(message.id.toInt())))
.andExpect(jsonPath("$.message", Matchers.`is`(message.message)))
}
}
Request Body를 사용하는 필터 추가
Sentry (에러 모니터링 도구)에 Request Body를 기록하는 필터를 추가한다.
request 에서 request body를 읽어오는 모습을 볼 수 있다.
@Component
class RequestBodyLogger: OncePerRequestFilter() {
val log: Logger=LoggerFactory.getLogger(this::class.java)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
Sentry.configureScope{scope ->
scope.setExtra("requestBody",request.reader.lines().collect(Collectors.joining(System.lineSeparator())))
}
filterChain.doFilter(request,response)
}
}
에러
필터를 추가하고 다시 테스트 코드를 실행하면 아래와 같은 에러를 마주친다.
java.lang.IllegalStateException: Cannot call getInputStream() after getReader() has already been called for the current request
이 에러는 @RequestBody 어노테이션을 처리하는 과정에서 request body의 getReader()가 이미 사용되었기 때문에 더 이상 request body를 읽어올 수 없다는 내용이다.
Servlet의 Request Body는 메모리에 저장하는 것이 아니라 BufferReader 또는 InputStream으로 처리된다. 즉, 한 번 소비가 되면
다른 곳에 저장하지 않는 이상 더 이상 데이터를 가져올 수가 없다.
따라서 필터에서 이미 소비가 되었기 때문에 컨트롤러에서 RequestBody를 해석하려고 하면 데이터를 읽어올 수 없기 때문에 에러가 발생한다.
해결방안
해결방안은 ContentCachingRequestWrapper
를 사용하는 것이다.
ContentCachingRequestWrapper
는 HttpServletRequest 에서 스트림이나 리더를 이용해 소비되는 데이터를 캐시에 저장해
byte array를 이용해 조회할 수 있도록 하는 래퍼 클래스이다.
이 클래스를 이용하면 필터에서 request body 데이터를 가져와도 이후에도 다시 데이터를 가져올 수 있다.
@Component
class RequestBodyLogger: OncePerRequestFilter() {
val log: Logger=LoggerFactory.getLogger(this::class.java)
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
//use cache wrapper for request
val wrappedRequest = ContentCachingRequestWrapper(request)
filterChain.doFilter(request,response)
Sentry.configureScope{scope ->
val buf=wrappedRequest.contentAsByteArray
val length = min(buf.size, request.contentLength)
scope.setExtra("requestBody", String(buf, 0, length, charset(request.characterEncoding)))
}
}
}
여기서 중요한 점은 contentAsByteArray
를 가져오기 전에 filterChain.doFilter
를 호출해야한다는 것이다.
contentAsByteArray
데이터는 request가 리더나 버퍼를 통해서 데이터가 읽어질 때 중간에 데이터를 가로채 저장함으로써 조회가 가능하다.
따라서, request body를 조회하기 전에 contentAsByteArray
데이터를 조회하면 빈 값이 있으므로 filterChain.doFilter
를 먼저 호출해야한다.
/**
* Return the cached request content as a byte array.
* <p>The returned array will never be larger than the content cache limit.
* <p><strong>Note:</strong> The byte array returned from this method
* reflects the amount of content that has been read at the time when it
* is called. If the application does not read the content, this method
* returns an empty array.
* @see #ContentCachingRequestWrapper(HttpServletRequest, int)
*/
public byte[] getContentAsByteArray() {
return this.cachedContent.toByteArray();
}