프로그래밍Java

[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️⃣ 동작 흐름 (현재 문제 상황)

  1. Filter 실행
  2. chain.doFilter() 호출
  3. Controller 실행
  4. Controller가 CompletableFuture 반환 → 비동기 시작
  5. Filter 로직 종료
  6. res.copyBodyToResponse() 실행 ❌ (body 없음)
  7. 이후 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

으로 등록하는게 좋습니다.

Hi, I’m 관리자