배경
실제 프로젝트에서 Request Body로부터 traceId를 전달받아 이를 저장하고, 이후 로깅이나 트레이싱 등에 활용해야 하는 요구사항이 있었다. 이를 위해 Spring에서 제공하는 ContentCachingRequestWrapper를 사용하기로 했다.
ContentCachingRequestWrapper는 원래 InputStream, Reader, 혹은 @RequestBody로 Request Body를 한번 읽고 나면 다시 읽을 수 없다는 한계를 깔끔하게 해결해 주는 유용한 클래스이다. 덕분에 traceId 같은 중요한 데이터를 여러 계층에서 반복적으로 로깅하거나 재사용할 수 있다.
그런데 실제로 적용하는 과정에서, ContentCachingRequestWrapper로 감쌌음에도 불구하고 Request Body에서 null이나 빈 값이 반환되는 현상을 경험하게 됐다. 이번 글에서는 이 트러블슈팅 경험과 원인, 그리고 실질적인 해결 방법을 정리해 공유한다.
코드는 아래 링크에서 확인할 수 있다.
practice-java-spring/spring-data-envers at c9d7609aa09d546408c531ede48434ca5dfb27f9 · chanbinme/practice-java-spring
Contribute to chanbinme/practice-java-spring development by creating an account on GitHub.
github.com
개발 환경
- Java 17
- Spring Boot 3.4.x
- Gradle
- IntelliJ
코드
RequestCachingFilter
한 번만 읽을 수 있는 Request를 ContentCachingRequestWrapper로 감싸 여러 번 읽을 수 있도록 하는 Filter이다. 반드시 filterChain.doFilter()에 Wrapper로 감싼 request를 전달해야 이후 계층(Filter, Controller, ExceptionHandler 등)에서 Body에 접근할 수 있다.
/**
* 요청을 ContentCachingRequestWrapper로 래핑하여 요청 본문을 캐싱하는 필터.
* 이 필터는 요청 본문을 읽을 수 있도록 하여, 이후의 필터나 컨트롤러에서 요청 본문에 접근할 수 있게 한다.
*/
@Component
@Order(1)
public class RequescCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
filterChain.doFilter(wrappedRequest, response);
}
}
TraceIdFilter
request를 ContentCachingRequestWrapper로 cast해서 body에 있는 traceId를 조회하는 Filter이다.
/**
* 요청 본문에서 traceId를 추출하는 필터.
* 이 필터는 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽고, traceId를 추출하여 로그에 출력한다.
*/
@Component
@Order(2)
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_FIELD = "traceId";
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
// 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
// 요청 본문에서 traceId를 추출한다.
String traceId = extractTraceId(requestBody);
// traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
System.out.println("############### Trace ID Filter Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Filter End ###############");
}
filterChain.doFilter(request, response);
}
private String extractTraceId(String requestBody) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(requestBody);
if (node.has(TRACE_ID_FIELD)) {
return node.get(TRACE_ID_FIELD).asText();
}
return null;
}
}
문제
이렇게 구현한 후 실제로 traceId를 로그에 찍어보았지만, null 값이 출력됐다.
############### Trace ID Filter Start ###############
Trace ID: null
############### Trace ID Filter End ###############
Request Body 자체가 아예 오지 않는 건 아닌지 의심했지만, CommonsRequestLoggingFilter의 로그를 보면 정상적으로 Body 값이 들어가는 것을 확인할 수 있었다.
2025-07-17T23:13:34.261+09:00 DEBUG 23020 --- [nio-8080-exec-1] o.s.w.f.CommonsRequestLoggingFilter : Request Data: POST /posts, payload={
"traceId": "00001",
"title": "배고픈데",
"content": "뭐먹지"
}]
왜 조회되지 않는지 원인과 해결 방법을 알아보자
원인
결국 문제의 원인은 ContentCachingRequestWrapper의 동작 원리를 완전히 이해하지 못한 것에 있었다.
공식 주석을 보면 다음과 같다.

입력 스트림과 리더(reader)로부터 읽은 모든 내용을 캐시하고, 이 내용을 바이트 배열을 통해 가져올 수 있게 해주는 HttpServletRequest 래퍼입니다. 이 클래스는 실제로 내용이 읽힐 때만 그 내용을 캐시 하는 인터셉터 역할을 하며, 그렇지 않은 경우에는 내용을 강제로 읽지 않습니다. 즉, 요청의 내용이 소비되지 않으면 캐시도 되지 않으므로, getContentAsByteArray()를 통해 내용을 가져올 수 없습니다.
즉, ContentCachingRequestWrapper로 감싸더라도, 요청 Body가 실제로 한 번이라도 InputStream (또는 Reader, @RequestBody 등)으로 읽혀야 캐시가 생긴다는 것이.
해결 방법
정리하자면, 개발자가 getInputStream()이나 getReader()로 Request Body를 수동으로 읽지 않는 이상, 그리고 컨트롤러의 @RequestBody가 호출되지 않은 상태라면, ContentCachingRequestWrapper 내부 캐시는 비어 있게 된다.
그래서 Body를 제대로 얻으려면 @RequestBody 등에서 본문을 읽은 시점 이후의 계층(예: 인터셉터의 postHandle, afterCompletion같은)에서 getContentAsByteArray()를 사용해야 한다. 보통 Filter는 DispatcherServlet보다 먼저 실행되므로, Filter에서는 Body가 아직 읽히지 않은 상태이기 때문에 getContentAsByteArray()가 빈 배열을 반환하는 것이다.
생각만 하면 의미가 없기 때문에 실제로 그런지 테스트해보자
테스트
TraceIdInterceptor
아래 interceptor에서 각 메서드별로 wrapper 감싸진 request에서 traceId를 조회하도록 했다.
/**
* 요청에서 traceId를 추출하는 인터셉터.
* ContentCachingRequestWrapper로 감싼다고 해서 body를 읽을 수 있는 것은 아니다.
* body가 한 번도 읽히지 않았다면, getContentAsByteArray()는 빈 배열을 반환한다.
* 일반적으로 body가 읽히는 시점은 @RequestBody가 있는 컨트롤러 메소드가 호출될 때이다.
*/
@Component
@RequiredArgsConstructor
public class TraceIdInterceptor implements HandlerInterceptor {
private static final String TRACE_ID_FIELD = "traceId";
private final ObjectMapper objectMapper;
/**
* 요청이 컨트롤러에 도달하기 전에 호출되는 메소드.
* 아직 @RequestBoyd를 호출하지 않았기 때문에 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 없다.
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
// 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
// 요청 본문에서 traceId를 추출한다.
String traceId = extractTraceId(requestBody);
// traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
System.out.println("############### Trace ID Interceptor.preHandle Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Interceptor.preHandle End ###############");
}
return true;
}
/**
* 컨트롤러 메소드가 호출된 후에 호출되는 메소드.
* 이 시점에서는 @RequestBody가 호출되어 ContentCachingRequestWrapper가 요청 본문을 캐싱했기 때문에, 본문을 읽을 수 있다.
* 하지만 예외가 발생하면 이 메소드는 호출되지 않는다.
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
// 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
// 요청 본문에서 traceId를 추출한다.
String traceId = extractTraceId(requestBody);
// traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
System.out.println("############### Trace ID Interceptor.postHandle Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Interceptor.postHandle End ###############");
}
}
/**
* 컨트롤러 메소드가 호출된 후에 호출되는 메소드.
* 이 시점에서는 @RequestBody가 호출되어 ContentCachingRequestWrapper가 요청 본문을 캐싱했기 때문에, 본문을 읽을 수 있다.
* afterCompletion 메소드는 요청 처리 후에 항상 호출되며, 예외가 발생하더라도 호출된다.
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
// 요청의 ContentCachingRequestWrapper를 사용하여 요청 본문을 읽을 수 있다.
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
// 요청 본문에서 traceId를 추출한다.
String traceId = extractTraceId(requestBody);
// traceId를 로그에 출력하거나 다른 용도로 사용할 수 있습니다.
System.out.println("############### Trace ID Interceptor.afterCompletion Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Interceptor.afterCompletion End ###############");
}
}
private String extractTraceId(String requestBody) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(requestBody);
if (node.has(TRACE_ID_FIELD)) {
return node.get(TRACE_ID_FIELD).asText();
}
return null;
}
}
@Configuration
@EnableWebMvc
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final ObjectMapper objectMapper;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TraceIdInterceptor(objectMapper))
.addPathPatterns("/**") // 모든 경로에 대해 인터셉터 적용
.excludePathPatterns("/error"); // 에러 페이지는 제외
}
}
Controller
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public void createPost(@RequestBody PostCreateRequestDto postCreateRequestDto) {
}
}
############### Trace ID Interceptor.preHandle Start ###############
Trace ID: null
############### Trace ID Interceptor.preHandle End ###############
############### Trace ID Interceptor.postHandle Start ###############
Trace ID: 00001
############### Trace ID Interceptor.postHandle End ###############
############### Trace ID Interceptor.afterCompletion Start ###############
Trace ID: 00001
############### Trace ID Interceptor.afterCompletion End ###############
예상한 대로 postHandler, afterCompletion에서 조회되는 걸 볼 수 있다.
예외가 발생했을 때는 어떻게 될까?
@RestController
@RequestMapping("/posts")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@PostMapping
public void createPost(@RequestBody PostCreateRequestDto postCreateRequestDto) {
throw new Exception("test"); // 예외 발생
}
}
############### Trace ID Filter Start ###############
Trace ID: null
############### Trace ID Filter End ###############
############### Trace ID Interceptor.preHandle Start ###############
Trace ID: null
############### Trace ID Interceptor.preHandle End ###############
############### Trace ID Interceptor.afterCompletion Start ###############
Trace ID: 00001
############### Trace ID Interceptor.afterCompletion End ###############
afterCompletion()에서만 호출되는 걸 확인할 수 있다.
이 외에도 Filter에서 filterChain.doFilter()를 호출한 이후에 Request를 조회하면 요청이 완료된 이후 DispatcherServlet - WAS 후처리를 담당하기 때문에 조회할 수 있다.
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_FIELD = "traceId";
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
String traceId = extractTraceId(requestBody);
System.out.println("############### Trace ID Filter.doFilter before Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Filter.doFilter before End ###############");
}
try {
filterChain.doFilter(request, response);
} finally {
if (request instanceof ContentCachingRequestWrapper cachingRequest) {
String requestBody = new String(cachingRequest.getContentAsByteArray(), cachingRequest.getCharacterEncoding());
String traceId = extractTraceId(requestBody);
System.out.println("############### Trace ID Filter.doFilter after Start ###############");
System.out.println("Trace ID: " + traceId);
System.out.println("############### Trace ID Filter.doFilter after End ###############");
}
}
}
private String extractTraceId(String requestBody) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(requestBody);
if (node.has(TRACE_ID_FIELD)) {
return node.get(TRACE_ID_FIELD).asText();
}
return null;
}
}
############### Trace ID Filter.doFilter before Start ###############
Trace ID: null
############### Trace ID Filter.doFilter before End ###############
############### Trace ID Filter.doFilter after Start ###############
Trace ID: 00001
############### Trace ID Filter.doFilter after End ###############
정리하면 다음과 같다.
| 위치 | Body 조회 가능 여부 | 설명 |
| Filter 내부(doFilter) | X (body 미소비) | Controller에서 @RequestBody가 호출되기 전 |
| Controller(@RequestBody) | O (body 읽음) | 실제로 Body가 소비됨, Wrapper에 캐시가 생김 |
| Interceptor preHandle | X | @RequestBody 호출 전이므로 캐시 없음 |
| Interceptor postHandle | O | @RequestBody 이후이므로 캐시로 본문 획득 가능 |
| Interceptor afterCompletion | O | 항상 호출됨, 예외 상황 포함 |
| Filter(doFilter 이후) | O | 요청 후에는 DispatcherServlet을 지나 Body 캐싱 완료 |
마무리
ContentCachingRequestWrapper 사용 시 주의할 점은 Request Body가 실제로 한 번이라도 읽혀야 캐싱이 이루어진다는 점이다. Filter 등 DispatcherServlet 이전에는 Body를 바로 읽을 수 없으니, 반드시 적절한 위치(컨트롤러 @RequestBody 이후, Interceptor의 postHandle/afterCompletion 등)에서 본문을 조회해야 원하는 데이터를 얻을 수 있다.
이 점만 명확히 알고 있으면, 실무에서 불필요하게 디버깅에 시간을 낭비하지 않을 수 있으니, 꼭 기억해 두자! (필자는 오늘 이거 때문에 꽤 많은 시간을 소비했다.. ㅎㅎ)
참조
ContentCachingRequestWrapper
docs.spring.io