HttpServletRequest Body를 여러번 읽을 수 없을까? - ContentCachingRequestWrapper

2025. 12. 17. 19:45·Code/Spring

Request Body에 대한 로그를 Filter 단계에서 찍고 싶을 때...

API를 개발하다 보면 "어떤 데이터를 바탕으로 에러가 나지?"라는 생각으로 재현하고 싶을 때가 있습니다. 이때 가장 먼저 확인하고 싶은 것이 바로 Request Body(JSON) 의 내용입니다.

하지만 그냥 로그를 찍으면 Stream Closed 에러가 발생하거나, 컨트롤러 계층에서 비어있는 요청이 전달되게 됩니다.

오늘은 이 문제의 근본적인 원인인 HttpServletRequest의 특징부터 이를 해결할 수 있는ContentCachingRequestWrapper에 대해서 알아보겠습니다.


1. HttpServletRequest에 대해서..

가장 먼저 우리가 다루고 있는 객체, HttpServletRequest 에 대해 간단히 짚고 넘어가겠습니다.

HttpServletRequest는 서블릿(Servlet) 표준 인터페이스로, 클라이언트가 서버로 보낸 HTTP Request Message를 객체로 캡슐화한 것입니다.

쉽게 말해 서버의 소켓을 통해 들어은 "편지" 라고 생각하면 됩니다. 그 내용에는 아래와 같은 정보들이 담겨 있습니다.

  • Header: 수신인, 발신인, 기타 정보 (User-Agent, Content-Type 등)
  • Parameter:  간단한 메모 내용 (Query String)
  • Body: 실제 내용 (JSON, File, XML 등)

Spring 컨트롤러에서 우리가 편하게 사용하는 @RequestBody 어노테이션도 결국 이 객체 안의 Body 데이터를 꺼내서 사용하는 것입니다. 아래의 그림을 통해서 FilterChain 과정을 간단히 그려봤습니다.

 


2. HttpServletRequest의 getInputStream()

문제는 이 Body 를 읽는 방식에 있습니다. HttpServletRequest는 Body 내용을 읽기 위해 getInputStream() 메서드를 제공합니다. 이름에서 볼 수 있든 이는 Stream 방식입니다. 이러한 Stream의 특성에 대해서 이해하면, 왜 한 번만 읽을 수 있는지 이해할 수 있습니다.

 

이러한 Stream은 Read Once라는 특징을 가집니다. HttpServletRequest는 아래와 과정을 거치며 소비되게 됩니다.

 

  1. Logging Filter가 로그를 남기기 위해 getInputStream를 사용해서 스트림을 소비합니다. (Read).
  2. 이후 요청이 Controller에 도착합니다.
  3. Controller가 데이터 바인딩을 위해 다시 getInputStream()을 사용하지만 이미 스트림이 비어있습니다.
  4. 결국에는 Body가 비어있게 있어서 Bad Request 에러, java.io.IOException: Stream closed 에러가 발생하게 됩니다.

"즉, 앞에서 Stream을 소비해 Body를 읽어버리면, 뒤 계층에서는 데이터가 도착하지 못합니다."


3. 해결 : ContentCachingRequestWrapper

이 문제를 해결하려면 어떻게 해야 할까요? 내용을 읽어도 사라지지 않게 복사본을 저장(Caching) 해두면 됩니다.

 

Spring Web 라이브러리는 이러한 Filter 계층의 Body 로깅을 위해서 util 패키지를 지원합니다.  이 안에는 내부적으로 ContentCachingRequestWrapper 라는 클래스를 제공합니다. 이 클래스는 HttpServletRequest의 Wrapper 역할을 하면서, 아래와 같은 기능을 제공합니다.

  1. 내부적으로 메모리(ByteArrayOutputStream)를 가지고 있습니다.
  2. getInputStream()으로 데이터를 읽을 때에 데이터를 동시에 자신의 내부 메모리에도 내용을 복사(캐시)해둡니다.
  3. 이러한 캐시된 내용을 나중에 getContentAsByteArray()를 호출하면 복사해둔 Body를 다시 읽을 수 있습니다.

아래는 mermaid로 구성한 ContentCachingRequestWrapper의 상속 구조입니다.

 

 


4. 예제 테스트

간단한 데모를 통해 실제로 어떻게 동작하는지 확인해봅시다. 일단 Body를 받게되는 엔드포인트를 만들어 줍시다.

@RestController
public class DemoController {

    @PostMapping("/api/test")
    public String test(@RequestBody String body) {
        System.out.println("[DemoController] Received Body: " + body);
        return "ok";
    }
}

 

이후에 두 가지 ContentCachingRequestWrapper 사용하는 경우와 Wrapper를 적용하기 위해서는 Filter에서 원본 요청을 래핑해야 합니다.

 

4.1 LoggingFilter - 정상적인 ContentCachingRequestWrapper 래핑

@Component
public class LoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (request instanceof HttpServletRequestWrapper) {
            System.out.println("[LoggingFilter] request는 HttpServletRequestWrapper 입니다.");
        }
        // 데이터가 읽어질때 캐시가 저장되도록 래핑
        ContentCachingRequestWrapper cachedRequest = new ContentCachingRequestWrapper(request, 1024);
        ContentCachingResponseWrapper cachedResponse = new ContentCachingResponseWrapper(response);

        // 캐시된 요청을 다음 filterChain으로 전송
        // Controller가 wrappingRequest.getInputStream()을 호출해서 읽는 순간에 내부적으로 데이터가 캐싱됨.
        filterChain.doFilter(cachedRequest, cachedResponse);

        // 로그 출력
        System.out.println("[LoggingFilter] Request URI: " + request.getRequestURI());
        System.out.println("[LoggingFilter] Request Body: " + getRequestBody(cachedRequest));
    }

    // 캐시된 내용을 읽기 - doFilter 전에 읽으면 캐시가 비어있음
    private String getRequestBody(ContentCachingRequestWrapper request) {
        // 내부 메모리에 저장된 바이트를 가져오기
        byte[] content = request.getContentAsByteArray();
        if (content.length == 0) return "";

        try {
            String encoding = request.getCharacterEncoding();
            return new String(content, encoding != null ? encoding : "UTF-8");
        } catch (Exception e) {
            return "Unreadable Body";
        }
    }
}

4.2 BadFilter - HttpServletRequestWrapper의 inputStream 소비

@Component
public class BadFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // HttpServletRequest request가 HttpServletRequestWrapper가 맞는지 검증
        if (request instanceof HttpServletRequestWrapper) {
            System.out.println("[BadFilter] request는 HttpServletRequestWrapper 입니다.");
        }

        // 기본 HttpServletRequestWrapper 사용
        HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request);

        // 필터에서 먼저 InputStream을 읽음
        String body = StreamUtils.copyToString(wrapper.getInputStream(), StandardCharsets.UTF_8);
        System.out.println("[BadFilter] Body: " + body);

        // 이미 빈 껍데기가 된 wrapper를 컨트롤러로 전달
        filterChain.doFilter(wrapper, response);
    }
}

4.3 로깅위치 유의!

저는 그냥 래핑하자마자 로그를 찍어봤는데, Controller 계층으로 데이터가 올라가지 않는 문제를 발견했습니다.


그런데 이러한 점은 ContentCachingRequestWrapper에 대한 이해가 부족해서 그랬었습니다.

반드시 chain.doFilter() 이후에 cachedRequest의 바디 로그를 출력해야 합니다.

 

ContentCachingRequestWrapper는 생성자가 호출될 때 내용을 캐싱하는 것이 아니라, 실제로 다른 객체가(주로 컨트롤러) InputStream을 읽을 때 캐싱을 수행합니다. 따라서 요청 처리 전에는 캐시된 내용이 비어있을 수 있습니다.


5. 한계

아래의 경우에는 불필요하거나 제대로 동작하지 않을 수 있기에 주의가 필요합니다.

  • 파일 업로드시 : Body 내용을 캐시 메모리(Byte Array)에 담아두기 때문에 파일 업로드와 같은 큰 요청이 들어올 경우 해당 캐시들이 쌓여서 OOM(OutOfMemoryError) 을 유발할 수 있습니다. 파일 업로드 경로는 Filter에서 제외하거나, Wrapper 적용 조건을 걸어야 합니다.
  • Body가 필요없는 Request : 컨트롤러에서 @RequestBody 등을 통해 body를 읽지 않는 요청이라면, doFilter가 끝나도 캐시가 비어있을 수 있습니다.
  • Form Data : application/x-www-form-urlencoded 형식의 경우, 서블릿 컨테이너가 파라미터 파싱을 위해 스트림을 먼저 소비하는 경우가 있어 적합하지 않습니다.

끝내며

HttpServletRequest, ContentCachingRequestWrapper 그리고 InputStream 특성에 대해서 추가로 공부해보는 시간을 가질 수 있었습니다. IO Stream을 보다보니 역시 시스템 레벨의 지식까지 안쓰이는 없네요. 역시 공부했던 CS는 항상 뜻밖의 도움이됩니다.

'Code > Spring' 카테고리의 다른 글

아직도 Service에서 만든 DTO를 그대로 반환하시나요?  (1) 2025.12.02
'Code/Spring' 카테고리의 다른 글
  • 아직도 Service에서 만든 DTO를 그대로 반환하시나요?
doma17
doma17
모든 개발 주저리 모음집
  • doma17
    doma17의 개발 블로그
    doma17
  • 전체
    오늘
    어제
    • 분류 전체보기 (9) N
      • 주저리 (3)
      • Code (4) N
        • Java (2) N
        • Spring (2)
      • Infra (2)
        • Redis (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    InputStream
    fluentd
    jvm
    Java25
    spring
    ContentCachingRequestWrapper
    Swarm
    java
    Docker
    HTTPServletRequest
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
doma17
HttpServletRequest Body를 여러번 읽을 수 없을까? - ContentCachingRequestWrapper
상단으로

티스토리툴바