프로그래밍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 관리자