Repeatablely Read Message from Servlet Request
1. Problem Context
HMAC 인증 필터를 구현하면서 다음 에러를 만났습니다.
- 실행 후 런타임에서 발생하는 에러 메시지
java.lang.IllegalStateException: getReader() has already been called for this request
at org.apache.catalina.connector.Request.getInputStream(Request.java:1024) ~[tomcat-embed-core-10.1.10.jar:10.1.10]
at org.apache.catalina.connector.RequestFacade.getInputStream(RequestFacade.java:298) ~[tomcat-embed-core-10.1.10.jar:10.1.10]
at org.springframework.http.server.ServletServerHttpRequest.getBody(ServletServerHttpRequest.java:206) ~[spring-web-6.0.10.jar:6.0.10]
at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver$EmptyBodyCheckingHttpInputMessage.<init>(AbstractMessageConverterMethodArgumentResolver.java:323) ~[spring-webmvc-6.0.10.jar:6.0.10]
at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:172) ~[spring-webmvc-6.0.10.jar:6.0.10]
...
- 테스트 실행 시 발생하는 에러 메시지
Request processing failed: java.lang.IllegalStateException: Cannot call getInputStream() after getReader() has already been called for the current request
jakarta.servlet.ServletException: Request processing failed: java.lang.IllegalStateException: Cannot call getInputStream() after getReader() has already been called for the current request
at app//org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1019)
at app//org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914)
...
로그에서 볼 수 있듯이 요청(request) 객체에서 getReader() 메소드를 여러번 호출하여 에러가 발생합니다. HMAC 필터에서 application/json 형식의 데이터를 추출하는 작업을 수행했기 때문입니다.
@Component
public class HmacFilter extends OncePerRequestFilter {
// ...
private String getMessage(HttpServletRequest request) {
try (BufferedReader reader = request.getReader()) {
StringBuilder stringBuilder = new StringBuilder();
String line = null;
while ((line = reader.readLine()) != null) {
stringBuilder.append(line);
}
return stringBuilder.toString();
} catch (IOException ioException) {
throw new RuntimeException(ioException);
}
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
var message = getMessage(request);
// ...
filterChain.doFilter(request, response);
}
}
getReader() 메소드를 호출하지 않게 우회하더라도 문제가 발생합니다.
- InputStream 객체는 메시지를 읽을 때 인덱스를 사용해 바이트 배열의 읽은 위치를 지나가기 때문에 다시 읽지 못 합니다.
- 요청 객체에 담긴 메시지가 필터에서 소비되어 컨트롤러 영역까지 전달되지 못합니다.
- 요청 핸들러(request handler)에서 메시지가 누락되었다는 예외를 발생시킵니다.
@Component
public class HmacFilter extends OncePerRequestFilter {
// ...
private String getMessage(HttpServletRequest request) {
try (
OutputStream outputStream = new ByteArrayOutputStream();
InputStream inputStream = request.getInputStream()
) {
inputStream.transferTo(outputStream);
return outputStream.toString();
} catch (IOException ioException) {
throw new RuntimeException(ioException);
}
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
var message = getMessage(request);
// ...
filterChain.doFilter(request, response);
}
}
- 요청 메시지를 필터에서 소비하기 때문에 서블릿 컨테이너에서 메시지를 보내지 않았다는
bad request(400)
가 발생합니다.
$ ccurl -X POST http://localhost:8080/todos\
-H 'Content-Type: application/json'\
-d '{"content":"Hello World"}' | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 121 0 96 100 25 866 225 --:--:-- --:--:-- --:--:-- 1163
{
"timestamp": "2023-07-01T14:01:04.009+00:00",
"status": 400,
"error": "Bad Request",
"path": "/todos"
}
- 테스트 실행 시 동일하게
bad request(400)
가 발생합니다.
MockHttpServletRequest:
HTTP Method = POST
Request URI = /todos
Parameters = {}
Headers = [Content-Type:"application/json;charset=UTF-8", Content-Length:"32"]
Body = {"id":0,"content":"Hello World"}
Session Attrs = {}
Handler:
Type = action.in.blog.TodoController
Method = action.in.blog.TodoController#createTodo(Todo)
Async:
Async started = false
Async result = null
Resolved Exception:
Type = org.springframework.http.converter.HttpMessageNotReadableException
ModelAndView:
View name = null
View = null
Model = null
FlashMap:
Attributes = null
MockHttpServletResponse:
Status = 400
Error message = null
Headers = []
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
Status expected:<200> but was:<400>
Expected :200
Actual :400
2. Solve the problem
문제 해결을 위해 HttpServletRequestWrapper 클래스를 상속 받은 요청 클래스를 생성합니다.
- 객체 생성시 메시지 인코딩 방법과 InputStream 객체에 저장된 원장(origin) 데이터를 필드에 저장합니다.
- 클라이언트(client)가 InputStream 객체나 Reader 객체를 요청하면 원장 데이터를 담은 객체를 전달합니다.
package action.in.blog;
import io.micrometer.common.util.StringUtils;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
public class RepeatableReadRequest extends HttpServletRequestWrapper {
private final Charset encoding;
private final byte[] rawBytes;
public RepeatableReadRequest(HttpServletRequest request) {
super(request);
String characterEncoding = request.getCharacterEncoding();
if (StringUtils.isBlank(characterEncoding)) {
characterEncoding = StandardCharsets.UTF_8.name();
}
encoding = Charset.forName(characterEncoding);
try {
rawBytes = request.getInputStream().readAllBytes();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public ServletInputStream getInputStream() throws IOException {
var byteInputStream = new ByteArrayInputStream(rawBytes);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
@Override
public int read() throws IOException {
return byteInputStream.read();
}
};
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(this.getInputStream(), this.encoding));
}
}
위 요청 객체를 래핑할 수 있는 클래스를 필터에서 사용합니다.
- 요청 객체를 위에서 생성한 RepeatableReadRequest 클래스로 감쌉니다.
- 래핑된 객체를 사용해 메시지를 추출합니다.
- 래핑된 객체를 필터 체인(filter chain)에 전달합니다.
@Component
public class HmacFilter extends OncePerRequestFilter {
// ...
private String getMessage(HttpServletRequest request) {
try (
OutputStream outputStream = new ByteArrayOutputStream();
InputStream inputStream = request.getInputStream()
) {
inputStream.transferTo(outputStream);
return outputStream.toString();
} catch (IOException ioException) {
throw new RuntimeException(ioException);
}
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
var wrappedRequest = new RepeatableReadRequest(request);
var message = getMessage(wrappedRequest);
// ...
filterChain.doFilter(wrappedRequest, response);
}
}
Result
위 방법으로 해당 문제를 해결하면 정상적으로 요청 메시지가 전달됩니다.
$ curl -X POST http://localhost:8080/todos\
-H 'Content-Type: application/json'\
-d '{"content":"Hello World"}' | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 60 0 35 100 25 4490 3207 --:--:-- --:--:-- --:--:-- 30000
{
"id": 1000,
"content": "Hello World"
}
CLOSING
전체 구현 코드를 확인하시려면 HMAC(Hash-based Message Authentication Code) 포스트를 참고하시길 바랍니다.
댓글남기기