API 요청 로깅을 할 때 ContentCachingWrapper 를 사용하는 이유

반응형
ContentCachingRequestWrapper wrappedRequest = 
request instanceof ContentCachingRequestWrapper ? 
(ContentCachingRequestWrapper) request : 
new ContentCachingRequestWrapper(request, REQUEST_BUFFER_SIZE);

ContentCachingResponseWrapper wrappedResponse = 
response instanceof ContentCachingResponseWrapper ? 
(ContentCachingResponseWrapper) response: 
new ContentCachingResponseWrapper(response);

이 글에서는 요청 하나당 Request / Response 전체를 로깅하기 위해 만든 필터를 다뤄보려고 합니다. 단순히 헤더만 찍는 것이 아니라, Body까지 포함한 전문 로깅을 목표로 한 구조입니다.

public class RequestResponseLoggingFilter extends OncePerRequestFilter {

  @Override
  protected void doFilterInternal(HttpServletRequest request,
                                  HttpServletResponse response,
                                  FilterChain filterChain)
      throws ServletException, IOException {
      
      ...
      
      }
      
}

저는 이 요구사항을 해결하기 위해 Filter 를 선택했습니다.
HTTP 요청/응답이라는 웹 요청 레벨의 관심사이기 때문에, 비즈니스 로직에 가까운 Interceptor 나 AOP 보다는 필터가 더 적절하다고 판단했습니다. (이 선택에 대한 자세한 이유는 아래 글에서 정리했습니다.)

2026.01.13 - [개발] - filter vs interceptor vs AOP

 

filter vs interceptor vs AOP

스프링으로 개발하다 보면, "요청이 들어왔을 때 공통적으로 처리해야 하는 로직"을 어디에 둘지 고민하게 됩니다. 예를 들어 인증, 로깅, MDC 세팅, 트래픽 제어, 요청 검증 같은 것들입니다. 스

b-programmer.tistory.com

필터의 기본적인 구조는 단순합니다.

 

filterChain.doFilter(request, response);

이 코드는 현재 필터의 처리를 마친 뒤, 요청 처리를 다음 필터(또는 최종적으로 DispatcherServlet)로 위임하는 역할을 합니다. 필터는 이 호출을 기준으로 전처리 / 후처리를 나누는 Around 구조를 가집니다.

그런데 여기서 한 가지 의문이 생깁니다.

ContentCachingRequestWrapper wrappedRequest = 
request instanceof ContentCachingRequestWrapper ? 
(ContentCachingRequestWrapper) request : 
new ContentCachingRequestWrapper(request, REQUEST_BUFFER_SIZE);

ContentCachingResponseWrapper wrappedResponse = 
response instanceof ContentCachingResponseWrapper ? 
(ContentCachingResponseWrapper) response: 
new ContentCachingResponseWrapper(response);

그리고 다시

filterChain.doFilter(wrappedRequest, wrappedResponse);

왜 원래의 request / response 가 아니라, 굳이 한 번 더 감싼 객체를 넘기는 것일까요?

그래서 맨위에 보여준 코드는 무엇을 하는 코드인가?

현재 이 필터가 하는 역할은 Request와 Response 전체를 로깅하기 위한 필터입니다.
단순히 헤더를 찍는 것이 아니라, Body까지 포함한 정보까지 담고 싶었습니다.

HTTP Request와 Response는 스트림(Stream) 기반입니다.

public interface HttpServletRequest extends ServletRequest {
 ...
 
 ServletInputStream getInputStream() throws IOException;

}

스트림 기반이라는건 한 번 읽으면 다시 읽을 수 없습니다.
즉, 필터에서 Body를 읽어버리면 컨트롤러는 더 이상 Body를 읽을 수 없고 JSON 파싱은 실패하거나 @RequestBody가 비어버리는 문제가 발생합니다.

확인을 해보니 아무값도 보이지 않네요. 로깅을 찍으려고 보니 Request Body와 Response Body가 아무것도 메시지가 나오지 않는다는것을 알 수 있습니다. 참고로 (empty)라고 보이는 이유는

if (json == null || json.isBlank()) {
      return "  (empty)";
}

요런 코드가 있기 때문에 empty라고 노출이 되어집니다.

그러면 어떻게 해야 할까?

제가 하고 싶은건 다음과 같았습니다. 로깅메시지가 보여지면서, 마스킹까지 보여주고 싶었습니다.
확인한 문제로는 스트림 구조는 한번 읽으면 다시 읽을 수 없다는 문제가 있었습니다.
갑자기 드는 의문점은 왜 한번 읽은 request와 response으로는 어째서 로그를 만들 수 없었을까? 
그 이유는 request와 response는 이미 소비가 되어진 스트림이기 때문이라 그렇습니다.

강에 물이 상류에서 하류로 흐르고 있다고 가정해 봅시다. 상류가 request 이고, 하류가 response 라고 볼 수 있습니다. 물은 흐르는 순간 소비되며, 이미 흘러간 물을 다시 퍼올 수는 없습니다.

흐름(flow) 자체는 붙잡을 수 없는 "상태" 이기 때문에, 그 내용을 나중에 다시 확인하고 싶다면, 흐르는 순간에 따로 떠서 어딘가에 보관해 두어야 합니다. HTTP Body 로깅 역시 마찬가지입니다. 스트림이 소비되기 전에, 혹은 소비되는 그 순간에 별도의 버퍼에 복사해 두지 않으면, 이후에는 더 이상 그 내용을 조회할 방법이 없습니다.

ContentCachingRequestWrapper wrappedRequest = 
request instanceof ContentCachingRequestWrapper ? 
(ContentCachingRequestWrapper) request : new ContentCachingRequestWrapper(request, REQUEST_BUFFER_SIZE);

ContentCachingResponseWrapper wrappedResponse = 
response instanceof ContentCachingResponseWrapper ? 
(ContentCachingResponseWrapper) response: new ContentCachingResponseWrapper(response);

 

그렇다면, 이 코드는 request 와 response 를 복사하는 코드일까요?
결론부터 말하면, 이 코드는 실제로 복사를 수행하는 코드는 아닙니다.
이 코드는 나중에 Body 가 읽히거나 쓰일 때, 그 내용을 복사할 수 있도록 준비하는 코드 입니다.
즉, 이 시점에서는 아직 어떤 데이터도 읽거나 쓰지 않았고, 실제 복사도 일어나지 않았습니다. 단지 복사를 가로챌 수 있는 래퍼를 씌워둔 상태일 뿐입니다.

코드를 하나씩 해석해 보면 다음과 같습니다.

ContentCachingRequestWrapper wrappedRequest = 
request instanceof ContentCachingRequestWrapper ? 
(ContentCachingRequestWrapper) request : new ContentCachingRequestWrapper(request, REQUEST_BUFFER_SIZE);
filter 에서 래핑
    ↓
filterChain.doFilter(...)
    ↓
컨트롤러에서 @RequestBody 파싱
    ↓
내부적으로 request.getInputStream().read(...)
    ↓
이 순간:
  - 원본 스트림 소비
  - 동시에 wrapper 내부 buffer 로 copy​

 

request 가 이미 ContentCachingRequestWrapper 로 감싸져 있다면 그대로 사용하고,
그렇지 않다면 나중에 Body 가 읽힐 때 내용을 캐싱할 수 있도록 래퍼로 한 번 감싸는 것입니다.
response 역시 동일한 의미를 가집니다.

ContentCachingResponseWrapper wrappedResponse = 
response instanceof ContentCachingResponseWrapper ? 
(ContentCachingResponseWrapper) response: new ContentCachingResponseWrapper(response);
filter 에서 래핑
    ↓
filterChain.doFilter(...)
    ↓
컨트롤러가 응답 write
    ↓
이 순간:
  - wrapper 내부 buffer 에 copy
  - (아직 실제 response 로는 안 나감)


이 시점에서 중요한 점은, 아직 Request/Response Body 는 전혀 복사되지 않았다는 것입니다. 실제 복사는 이후 컨트롤러나 다른 로직에서 Body 를 읽거나(request) 쓰는(response) 순간에, 이 래퍼가 I/O 를 가로채면서 자동으로 수행됩니다.

좋습니다. 결국 이 구조는 요청/응답을 필터링해서, 실제 원문이 소비되는 순간에 그 내용을 가로채어 동시에 복사본을 만들어 두는 구조라고 이해할 수 있습니다.

Request와 Response의 캐싱 전략의 차이

Request 와 Response 는 캐싱 전략이 서로 다릅니다.
Request 는 캐싱할 때 최대 얼마까지 메모리에 저장할지 한도를 지정할 수 있습니다.

new ContentCachingRequestWrapper(request, REQUEST_BUFFER_SIZE)

이 제한이 필요한 이유는, 요청 바디에는 파일 업로드나 대용량 JSON 등 수십 MB 이상이 들어올 수도 있기 때문입니다. 만약 이런 요청을 무조건 전부 메모리에 캐싱한다면, 서버 메모리가 쉽게 고갈될 수 있습니다. 그래서 Request 쪽은 "캐싱은 하되, N 바이트까지만" 이라는 안전장치를 두는 구조로 설계되어 있습니다.

반면 Response 는 성격이 다릅니다.
Response 는 이미 존재하는 데이터가 아니라, 스트림을 통해 계속해서 만들어지고 있는 데이터입니다. 컨트롤러 실행 과정에서 JSON 직렬화, 템플릿 렌더링 등의 과정을 거치며 OutputStream 에 조금씩 쓰여지면서 점진적으로 완성됩니다. 따라서 Request 처럼
"미리 N 바이트까지만 캐싱한다" 는 개념을 동일하게 적용하기가 어렵습니다.

대신 ContentCachingResponseWrapper 는 응답이 만들어지는 동안의 출력을 내부 버퍼에 모두 모아두었다가, 필터의 마지막 단계에서 다음 코드로 실제 Response 에 한 번에 방출합니다.

wrappedResponse.copyBodyToResponse();

이 메서드는 Response 를 "복사"하는 역할이 아니라, 이미 내부 버퍼에 모아두었던 응답 내용을 실제 HttpServletResponse 로 써주는 마무리 단계입니다. 이 호출을 하지 않으면, 클라이언트는 응답을 받지 못하게 됩니다.


참고용으로 캐싱Wrapper말고 다른 래퍼들도 소개합니다.

분류대표  Wrapper
기본 베이스 HttpServletRequestWrapper / ResponseWrapper
캐싱 ContentCachingRequestWrapper / ResponseWrapper
FormContentRequestWrapper
파일 업로드 MultipartHttpServletRequest
ETag ShallowEtagResponseWrapper
보안 SecurityContextHolderAwareRequestWrapper
압축/암호화 (커스텀 구현) GzipResponseWrapper 등

결론

HTTP 요청과 응답은 스트림(Stream) 단위로 동작합니다.
스트림의 가장 큰 특징은 한 번 소비되면 다시 되돌릴 수 없다는 점입니다. 즉, Request Body 나 Response Body 는 한 번 읽히거나 쓰이고 나면, 그 이후에는 동일한 내용을 다시 조회할 수 없습니다.
이 말은 곧, 요청 처리가 끝난 뒤에 로그를 남기려고 하면 이미 Body 는 사라진 상태라는 뜻이 됩니다. 따라서 단순히 HttpServletRequest 나 HttpServletResponse 에서 Body 를 다시 꺼내보는 방식으로는 요청/응답 전문 로그를 남길 수 없습니다.
이를 해결하기 위한 방법은, 스트림이 소비되는 순간을 가로채서 동시에 복사본을 만들어 두는 구조를 사용하는 것입니다. 즉, 원본 스트림의 흐름은 그대로 유지하면서, 옆에서 로그용 사본을 따로 저장하는 방식입니다.
이를 위해 Spring 은 ContentCachingRequestWrapper 와 ContentCachingResponseWrapper 를 제공합니다. 이 Wrapper 들은 Request/Response 가 실제로 읽히거나 쓰여지는 순간에 그 내용을 내부 버퍼에 캐싱해 두고, 이후 필터 단계에서 해당 사본을 꺼내 로그나 마스킹 처리에 사용할 수 있게 해줍니다.

반응형

'개발' 카테고리의 다른 글

스레드 풀을 관리해보자.  (1) 2026.01.16
REST API란 무엇일까?  (0) 2026.01.14
filter vs interceptor vs AOP  (1) 2026.01.13
새로운 인증 전략 PASETO  (1) 2026.01.11
캐시를 이용하는 방법들  (0) 2026.01.09

댓글

Designed by JB FACTORY