[doFilter] ContentCachingResponseWrapper 비동기 처리에 대한 처리 방법
최근에 로그에 대한 db처리를 진행하였는데, 이에 따른 사이드 이펙이 발생되었다. db처리관련 포스팅은 [SpringBoot] doFilterInternal() 이용하여 DB에 request, response 등 기록 남기는 방법 이라는 주제로 남겨두었다.
오류 내용은 서버 배포 후 IOS 인증이 안된다는 문의가 발생하였다. 배포는 원복하였고, doFilter에서 해당 url만 제외처리하면 되는데…. 로깅을 db에 남길 수 없는 문제가 발생하기 때문에… 최대한 남기는 방향으로 처리하기 위해 ChatGpt에게 아래와 같이 물었더니, 단번에 알려준다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException, IOException {
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper((HttpServletRequest) request);
ContentCachingResponseWrapper res = new ContentCachingResponseWrapper((HttpServletResponse) response);
chain.doFilter(req, res);
............이하 생략
res.copyBodyToResponse();
}
아래 케이스일때 res.copyBodyToResponse(); 값이 넘어오지 않아.
Box user = new Box();
user.put("userNumber", userNumber);
cmnService.getModelMap(user, modelAndView, modelMap);
return CompletableFuture.completedFuture(modelMap);
원인은 비동기(@Async) 처리에 대한 분기가 필요했던 것이다. 해당 클래스를 열어서보니, @Async 어노테이션이 존재하였다. chatGPT 답변 내용은 다음과 같다.
문제의 핵심은 ContentCachingResponseWrapper + CompletableFuture(비동기 응답) 조합입니다.
현재 필터 구조는 동기 요청 기준으로 작성되어 있어서,
컨트롤러가 CompletableFuture 를 반환하면 응답이 아직 생성되기 전에 Filter가 끝나버립니다.
그래서 아래 코드 시점에 응답 body가 없습니다.
chain.doFilter(req, res);
res.copyBodyToResponse(); // 아직 response body가 생성되지 않음
왜냐하면
return CompletableFuture.completedFuture(modelMap);
이 구조는 Spring MVC Async 처리로 들어가기 때문입니다.
1️⃣ 동작 흐름 (현재 문제 상황)
- Filter 실행
chain.doFilter()호출- Controller 실행
- Controller가
CompletableFuture반환 → 비동기 시작 - Filter 로직 종료
res.copyBodyToResponse()실행 ❌ (body 없음)- 이후 Spring Async Thread에서 응답 생성
즉 Filter가 너무 빨리 끝남
2️⃣ 해결 방법 (정석)
isAsyncStarted() 체크 후 AsyncListener 사용해야 합니다.
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
ContentCachingRequestWrapper req = new ContentCachingRequestWrapper((HttpServletRequest) request);
ContentCachingResponseWrapper res = new ContentCachingResponseWrapper((HttpServletResponse) response);
chain.doFilter(req, res);
if (req.isAsyncStarted()) {
req.getAsyncContext().addListener(new AsyncListener() {
@Override
public void onComplete(AsyncEvent event) throws IOException {
ContentCachingResponseWrapper responseWrapper =
(ContentCachingResponseWrapper) event.getSuppliedResponse();
byte[] buf = responseWrapper.getContentAsByteArray();
String responseBody = new String(buf, responseWrapper.getCharacterEncoding());
// 여기서 로그 / DB 저장 처리
responseWrapper.copyBodyToResponse();
}
@Override public void onTimeout(AsyncEvent event) {}
@Override public void onError(AsyncEvent event) {}
@Override public void onStartAsync(AsyncEvent event) {}
});
} else {
byte[] buf = res.getContentAsByteArray();
String responseBody = new String(buf, res.getCharacterEncoding());
// 로그 / DB 저장
res.copyBodyToResponse();
}
}
3️⃣ 핵심 포인트
비동기 응답이면
request.isAsyncStarted()
가 true
이 경우
AsyncListener.onComplete()
에서 응답을 읽어야 합니다.
4️⃣ 추가로 많이 하는 실수
비동기 환경에서 아래 코드 사용하면 100% 문제 발생
chain.doFilter(req, res);
String body = new String(res.getContentAsByteArray());
왜냐면 Response 생성 전에 읽기 때문
5️⃣ 실무에서는 이렇게 더 많이 사용
사실 Filter보다 Spring HandlerInterceptor 가 더 안정적입니다.
특히
afterCompletion()
에서 response body 처리.
Spring Boot에서 요청/응답 로그 + Async(CompletableFuture) 대응 + 오류 DB 저장까지 가능한 실무형 Filter 예시
핵심은 ContentCachingRequestWrapper + ContentCachingResponseWrapper + AsyncListener 조합
1️⃣ 실무용 API Logging Filter (완성 코드)
import org.springframework.stereotype.Component;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import javax.servlet.*;
import javax.servlet.AsyncEvent;
import javax.servlet.AsyncListener;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@Component
public class ApiLoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
ContentCachingRequestWrapper req =
new ContentCachingRequestWrapper((HttpServletRequest) request);
ContentCachingResponseWrapper res =
new ContentCachingResponseWrapper((HttpServletResponse) response);
long start = System.currentTimeMillis();
try {
chain.doFilter(req, res);
if (req.isAsyncStarted()) {
req.getAsyncContext().addListener(new AsyncListener() {
@Override
public void onComplete(AsyncEvent event) throws IOException {
ContentCachingRequestWrapper requestWrapper =
(ContentCachingRequestWrapper) event.getSuppliedRequest();
ContentCachingResponseWrapper responseWrapper =
(ContentCachingResponseWrapper) event.getSuppliedResponse();
logging(requestWrapper, responseWrapper, start);
responseWrapper.copyBodyToResponse();
}
@Override
public void onTimeout(AsyncEvent event) {}
@Override
public void onError(AsyncEvent event) {}
@Override
public void onStartAsync(AsyncEvent event) {}
});
} else {
logging(req, res, start);
res.copyBodyToResponse();
}
} catch (Exception e) {
errorLogging(req, e);
throw e;
}
}
private void logging(ContentCachingRequestWrapper req,
ContentCachingResponseWrapper res,
long start) {
long end = System.currentTimeMillis();
String requestBody = getRequestBody(req);
String responseBody = getResponseBody(res);
String uri = req.getRequestURI();
String method = req.getMethod();
int status = res.getStatus();
System.out.println("===== API LOG =====");
System.out.println("URI : " + uri);
System.out.println("METHOD : " + method);
System.out.println("STATUS : " + status);
System.out.println("REQUEST : " + requestBody);
System.out.println("RESPONSE : " + responseBody);
System.out.println("TIME : " + (end - start) + "ms");
}
private void errorLogging(ContentCachingRequestWrapper req, Exception e) {
String uri = req.getRequestURI();
String body = getRequestBody(req);
System.out.println("===== ERROR LOG =====");
System.out.println("URI : " + uri);
System.out.println("REQUEST : " + body);
System.out.println("ERROR : " + e.getMessage());
// TODO : DB 저장 로직
}
private String getRequestBody(ContentCachingRequestWrapper request) {
byte[] buf = request.getContentAsByteArray();
if (buf.length == 0) {
return "";
}
return new String(buf, StandardCharsets.UTF_8);
}
private String getResponseBody(ContentCachingResponseWrapper response) {
byte[] buf = response.getContentAsByteArray();
if (buf.length == 0) {
return "";
}
return new String(buf, StandardCharsets.UTF_8);
}
}
2️⃣ 이 코드가 해결하는 것
| 기능 | 지원 여부 |
|---|---|
| Request Body Logging | ✅ |
| Response Body Logging | ✅ |
CompletableFuture | ✅ |
| Async API | ✅ |
| 오류 로그 | ✅ |
| Response 깨짐 방지 | ✅ |
3️⃣ 실제 동작 흐름
일반 API
Filter
↓
Controller
↓
Response 생성
↓
Filter logging()
↓
copyBodyToResponse()
Async API (CompletableFuture)
Filter
↓
Controller
↓
Async 시작
↓
Filter 종료
↓
Async Thread
↓
Response 생성
↓
AsyncListener.onComplete()
↓
logging()
4️⃣ 실무에서 추가로 넣는 기능 (추천)
실무에서는 여기에 이것도 넣습니다.
TraceId
X-TRACE-ID
요청 추적
IP 로그
String ip = req.getRemoteAddr();
Header 로그
Collections.list(req.getHeaderNames())
특정 URL 제외
예:
/actuator
/health
/swagger
5️⃣ 진짜 실무에서는 이것도 필수
요청 body 크기 제한
MAX_BODY_LOG = 2000
왜냐하면
파일 업로드
10MB JSON
로그 터집니다.
6️⃣ 한가지 더 (엄청 중요)
Spring Boot에서는 Filter 순서도 중요합니다.
@Order(Ordered.HIGHEST_PRECEDENCE)
또는
FilterRegistrationBean
으로 등록하는게 좋습니다.



