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 요청은 별도의 임시 파일 저장 전략을 고려해야 한다.