배경

운영 환경에서 어떤 요청이 어떤 에러를 냈는지 추적하기 위해 요청마다 URL, HTTP 메서드, Request Body를 로그로 남기는 RequestLoggingFilter를 구현했다. 로컬에서는 잘 동작했는데, 파일 업로드 API를 테스트하던 중 이상한 에러가 났다.

Required request part 'file' is not present

분명히 파일을 담아서 요청을 보냈는데, 컨트롤러에서 파일을 읽지 못했다.

원인

HTTP 요청의 body는 InputStream으로 전달되는데, InputStream은 단방향 스트림이다. 필터에서 읽었다고 해서 컨트롤러에서도 읽을 수 있는 게 아니다. RequestLoggingFilter가 로깅을 위해 body를 먼저 읽어버리면, 그 이후 컨트롤러에서 getInputStream()을 호출해도 이미 소비된 빈 스트림이 반환된다.

고민한 방법들

1. Spring의 ContentCachingRequestWrapper 사용
Spring이 이미 같은 목적의 ContentCachingRequestWrapper를 제공한다.
그런데 이 클래스는 body를 미리 읽어두는 게 아니라, 읽히는 시점에 캐싱하는 방식이라 필터에서 먼저 읽어야 한다는 전제가 필요하다. 동작 방식을 파악하는 데 시간이 걸릴 것 같아서, 구조가 명확한 HttpServletRequestWrapper를 직접 상속해서 만들기로 했다.

2. Multipart 요청도 같은 방식으로 처리
처음에는 일반 요청과 동일하게 body를 byte[]로 읽어서 캐싱하면 되지 않을까 생각했다.
그런데 Multipart 요청은 Spring 내부의 MultipartResolver가 직접 InputStream을 파싱하는 구조라, 필터에서 먼저 읽어버리면 MultipartResolver가 처리할 데이터가 없어진다. 파일 크기에 따라 메모리 문제도 생길 수 있다. Multipart는 로깅 자체를 건너뛰는 게 낫다고 판단했다.


해결 방법

생성 시점에 body를 byte[]로 읽어서 저장해두고, getInputStream() 호출 시마다 저장된 바이트 배열로 새 스트림을 반환하는 Wrapper를 만들었다.

public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {

    private final byte[] cachedBody;

    public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = request.getInputStream().readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody);
        return new ServletInputStream() {
            @Override public boolean isFinished() { return byteArrayInputStream.available() == 0; }
            @Override public boolean isReady() { return true; }
            @Override public void setReadListener(ReadListener readListener) {}
            @Override public int read() { return byteArrayInputStream.read(); }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
}

필터에서는 Multipart 요청을 먼저 분기해서 skip하고, 나머지는 Wrapper로 감싼 뒤 필터 체인에 넘긴다.

// Multipart는 로깅 skip
String contentType = request.getContentType();
if (contentType != null && contentType.startsWith("multipart/")) {
    filterChain.doFilter(request, response);
    return;
}

// 나머지 요청은 Wrapper로 감싸서 body 재사용 가능하게
CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request);
String requestBody = wrappedRequest.getReader().lines().reduce("", String::concat);
log.info("[REQUEST] {} {} body = {}", method, fullUrl, requestBody);

filterChain.doFilter(wrappedRequest, response); // 컨트롤러에서도 읽을 수 있음



정리

  • 필터에서 HttpServletRequest를 직접 사용하면 body를 한 번만 읽을 수 있다.
  • HttpServletRequestWrapper를 상속해서 body를 미리 캐싱해두면 여러 번 읽을 수 있고, 필터 체인 전체에서 안전하게 body를 재사용할 수 있다.
  • Multipart 요청은 Spring 내부 파싱 구조 때문에 같은 방식을 적용할 수 없으므로 별도로 처리해야 한다.