API 요청 로깅을 할 때 ContentCachingWrapper 를 사용하는 이유
- 개발
- 2026. 1. 18. 22:47
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 |