[스프링] 서블릿과 리액티브의 요청 본문 접근 및 캐싱
2026. 10. 8.
웹 애플리케이션 개발 시 감사 로깅(audit logging), 요청 위변조 서명 검증, 커스텀 페이로드 분석 파이프라인 구축 등을 위해 컨트롤러 진입 전 필터 계층에서 HTTP 요청 본문을 읽어야 하는 요구사항이 자주 발생한다. 개발 환경에서 디버그를 위한 로깅 목적으로 본문 확인이 필요한 경우도 발생한다.
그러나 HTTP 요청 본문은 기본적으로 한 번만 읽을 수 있는 스트림으로 제공된다. 필터에서 본문을 먼저 읽어 소비하면 뒤이어 실행되는 컨트롤러는 빈 본문을 받는다. 그 결과는 컨트롤러가 본문을 읽는 방식에 따라 달라진다. @RequestBody는 예외와 함께 HTTP 400 또는 500이 되고, 스트림을 직접 읽는 컨트롤러는 오류 없이 빈 본문으로 정상 응답한다. 각 절의 블록에서 실행한 코드와 호출 로그를 펼쳐 볼 수 있다.
서블릿 기반 스프링 MVC와 비동기 논블로킹 기반 스프링 웹플럭스는 I/O 아키텍처가 완전히 다르므로 본문을 안전하게 읽고 보존하는 방식 또한 서로 다른 메커니즘을 사용한다.
서블릿
스트림 1회 소비 제약
서블릿 표준 인터페이스인 HttpServletRequest의 getInputStream()은 단방향 소켓 스트림인 ServletInputStream을 반환한다. 컨트롤러가 @RequestBody를 통해 JSON 데이터를 객체로 역직렬화할 때 내부적으로 ServletInputStream을 끝까지 읽는다.
필터의 전처리 단계에서 본문을 먼저 읽어버리면 스트림 포인터가 파일 끝(EOF)에 도달하게 된다. 그 결과 컨트롤러의 HttpMessageConverter는 읽을 데이터가 존재하지 않아 HttpMessageNotReadableException 예외를 발생시키며 HTTP 400으로 요청 처리에 실패한다.
필터가 getReader()로 읽었다면 컨트롤러가 getInputStream()을 호출하는 순간 IllegalStateException이 발생하고 HTTP 500이 된다. 컨트롤러가 @RequestBody 없이 getInputStream()을 직접 읽는다면 예외가 발생하지 않는다. 이때는 첫 read()가 -1을 돌려주고 빈 본문(0바이트)으로 정상 응답(HTTP 200)이 반환된다.
동기 래퍼: ContentCachingRequestWrapper
스프링 웹(web) 라이브러리는 이 문제를 해결하기 위해 지연 캐싱 메커니즘을 지원하는 ContentCachingRequestWrapper 클래스를 제공한다.
지연 캐싱(lazy caching)이란 래퍼 인스턴스를 생성하는 시점에는 원본 소켓 스트림을 즉시 읽지 않는 것을 말한다. 다운스트림의 컨트롤러나 다음 필터가 getInputStream() 또는 getReader()를 호출하여 실제로 데이터를 읽을 때 읽기 처리된 바이트를 래퍼 내부의 ByteArrayOutputStream 버퍼에 동시에 복사한다. 따라서 필터의 전처리 단계(체인 실행 전)에서는 버퍼가 비어 있으며, 체인 실행이 완료된 후처리 단계에서 getContentAsByteArray()를 호출해야 캐싱된 바이트 데이터를 정상적으로 획득할 수 있다. 래퍼로 감싸더라도 필터가 체인 실행 전에 getInputStream()으로 본문을 직접 읽으면 원본 스트림이 이미 소진된 상태가 되어 컨트롤러가 빈 본문을 받는다(HTTP 400). 래퍼가 막아 주는 것은 다운스르트림이 읽을 때의 복사뿐이다.
응답 본문 역시 동일하게 확인하려면 ContentCachingResponseWrapper를 함께 사용하면 된다. 응답 본문 래핑 시 주의점은 이 래퍼는 응답 데이터를 클라이언트 소켓으로 즉시 방출하지 않고 내부 버퍼에 모아두기 때문에 필터 로직의 종료 시점에 반드시 copyBodyToResponse() 메서드를 호출해야만 클라이언트에게 실제 응답 본문이 온전히 전송된다. 호출이 누락되면 클라이언트는 0바이트의 빈 응답을 받게 된다.
리액티브 환경
리액티브 스트림의 특성과 한계
스프링 웹플럭스는 서블릿 컨테이너 대신 네티(Netty) 등의 이벤트 루프 위에서 논블로킹 방식으로 동작하며 요청 본문을 Flux<DataBuffer> 타입으로 처리한다. 따라서 서블릿의 처리 방식과 다르다.
스프링 웹플럭스 환경에서는 본문이 하나의 완성된 바이트 배열로 즉시 도착하지 않고 네트워크 패킷 청크 단위로 분할 발행되는 비동기 청크 전달 과정이 일어난다. 이러한 리액티브 스트림 환경에서 가장 먼저 마주하는 제약은 단일 구독(single subscriber) 원칙이다. 네티와 리액티브 웹 계층의 본문 스트림은 기본적으로 단 한 번의 구독만 허용한다. 만약 필터가 request.getBody().subscribe()를 직접 호출하여 본문을 먼저 소비한 뒤 컨트롤러가 다시 본문 스트림을 구독하려고 시도하면 Rejecting additional inbound receiver라는 메시지와 함께 IllegalStateException이 발생하며 HTTP 500 에러가 반환된다. 특히 비동기 파이프라인의 특성상 컨트롤러의 두 번째 구독 시도가 감지되는 즉시 스트림 전체가 오류로 중단된다. 따라서 실제 네트워크 레벨에서 본문 데이터가 방출되기도 전에 요청 처리 라이프사이클이 종료되고 첫 번째로 구독을 시도했던 필터조차 1바이트의 본문도 수신하지 못하게 된다. 이러한 오류는 리액티브 프로그래밍에서는 모든 것이 비동기적으로 처리되어야 한다는 비동기 구현 원칙을 보여주는 것이기도 하다.
이러한 구독 문제를 피하기 위해 필터에서 DataBufferUtils.join()을 사용하여 본문 스트림을 끝까지 모아서 읽은 뒤 다운스트림 체인으로 넘기는 방법을 사용할 경우 정합성 문제가 존재한다. join() 연산자는 발행되는 모든 DataBuffer 청크를 단일 버퍼로 결합하면서 원본 스트림을 끝까지 완전히 소비해 버린다. 스트림이 이미 완료 처리되었기 때문에 다운스트림의 컨트롤러로 예외 없이 빈 본문 스트림이 그대로 유입된다. 이 상태에서 @RequestBody 어노테이션으로 JSON 객체 역직렬화를 기대하는 컨트롤러는 본문이 완전히 누락된 것으로 판단하여 ServerWebInputException을 발생시키고 HTTP 400 에러 응답을 반환한다. 반면 본문 스트림을 직접 수집하도록 구현된 컨트롤러라면 오류를 감지하지 못한 채 길이가 0인 빈 데이터로 정상 처리하고 HTTP 200 성공 응답을 반환하는 비정상적인 처리가 수행된다.
마지막으로 고성능 네트워크 I/O를 위해 네티가 사용하는 다이렉트 메모리 풀링(direct memory pooling)과 참조 카운트(reference count) 메커니즘을 고려해야 한다. 네티는 힙 메모리 할당 및 가비지 컬렉션 부하를 줄이기 위해 PooledByteBufAllocator를 통해 풀링된 다이렉트 버퍼를 사용하며 각 버퍼는 ReferenceCounted 인터페이스를 통해 명시적인 참조 카운트를 기반으로 생명주기가 관리된다. 필터가 전달받은 DataBuffer를 읽은 뒤 DataBufferUtils.release()를 호출하여 임의로 해제해 버리고 다운스트림으로 그대로 흘려보내면 버퍼의 참조 카운트는 이미 0이 된다. 이후 다운스트림의 스프링 메시지 디코더(예: Jackson2JsonDecoder)나 네티 파이프라인이 정상적인 흐름에 따라 동일한 버퍼를 소비하고 다시 해제하려 시도하는 순간 이미 해제된 버퍼를 다시 참조하려 했다는 의미의 refCnt: 0과 함께 IllegalReferenceCountException이 발생하며 최종적으로 HTTP 500 에러로 요청이 강제 종료된다. 반대로 필터에서 읽기 작업을 위해 참조 카운트를 올리고 이를 적절히 해제하지 못했을 때 발생하는 메모리 누수 위험 역시 남게 된다.
비동기 데코레이터
웹플럭스에서는 동기 방식의 바이트 배열 캐싱 래퍼를 사용할 수 없다. 대신 데코레이터 패턴 기반의 ServerHttpRequestDecorator와 ServerHttpResponseDecorator를 활용한다.
요청 스트림 인터셉트 (ServerHttpRequestDecorator)
ServerHttpRequestDecorator의getBody()메서드를 오버라이드한다.- 다운스트림을 향해 흘러가는
Flux<DataBuffer>파이프라인 중간에 개입하여, 각 데이터 버퍼 청크가 발행될 때 바이트를 읽어 별도 메모리에 기록한다. - 이때 다운스트림 핸들러가 해당 버퍼를 정상 소비할 수 있도록 버퍼를 해제하지 않고 필요한 바이트만 복사해 기록한 뒤 그대로 방출해야 한다. 복사만 한 데코레이터는 HTTP 200으로 정상 처리됐고, 해제까지 한 데코레이터는 앞 절의
IllegalReferenceCountException으로 실패했다. - 본문 전체를 모아 바이트 배열로 캐시한 뒤 새 버퍼로 다시 내보내는 방식도 정상 처리됐다. 단, 본문이 비어 있으면
join()이 아무것도 방출하지 않아 체인이 실행되지 않는 점에 주의한다. DataBufferUtils.retain()으로 참조 카운트를 직접 관리하는 방식은 측정하지 않았다.
응답 스트림 인터셉트 (ServerHttpResponseDecorator)
ServerHttpResponseDecorator의writeWith()메서드를 오버라이드한다.- 컨트롤러가 작성을 완료하고 클라이언트를 향해 방출하는 출력
DataBuffer퍼블리셔 스트림을 가로채 응답 본문을 기록한 뒤 실제 소켓 채널로 방출한다.
필터 체인 전달
- 데코레이팅된 요청과 응답 객체를
ServerWebExchange.mutate()메서드를 통해 결합하여 새로운ServerWebExchange인스턴스를 빌드하고, 이를WebFilterChain.filter()에 전달한다.
서블릿 vs 리액티브 본문 접근 비교
| 비교 항목 | 서블릿 스택 (Spring MVC) | 리액티브 스택 (Spring WebFlux) |
|---|---|---|
| 기반 데이터 타입 | ServletInputStream (동기 블로킹) |
Flux<DataBuffer> (비동기 논블로킹) |
| 사용 인터페이스 / 래퍼 | ContentCachingRequestWrapper |
ServerHttpRequestDecorator |
| 캐싱 메커니즘 | 컨트롤러 소비 시 내부 버퍼 동시 복사 (지연 캐싱) | 리액티브 스트림 파이프라인 연산자 인터셉트 |
| 본문 접근 시점 | 필터 체인 완료 후 (후처리 단계) | 스트림 청크 발행 시점 또는 조합 완료 시점 |
| 응답 데이터 방출 필수 작업 | copyBodyToResponse() 호출 필수 |
writeWith() 체인 유지 및 퍼블리셔 위임 |
| 주요 고려 사항 | 서블릿 스트림 1회 소비, 미호출 시 빈 버퍼 | 단일 구독 제약, 메모리 풀 참조 카운트 관리(해제한 버퍼를 다시 쓰면 예외) |
주의 사항
메모리 부하와 OutOfMemory 방지
대용량 파일 업로드 요청이나 거대한 JSON 페이로드를 무제한으로 메모리에 캐싱하면 JVM 힙 메모리가 고갈되어 OOM 장애로 이어진다. 설정 가능한 최대 바이트 크기(예: 1MB)를 지정하고 한도를 초과하는 요청은 캐싱 대상에서 제외하거나 앞단 일부만 잘라내는 방어 로직이 필요하다.
민감 정보 마스킹 (Security Masking)
비밀번호, 주민등록번호, 계좌번호, 인증 토큰 등의 민감 데이터가 로그나 관측성 시스템에 평문으로 남지 않도록 정규식이나 JSON 필드 기반의 마스킹 처리를 필터 단에서 반드시 수행해야 한다.
멀티파트(multipart) 요청 제외
multipart/form-data 형식의 요청은 일반적인 JSON 본문과 구조가 다르며 스트림 처리 방식이 복잡하므로 필터 진입 시 Content-Type 헤더를 검사하여 멀티파트 요청은 본문 캐싱 대상에서 건너뛰도록 분기하는 것이 일반적이다.