Spring AbstractRequestLoggingFilter로 RequestBody 로깅 찍기

2025. 10. 14. 22:32·Spring

✅ 개요

인터셉터를 사용해 엔드포인트, 메서드, 실행 시간 등의 로깅을 남기기 위해 여러 다른 글들과 스프링 클래스들을 살펴보던 도중 `AbstractRequestLoggingFilter`이라는 클래스를 발견하였습니다. 이 필터를 사용해 정상적으로 로그를 출력하기 위한 과정을 정리하고자 합니다.

 

✅ ApiLoggingFilter 구현

먼저 `AbstractRequestLoggingFilter`는 `org.springframework.web.filter` 패키지로 스프링 시큐리티 필터를 모두 거친 다음에 동작하는 필터입니다. 이 필터 클래스에서 `getBeforeMessage()`와 `getAfterMessage()` 메서드만 직접 만들면 원하는 형태로 로그를 출력할 수 있을 것으로 예상하고 다음과 같이 구성했습니다.

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.WebUtils;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Component
public class ApiLoggingFilter extends CommonsRequestLoggingFilter {
    
    @Override
    protected int getMaxPayloadLength() {
        return 1000;
    }

    @Override
    protected boolean isIncludePayload() {
        return true;
    }

    @Override
    protected boolean isIncludeQueryString() {
        return true;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean isFirstRequest = !isAsyncDispatch(request);
        HttpServletRequest requestToUse = request;

        if (isIncludePayload() && isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
            requestToUse = new ContentCachingRequestWrapper(request, getMaxPayloadLength());
        }

        long startMs = System.currentTimeMillis();

        boolean shouldLog = shouldLog(requestToUse);
        if (shouldLog && isFirstRequest) {
            beforeRequest(requestToUse, getBeforeMessage(requestToUse));
        }
        try {
            filterChain.doFilter(request, response);
        } finally {
            if (shouldLog && !isAsyncStarted(requestToUse)) {
                afterRequest(requestToUse, getAfterMessage(startMs, requestToUse, response));
            }
        }
    }

    @Override
    protected String getMessagePayload(HttpServletRequest request) {
        ContentCachingRequestWrapper wrapper =
                WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
        if (wrapper != null) {
            byte[] buf = wrapper.getContentAsByteArray();
            if (buf.length > 0) {
                int length = Math.min(buf.length, getMaxPayloadLength());
                return new String(buf, 0, length, StandardCharsets.UTF_8);
            }
        }
        return null;
    }

    private String getBeforeMessage(HttpServletRequest request) {
        StringBuilder msg = new StringBuilder();

        msg.append("API REQUEST <<< [");
        appendRequestPath(request, msg);
        msg.append(" | ");

        if (isIncludePayload()) {
            String payload = getMessagePayload(request);
            msg.append("body=").append(payload);
        }

        msg.append("]");
        return msg.toString();
    }

    private String getAfterMessage(long startMs, HttpServletRequest request, HttpServletResponse response) {
        long executionTimeMs = System.currentTimeMillis() - startMs;
        int status = response.getStatus();

        StringBuilder msg = new StringBuilder();
        msg.append("API RESPONSE >>> [");

        appendRequestPath(request, msg);

        msg.append(" | ");

        msg.append("status=").append(status).append(" | ");
        msg.append("executionTime=").append(executionTimeMs).append("ms");

        msg.append("]");
        return msg.toString();
    }

    private void appendRequestPath(HttpServletRequest request, StringBuilder msg) {
        msg.append(request.getMethod()).append(" ");
        msg.append(request.getRequestURI());

        if (isIncludeQueryString()) {
            String queryString = request.getQueryString();
            if (queryString != null) {
                msg.append('?').append(queryString);
            }
        }
    }
}
  • `doFilterInternal()`의 코드를 그대로 가져와 실행시간을 측정하기 위해 `startMs` 변수만 추가했습니다.
  • `getMaxPayloadLength()`나 `isIncludeXXX()` 같은 경우는 `@Bean`으로 등록하면서 setter를 통해 설정할 수 있지만, 설정 클래스를 생략하기 위해 직접 재정의하였습니다.
  • 기본 `getMessagePayload()` 메서드를 사용하면 한글 깨짐 문제가 발생하여 charset을 UTF-8로 설정하기 위해 재정의하였습니다.
  • `CommonsRequestLoggingFilter`는 `AbstractRequestLoggingFilter`의 기본 구현체 중 하나로, 로깅 레벨이 debug일 때 로그를 출력하도록 되어 있습니다. 따라서 `application.yml`에 다음과 같은 설정을 해주었습니다.
logging:
  level:
    org.springframework.web.filter.CommonsRequestLoggingFilter: debug

포맷은 원하는 형태로 잘 맞춰졌습니다. 하지만 요청에 body가 null로 나오는 문제가 발생했습니다.

디버깅해보니 버퍼에 아무런 데이터도 없는 것을 확인했습니다.

 

🤔 body가 null인 이유?

찾아보니 `HttpServletRequest`의 `InputStream`은 요청당 한 번 밖에 `read()`할 수 없다고 합니다. 즉 한 번 읽고 나면 이후에는 빈 body로 전달되는 것이고 컨트롤러  `@RequestBody` 에 바인딩 되기 위해 `MappingJackson2HttpMessageConverter`에서 처음 `read()`를 해야만 하는 구조인 것 같습니다.

처음에는 `new ContentCachingRequestWrapper(request, getMaxPayloadLength());`로 감싸는 부분에서 저 래퍼 클래스가 요청 바디를 캐싱해주는 역할을 하는 줄 알았습니다. 하지만 알고 보니 디스패처 서블릿에서 소비된 `InputStream` 을 캐싱해주는 역할인 것 같습니다. 그래서 `getAfterMessage()`에서 payload를 가져오려고 할 때 이미 한 번 소비된 `InputStream`임에도 정상적으로 payload를 가져오는 것을 확인했습니다.

하지만 제가 원하는 것은 요청 단계에서 payload를 출력하는 것이 목표인데, 만약 `getBeforeMessage()`에서 `read()`해버리면 디스패처 서블릿에서 `@RequestBody`를 읽을 수 없어 `HttpMessageNotReadableException` 예외가 발생하게 됩니다.

 

이를 해결하기 위해서는 추가적인 클래스가 필요했습니다.

 

🧩 해결 방법

위 문제를 해결하기 위해 몇 가지 클래스를 추가했습니다.

1️⃣ CachedHttpServletRequest

더보기
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import org.springframework.util.StreamUtils;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;

public class CachedHttpServletRequest extends HttpServletRequestWrapper {

    private final byte[] cachedPayload;

    public CachedHttpServletRequest(HttpServletRequest request) throws IOException {
        super(request);
        InputStream requestInputStream = request.getInputStream();
        this.cachedPayload = StreamUtils.copyToByteArray(requestInputStream);
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedServletInputStream(this.cachedPayload);
    }

    @Override
    public BufferedReader getReader() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedPayload);
        return new BufferedReader(new InputStreamReader(byteArrayInputStream));
    }
}

2️⃣ CachedServletInputStream

더보기
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import lombok.extern.slf4j.Slf4j;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
public class CachedServletInputStream extends ServletInputStream {

    private final InputStream cachedInputStream;

    public CachedServletInputStream(byte[] cachedBody) {
        this.cachedInputStream = new ByteArrayInputStream(cachedBody);
    }

    @Override
    public boolean isFinished() {
        try {
            return cachedInputStream.available() == 0;
        } catch (IOException exp) {
            log.error(exp.getMessage());
        }
        return false;
    }

    @Override
    public boolean isReady() {
        return true;
    }

    @Override
    public void setReadListener(ReadListener readListener) {
        throw new UnsupportedOperationException();
    }

    @Override
    public int read() throws IOException {
        return cachedInputStream.read();
    }
}

3️⃣ RequestCachingFilter

더보기
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Order(value = Ordered.HIGHEST_PRECEDENCE)
@Component
public class RequestCachingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        filterChain.doFilter(new CachedHttpServletRequest(request), response);
    }
}

그리고 `ApiLoggingFilter`에서는 다음 부분을 수정하였습니다.

더보기
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    boolean isFirstRequest = !isAsyncDispatch(request);
    HttpServletRequest requestToUse = request;

    if (isIncludePayload() && isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
        requestToUse = new ContentCachingRequestWrapper(request, getMaxPayloadLength());
    }

    long startMs = System.currentTimeMillis();

    boolean shouldLog = shouldLog(requestToUse);
    if (shouldLog && isFirstRequest) {
        beforeRequest(requestToUse, getBeforeMessage(requestToUse));
    }
    try {
        filterChain.doFilter(request, response); //수정! requestToUse -> request
    } finally {
        if (shouldLog && !isAsyncStarted(requestToUse)) {
            afterRequest(requestToUse, getAfterMessage(startMs, requestToUse, response));
        }
    }
}

@Override
protected String getMessagePayload(HttpServletRequest request) {
    try {
        return new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
    } catch (IOException e) {
        return null;
    }
}

최종 완성된 `ApiLoggingFilter`는 다음과 같습니다.

더보기
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.CommonsRequestLoggingFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Component
public class ApiLoggingFilter extends CommonsRequestLoggingFilter {

    @Override
    protected int getMaxPayloadLength() {
        return 1000;
    }

    @Override
    protected boolean isIncludePayload() {
        return true;
    }

    @Override
    protected boolean isIncludeQueryString() {
        return true;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        boolean isFirstRequest = !isAsyncDispatch(request);
        HttpServletRequest requestToUse = request;

        if (isIncludePayload() && isFirstRequest && !(request instanceof ContentCachingRequestWrapper)) {
            requestToUse = new ContentCachingRequestWrapper(request, getMaxPayloadLength());
        }

        long startMs = System.currentTimeMillis();

        boolean shouldLog = shouldLog(requestToUse);
        if (shouldLog && isFirstRequest) {
            beforeRequest(requestToUse, getBeforeMessage(requestToUse));
        }
        try {
            filterChain.doFilter(request, response);
        } finally {
            if (shouldLog && !isAsyncStarted(requestToUse)) {
                afterRequest(requestToUse, getAfterMessage(startMs, requestToUse, response));
            }
        }
    }

    @Override
    protected String getMessagePayload(HttpServletRequest request) {
        try {
            return new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        } catch (IOException e) {
            return null;
        }
    }

    private String getBeforeMessage(HttpServletRequest request) {
        StringBuilder msg = new StringBuilder();

        msg.append("API REQUEST <<< [");
        appendRequestPath(request, msg);
        msg.append(" | ");

        if (isIncludePayload()) {
            String payload = getMessagePayload(request);
            msg.append("body=").append(payload);
        }

        msg.append("]");
        return msg.toString();
    }

    private String getAfterMessage(long startMs, HttpServletRequest request, HttpServletResponse response) {
        long executionTimeMs = System.currentTimeMillis() - startMs;
        int status = response.getStatus();

        StringBuilder msg = new StringBuilder();
        msg.append("API RESPONSE >>> [");

        appendRequestPath(request, msg);

        msg.append(" | ");

        msg.append("status=").append(status).append(" | ");
        msg.append("executionTime=").append(executionTimeMs).append("ms");

        msg.append("]");
        return msg.toString();
    }

    private void appendRequestPath(HttpServletRequest request, StringBuilder msg) {
        msg.append(request.getMethod()).append(" ");
        msg.append(request.getRequestURI());

        if (isIncludeQueryString()) {
            String queryString = request.getQueryString();
            if (queryString != null) {
                msg.append('?').append(queryString);
            }
        }
    }
}

➡️ 결과 확인

드디어 RequestBody를 잘 출력하는 것을 확인했습니다. 컨트롤러 요청에도 문제 없었습니다. 줄바꿈까지 잘 처리되서 보기 좋은 것 같습니다.

 


 

✅ 정리

`AbstractRequestLoggingFilter` 클래스를 사용하여 요청과 응답 사이에 원하는 형태로 로깅을 할 수 있었습니다. 위 해결 과정에서 각 클래스들이 어떤 역할을 하는지 디버깅으로 하나씩 확인해보려고 했으나 내부 코드가 너무 복잡해 모든 것을 확인하기는 힘들었습니다. 대략적으로는 `RequestCachingFilter`가 모든 필터(시큐리티 필터 포함) 중 가장 먼저 실행되어 요청 바디를 캐싱하고, `HttpMessageConverter` 단계에서 위에서 추가한 `CachedHttpServletRequest`를 사용하는 구조인 것 같습니다.

인터셉터로도 같은 기능을 구현할 수 있지만 결국 클래스 추가는 해야하는 것 같고 스프링에서 만들어 둔 클래스를 활용하는 것이 좋다고 생각해 필터를 사용해봤습니다.

 

같은 고민을 하고 계시는 다른 분들에게 조금이나마 도움이 되었으면 좋겠고, 더 좋은 의견 주시면 감사하겠습니다.

 

🔖 참고

https://www.baeldung.com/spring-http-logging

https://keichee.tistory.com/468

'Spring' 카테고리의 다른 글

Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기  (0) 2025.11.19
Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델  (0) 2025.11.19
PageableExecutionUtils.getPage로 페이징 성능 개선하기  (0) 2025.10.24
Spring 비동기로 이메일 전송하기  (0) 2025.10.21
[데브코스] Spring + Toss payments로 결제 기능 개발하기  (0) 2025.10.13
'Spring' 카테고리의 다른 글
  • Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델
  • PageableExecutionUtils.getPage로 페이징 성능 개선하기
  • Spring 비동기로 이메일 전송하기
  • [데브코스] Spring + Toss payments로 결제 기능 개발하기
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    raid
    데브코스
    토스 페이먼츠
    오블완
    자바
    JPA
    티스토리챌린지
    백엔드 면접
    스프링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
Spring AbstractRequestLoggingFilter로 RequestBody 로깅 찍기
상단으로

티스토리툴바