[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();
    }

+ Recent posts