[Spring] Spring은 Body를 왜 Input Stream으로 읽을까?

[Spring] Spring은 Body를 왜 Input Stream으로 읽을까?

Spring에서 Body를 직접 읽어야 하는 경우가 있다. HttpServletRequest에서 HTTP Request Body를 InputStream으로 읽도록 되어있는데, Spring은 왜 문자열이 아닌 InputStream으로 Body를 읽는걸까?

Spring에서 가끔 Request Body를 직접 읽어야 하는 경우가 있다. Controller 부터는 Request Body가 이미 파싱된 상태이기 때문에 사용 할 일이 드물겠지만, Filter에서 Request Body를 읽어야 하는 경우가 종종 있다.

자주 활용되는 예로는 요청과 응답을 로깅 할 때, Filter에서 Request Body와 Response Body를 읽어서 출력한다.

Spring에서 Request Body는 아래와 같이 HttpServletRequest에서 InputStream 또는 BufferedReader로 읽도록 되어있다.

// HttpServletRequest request
// InputStream 으로 Body 읽기
request.getInputStream()
// 또는 Reader 로 Body 읽기
request.getReader()

Filter를 개발하다가 문득 궁금증이 생겼다. 왜 Spring에서는 Request Body를 읽을 때, Byte 또는 String 타입에 저장된 데이터를 사용하지 않고 InputStream을 사용하는 것일까??

InputStream인 이유

유연성

InputStream은 Request Body를 유연하게 읽을 수 있다. 필요에 따라 다양한 방식으로 데이터를 처리할 수 있다. 예를 들어, 데이터를 수신할 때 쪼개어 읽고 처리하거나 한 번에 모두 읽을 수 있다.

호환성

InputStream의 또 다른 장점은 호환성이다. HTTP Request Body에는 String 타입의 텍스트 뿐만 아니라 바이너리 데이터, 압축 데이터 등 다양한 데이터 형식을 사용 할 수 있다. InputStream은 이러한 모든 형식을 처리할 수 있으므로 Request Body를 읽는 데 가장 적합하다.

효율성 *

가장 큰 이유 중 하나는 효율성이다. InputStream을 사용하면 애플리케이션이 전체 요청이 완료될 때까지 기다리지 않고 수신되는 데이터를 처리할 수 있다. 요청을 처리하기 전에 요청 전체 데이터를 메모리에 로드할 필요가 없기 때문에 대용량 요청을 효과적으로 처리 할 수 있다.

발생하는 문제

InputStream을 사용 함으로써 문제가 없는 것은 아니다. InputStream은 단 한 번만 읽을 수 있다. 따라서 Filter에서 Body를 읽었다면, Controller 메소드가 호출되기 전에 에러가 발생한다. Body를 파싱하기 위해 InputStream을 읽어도 더 이상 데이터가 없기 때문이다.

해결 방법

1. Spring 에서 제공하는 ContentCachingRequestWrapper로 HttpServletRequest를 Wrapping 한다.

@Override
public void doFilterInternal(
  HttpServletRequest request,
  HttpServletResponse response,
  FilterChain chain
) {
  ..중략

  chain.doFilter(new ContentCachingRequestWrapper(request), response);

  ..중략
}

ContentCachingRequestWrapper를 사용 할 때 주의 할 점은 HttpServletRequest를 그냥 사용 할 때와 동일하게 InputStream을 두 번 이상 사용 할 수는 없다.

하지만 아래 코드와 같이 컨트롤러가 실행된 후인 doFilter 호출 후 아래에 getContentAsByteArray() 메소드를 호출하여 필터에서 Request Body를 읽을 수 있다.

@Override
public void doFilterInternal(
  HttpServletRequest request,
  HttpServletResponse response,
  FilterChain chain
) {
  ..중략
  chain.doFilter(request);

  ContentCachingRequestWrapper wrappedRequest = (ContentCachingRequestWrapper)request;
  byte[] body = wrappedRequest.getContentAsByteArray();

  ..중략
}

2. RequestBody를 여러번 읽을 수 있도록 HttpServletRequest를 Wrapping 하는 클래스를 직접 작성한다.

아래와 같이 Wrapping을 하면 컨트롤러 호출 이전에도 Request Body를 읽을 수 있다.


public class MultiReadHttpServletRequest extends HttpServletRequestWrapper {

    private byte[] buffer;

    public MultiReadHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        ServletInputStream inputStream = request.getInputStream();
        buffer = inputStream.readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() {
        return new MultiReadServletInputStream(buffer);
    }
}

class MultiReadServletInputStream extends ServletInputStream {

    private ByteArrayInputStream inputStream;

    public MultiReadServletInputStream(byte[] buffer) {
        this.inputStream = new ByteArrayInputStream(buffer);
    }

    @Override
    public int read() {
        return inputStream.read();
    }

    @Override
    public boolean isFinished() {
        return inputStream.available() == 0;
    }

    @Override
    public boolean isReady() {
        return true;
    }

    @Override
    public void setReadListener(ReadListener listener) {
        throw new UnsupportedOperationException();
    }
}

발생 할 수 있는 문제

클라이언트는 요청당 Body를 단 한 번만 전송해줄 수 있기 때문에, Spring에서 Request Body를 다시 읽으려면 서버 메모리를 사용해야 한다. 따라서 파일과 같이 대용량 요청에 대해서는 메모리 사용량에 대해서 주의가 필요하다.

결론

Spring에서 HTTP Request Body를 가져올 때, 유연성, 호환성 그리고 효율성 때문에 InputStream을 사용한다. 대규모 요청을 처리할 때 요청을 수신되는 대로 데이터를 처리할 수 있으므로 서버 애플리케이션의 메모리 사용량을 줄이는 데 도움이 된다. 또한 InputStream은 높은 수준의 유연성을 제공하여 애플리케이션이 다양한 방식으로 데이터를 처리할 수 있으며, 다양한 데이터 형식과 호환된다. InputStream은 데이터를 단 한 번만 읽을 수 있으므로, RequestBody를 다시 읽기 위해서는 Wrapping이 필요하다. Wrapping을 할 때는 대용량 요청에 대한 메모리 사용량을 고려해야 한다.

출처

타이틀 이미지: UnsplashKira auf der Heide