Spring Boot에서 HTTP 요청 본문 재사용을 위한 InputStream 캐싱 구현

Spring Boot 애플리케이션에서 HTTP 요청의 본문(body)을 여러 번 읽어야 하는 상황이 발생할 수 있다. 예를 들어, 로깅 목적으로 원본 요청 데이터를 확인한 후 컨트롤러에서 다시 본문을 파싱해야 하는 경우, 표준 HttpServletRequest의 InputStream은 한 번만 읽을 수 있어 두 번째 접근 시 비어 있는 상태로 반환된다. 이 문제를 해결하려면 요청이 프레임워크 내부 로직에 도달하기 전에 InputStream의 내용을 메모리에 보관하고, 이후 접근 시 보관된 데이터에서 새로운 스트림을 생성하여 반환하는 메커니즘이 필요하다.

요청 본문 캐 래퍼 클래스

HttpServletRequestWrapper를 확장하여 요청 본문을 바이트 배열로 저장하고, getInputStream() 호출 시마다 새로운 스트림을 생성하는 커스텀 래퍼를 구현한다.
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.springframework.http.HttpMethod;
import org.springframework.util.CollectionUtils;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;

public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {

    private final byte[] cachedPayload;
    private static final int DEFAULT_BUFFER_SIZE = 8192;

    public CachedBodyHttpServletRequest(HttpServletRequest originalRequest) throws IOException {
        super(originalRequest);
        this.cachedPayload = extractPayload(originalRequest);
    }

    private byte[] extractPayload(HttpServletRequest request) throws IOException {
        String contentType = request.getContentType();
        
        // multipart/form-data는 파싱 과정에서 소모되므로 별도 처리
        if (isMultipartContent(contentType)) {
            return drainInputStream(request.getInputStream());
        }
        
        // 폼 데이터 POST 요청은 파라미터 맵에서 재구성
        if (isUrlEncodedFormPost(request)) {
            return reconstructFormData(request);
        }
        
        // 그 외 모든 요청은 InputStream에서 직접 읽기
        return drainInputStream(request.getInputStream());
    }

    private boolean isMultipartContent(String contentType) {
        return contentType != null && contentType.startsWith("multipart/");
    }

    private boolean isUrlEncodedFormPost(HttpServletRequest request) {
        return HttpMethod.POST.matches(request.getMethod()) &&
               request.getContentType() != null &&
               request.getContentType().contains("application/x-www-form-urlencoded");
    }

    private byte[] reconstructFormData(HttpServletRequest request) {
        Map paramMap = request.getParameterMap();
        
        if (CollectionUtils.isEmpty(paramMap)) {
            return new byte[0];
        }

        String formString = paramMap.entrySet().stream()
            .map(entry -> {
                String key = entry.getKey();
                String[] values = entry.getValue();
                return java.util.Arrays.stream(values)
                    .map(val -> key + "=" + val)
                    .collect(Collectors.joining("&"));
            })
            .collect(Collectors.joining("&"));

        return formString.getBytes(StandardCharsets.UTF_8);
    }

    private byte[] drainInputStream(InputStream source) throws IOException {
        try (ByteArrayOutputStream sink = new ByteArrayOutputStream()) {
            byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
            int bytesRead;
            while ((bytesRead = source.read(buffer)) != -1) {
                sink.write(buffer, 0, bytesRead);
            }
            sink.flush();
            return sink.toByteArray();
        }
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream bis = new ByteArrayInputStream(this.cachedPayload);
        
        return new ServletInputStream() {
            @Override
            public int read() throws IOException {
                return bis.read();
            }

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

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

            @Override
            public void setReadListener(ReadListener listener) {
                throw new UnsupportedOperationException("동기 처리만 지원합니다");
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(
            new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)
        );
    }

    public byte[] getCachedPayload() {
        return this.cachedPayload.clone();
    }
}

필터를 통한 래퍼 적용

구현한 래퍼를 모든 요청에 자동으로 적용하기 위해 OncePerRequestFilter 기반의 필터를 생성한다. 이 필터는 요청 체인의 최상단에서 원본 요청을 래퍼로 교체하여 이후 모든 컴포넌트가 캐싱된 본문에 접근할 수 있게 한다.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestBodyCacheFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        
        // GET, HEAD, DELETE 등 본문이 없는 메서드는 래핑 생략
        if (!requiresBodyCaching(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request);
        filterChain.doFilter(wrappedRequest, response);
    }

    private boolean requiresBodyCaching(HttpServletRequest request) {
        String method = request.getMethod();
        return method.equalsIgnoreCase("POST") 
            || method.equalsIgnoreCase("PUT") 
            || method.equalsIgnoreCase("PATCH");
    }
}

Spring Boot 자동 구성 등록

필터가 스프링 컨텍스트에서 자동으로 등록되도록 하거나, 수동으로 빈 구성을 통해 우선순위를 명시적으로 제어할 수 있다. 아래는 선택적인 명시적 구성 예시이다.
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class WebRequestConfiguration {

    @Bean
    public FilterRegistrationBean<RequestBodyCacheFilter> requestBodyCacheFilterRegistration(
            RequestBodyCacheFilter filter) {
        
        FilterRegistrationBean<RequestBodyCacheFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(filter);
        registration.addUrlPatterns("/api/*", "/webhook/*");
        registration.setOrder(Integer.MIN_VALUE);
        registration.setName("requestBodyCacheFilter");
        return registration;
    }
}

활용 예시: 컨트롤러에서 캐싱된 본문 접근

래퍼가 적용된 후에는 컨트롤러에서 여러 번 본문을 읽거나, 필터에서 읽은 후에도 컨트롤러에서 정상적으로 @RequestBody 바인딩이 가능하다.
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @PostMapping
    public ResponseEntity<String> createOrder(
            @RequestBody OrderRequest payload,
            HttpServletRequest request) throws IOException {
        
        // 첫 번째: @RequestBody로 자동 바인딩된 객체 사용
        
        // 두 번째: 필요시 원본 JSON 문자열 재확인
        if (request instanceof CachedBodyHttpServletRequest cached) {
            String rawJson = new String(cached.getCachedPayload());
            // 로깅 또는 서명 검증 등에 활용
        }
        
        // 세 번째: InputStream으로 다시 읽기 (같은 데이터 반환)
        try (InputStream is = request.getInputStream()) {
            // 스트림 재사용 가능
        }
        
        return ResponseEntity.ok("처리 완료");
    }
}
이 구현 방식의 핵심 원리는 Servlet 스펙의 요청 래핑 메커니즘을 활용하여, 프레임워크의 내부 파싱 로직과 애플리케이션 코드 모두가 동일한 요청 본문 데이터에 독립적으로 접근할 수 있게 하는 것이다. 메모리 사용량을 고려할 때, 대용량 파일 업로드가 있는 multipart 요청은 별도의 임시 파일 저장 전략을 고려해야 한다.

태그: Spring Boot Servlet Filter HttpServletRequestWrapper InputStream java

5월 24일 21:48에 게시됨