[Spring] Request와 RequestWrapper (java.io.IOException: Stream closed)

yourjin·2023년 3월 13일
1

dev.log

목록 보기
9/13

➕ Topic


  • java.io.IOException: Stream closed
  • 위의 에러와 함께 POST 요청 시 body 값을 가져오지 못한다면, 어느 곳에서 Request 객체의 InputStream 값을 이미 읽어버린 건 아닌지 확인해보자!
  • 이 포스팅은 위의 에러를 해결한 과정을 다룬다. 혹시 Filter, Interceptor, AOP 등 Spring 공통 로직 처리에 대한 사전 지식이 부족하다면 여기를 참고 바란다.

➕ Contents


InputStream

난 비록 Stream과 관련해서 건드린 부분이 없으나, 에러 로그에 떡하니 Stream이 적혀있으므로 Stream에 대해서 알아볼 필요가 있었다.

InputStream/OutputStream 은 java.io 패키지에서 제공하는 입출력을 위한 추상클래스이다. 여기서 입출력이란 파일, 네트워크, 키보드 등으로 데이터를 주고 받는 것을 말한다. 이때 데이터는 byte 단위로 Stream이라는 통로를 통하는데, 이 통로가 하는 공통적인 역할에 대해서 규정(추상화)한 클래스가 바로 InputStream/OutputStream이다.

출처: https://sjh836.tistory.com/120

여기서 가장 주목해야 할 Stream의 특징은 다음과 같다.

Stream은 단방향으로 흘러가며, 한번 읽은 것(read)을 다시 읽을 수 없다.

HttpServletRequest

평소에 요청 정보를 담고 있는 객체로 HttpServletRequest를 사용하고 있는데, 여기에서도 Stream이 사용되고 있다.

HttpServletRequest의 서브 클래스인 ServletRequest에 보면 getInputStream() 이라는 메서드가 있다. 주석을 보면 body에 있는 값을 가져올 때 ServletInputStream의 형태로 가져온다. (Retrieves the body of the request as binary data using a ServletInputStream.)

앞서 다룬 Filter, Interceptor에서 공통 로직을 처리할 때 이 request 객체에 있는 정보들을 활용하는 경우가 종종 있다. 필자의 경우 Filter에서 로깅할 때 parameter 값을 같이 출력하는 케이스가 있었다. 문제는 body도 InputStream이기 때문에, 한번 읽고 나서는 다시 읽을 수 없다는 데 있다. 즉 Filter나 Interceptor에서 body 값을 이미 읽었다면, Controller에서는 값을 가져올 수 없다는 것이다.

HttpServletRequestWrapper

그럼 공통 로직 처리할 때는 body 값을 못쓰는가?

다행히 그런 것은 아니고, HttpServletRequestWrapper라는 Wrapper를 확장해 Request 값을 복사해 놓고 사용할 수 있다. HttpServletRequestWrapper는 ServletRequestWrapper를 상속 받고, ServletRequestWrapper는 ServletRequest를 상속 받고 있다. 안에 로직을 살펴보면 생성자에서 ServletRequest 객체를 그대로 참조하고 있다.

이 클래스를 상속하는 클래스를 만들어서 Request 를 복사해놓고, 실제 Request 객체가 아닌 Wrapper 객체를 사용한다면 Stream을 이미 읽어버려서 생기는 에러를 방지할 수 있다.


public class TestRequestWrapper extends HttpServletRequestWrapper {
    private byte[] bodyData;
    private Map<String, Object> params;
    
    public TestRequestWrapper(HttpServletRequest request) {
        super(request);

        try{
            InputStream inputStream = request.getInputStream();
            this.bodyData = IOUtils.toByteArray(inputStream);

            this.params = new HashMap<String, Object>(request.getParameterMap());
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.bodyData);
        ServletInputStream servletInputStream = new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return byteArrayInputStream.read();
            }
        };
        return servletInputStream;
    }
}

Wrapper를 쓰는데도 에러가 난다고? 사용하는 시점도 중요하다!

문제는 나의 경우는 Wrapper 객체를 이미 만들어서 사용 중이었다는 것이다.

달라진 점이라면 기존에는 Wrapper 객체를 사용하는 Filter가 있어서 Filter에서 Wrapping 한 다음에 Interceptor로 넘어갔다면, 새로운 프로젝트에서는 해당 Filter가 필요 없어져서 주석 처리를 했다는 것이다. 구글링을 하던 중에 이유를 찾을 수 있었는데, Disptcher Servlet의 동작 과정을 알고 있어야 이해 가능한 부분이었다.

Dispatcher Servlet 동작 과정

앞선 포스팅에서도 짚고 넘어갔는데, Interceptor는 Filter와 다르게 Spring Context 안에서 동작하기 때문에 Dispatcher Servlet을 거쳐서 실행된다.


출처: https://mangkyu.tistory.com/18

Dispatcher Servlet의 동작 과정을 보면,
② Handler Mapping: 요청을 처리할 컨트롤러를 찾음 (HandlerExecutionChain을 조회)
③ Handler Adapter: 요청을 컨트롤러로 위임할 핸들러 어댑터를 찾아서 전달함

이 과정을 거쳐서 실제로 요청을 처리할 Controller에게 전달된다.

출처: https://mangkyu.tistory.com/18

이것만 보면 이해가 잘 안될 수 있는데, 실제로 이 과정이 일어나는 DispatcherServlet의 doDispatch() 소스를 보면 다음과 같다.

3번 HandlerExectionChain 처리 부분을 주목해보자. 먼저 applyPreHandle()에서 Interceptor의 preHandle() 을 호출한 후에, 실제로 요청을 처리할 컨트롤러(핸들러)에게 전달하는 것을 볼 수 있다.

만약에 Filter가 아닌 Interceptor에서만 Wrapper 클래스를 사용한다면 다음과 같은 순서로 진행된다.

  1. Wrapping 되지 않은 Request 객체가 Interceptor에게 전달된다.
  2. Interceptor에서 Request 객체를 읽고, Wrapper 객체를 반환한다.
    1. Interceptor 로직에서 Request 객체를 읽었는지 여부에 상관 없이, Wrapper 객체를 만들면서 이미 한번은 읽기 때문에 Request 객체는 더 이상 읽을 수 없는 상태가 된다.
    2. 코드를 보면 알겠지만, 이 Wrapper 객체는 preHandle() 를 처리할 때만 전달될 뿐, doDispatch() 의 이후 과정에서 사용하는 Request 객체는 처음에 전달된 Wrapping 되지 않은 객체이다.
  3. 이후 요청을 처리할 Controller에게 전달되어 데이터 바인딩을 할 때는 이미 읽은 Request 객체가 전달된다. → Stream Closed 에러 발생!

한마디로 Interceptor부터 Wrapper 객체를 만드는 것은 doDispatch() 의 로직상 의미가 없다. Interceptor 로직에서 굳이 Request 객체를 읽을 필요가 없다면, Wrapper 객체를 만들며 InputStream을 읽는 한번 뿐인 기회를 소모하지 않고 그냥 Request 객체를 보내면 된다. 그게 아니라면 Filter에서부터 Wrapper 객체로 전달해야 데이터 바인딩 시점까지 데이터가 잘 전달 될 수 있다.

번외: application/x-www-form-urlencoded 도 처리가 필요하다면

서버에서 application/json 타입 외에 application/x-www-form-urlencoded 타입(폼을 통해 submit된 데이터)도 처리가 필요하다면, 아까 작성한 TestRequestWrapper에서 약간의 수정이 필요하다. 폼 타입으로 전달된 데이터는 Spring에서 getParameter()로 데이터 바인딩 처리를 하기 때문이다. 따라서 getParameterXX() 메서드들도 오버라이드 해주면 정상적으로 동작한다.

➕ References


profile
make it mine, make it yours

1개의 댓글

comment-user-thumbnail
2024년 1월 24일

잘보고가여

답글 달기