유성

왜 Request Body는 컨트롤러에 도달하지 못했나? (feat. Sitemesh & Multipart) 본문

Spring

왜 Request Body는 컨트롤러에 도달하지 못했나? (feat. Sitemesh & Multipart)

백엔드 유성 2025. 12. 19. 14:39

백엔드 개발을 하다 보면 한 번쯤은 마주하게 되는 에러가 있다. "Stream closed" 혹은 "Required request body is missing".

분명 요청은 정상적으로 보냈는데, 정작 컨트롤러에 도달하면 바디(Body)가 증발해 버리는 현상이다.

 

이 현상은 OkHttp3의 ResponseBody를 처리할 때나, 필터에서 로깅을 시도할 때 자주 발생한다. 도대체 무엇이 우리의 데이터를 중간에 가로챈 것일까?

1. 범인 찾기: InputStream의 일회성

HttpServletRequest의 getInputStream()은 데이터를 스트림 방식으로 읽어온다.

스트림은 말 그대로 '흐르는 물'과 같아서, 한 번 읽기 시작하면 포인터가 데이터의 끝(EOF)으로 이동한다.

일반적인 서블릿 환경에서 이 포인터를 다시 0번 인덱스로 되돌리는 reset() 기능은 동작하지 않는다.

 

내가 마주한 문제는 Sitemesh3와의 충돌이었다.

JSP 환경에서 레이아웃을 잡아주는 Sitemesh3는 필터 단에서 요청을 가로채 처리한다.

이 과정에서 특정 조건에 따라 InputStream이 소비되기도 하는데, 문제는 그 뒤에 실행될 Multipart Resolver였다.

 

Resolver가 컨트롤러에 데이터를 전달하기 위해 스트림을 열었을 땐, 이미 Sitemesh가 한 차례 '강물'을 다 마셔버린 뒤였다.

 

2. 스프링의 해결책: ContentCachingRequestWrapper

나는 Sitemesh3 필터를 ContentCachingRequestWrapper를 생성해주는 필터(예: ShallowEtagHeaderFilter 또는 커스텀 캐싱 필터)보다 뒷 순서에 배치하여 이 문제를 해결했다.

 

여기서 핵심 질문이 생긴다. "필터에서 이미 스트림을 읽었는데, 어떻게 비즈니스 로직에서 데이터를 다시 읽을 수 있었을까?"

그 해답은 스프링이 제공하는 ContentCachingRequestWrapper의 '지연 복사(Lazy Copy)' 메커니즘에 있다.

2.1 지연 복사의 원리

이 Wrapper는 요청을 받자마자 데이터를 메모리에 올리는 '능동적 객체'가 아니다. 대신, 누군가 read() 메서드를 호출할 때를 기다리는 '기록기(Recorder)'에 가깝다.

  1. Wrapper의 가로채기: 필터에서 요청 객체를 Wrapper로 감싸서 넘긴다. 이때까지 스트림은 여전히 싱싱한 상태다.
  2. 첫 번째 소비 (Sitemesh 등): 앞선 필터에서 read()를 호출하면, Wrapper가 구현한 커스텀 스트림이 실행된다. 원본에서 1바이트를 읽어 사용자에게 전달함과 동시에, 자신의 내부 ByteArrayOutputStream에 몰래 복사본을 만든다.
  3. 재사용의 마법: 스트림이 끝까지 읽혀 원본은 비어버렸지만, Wrapper의 내부 바구니(캐시)에는 데이터가 가득 차 있다. 이후 ArgumentResolver가 데이터를 요구하면, Wrapper는 비어버린 원본 대신 저장해둔 복사본을 꺼내준다.

 

3. 왜 필터 순서가 결정적이었나?

순서가 잘못되었을 땐(Sitemesh가 앞설 때), Wrapper가 끼어들 틈도 없이 원본 스트림이 소모되고 닫혔다. 하지만 Wrapper를 전진 배치하자 "누가 읽든 내가 옆에서 복사하겠다"라는 전략이 성공한 것이다.

이는 단순히 "순서를 바꾸니 된다"는 차원을 넘어, 소모성 자원(Stream)의 선취권과 공유 전략을 설계하는 일이다.

 

4. ContentCachingRequestWrapper 가 완벽한 해결책인가?

하지만 주의할 점이 있다. ContentCachingRequestWrapper는 편리하지만 모든 데이터를 힙(Heap) 메모리에 담는다.

대용량 파일 업로드 환경에서는 이것이 시한폭탄이 될 수 있다.

1MB 이미지가 한번에 수만번 업로드 되면 OOM이 발생할것이다.

 

이것을 해결하기 위해 body의 크기에 따라 copy 위치를 지정하는 방법이 있다.

  • 64KB 미만 : 메모리 저장
  • 64KB ~ 1MB : DirectByteBuffer 등 오프힙 메모리 사용 (OS 버퍼 직접사용)
  • 1MB 초과 : Disk에 .tmp 형식으로 저장