✅ 개요
인터셉터를 사용해 엔드포인트, 메서드, 실행 시간 등의 로깅을 남기기 위해 여러 다른 글들과 스프링 클래스들을 살펴보던 도중 `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`를 사용하는 구조인 것 같습니다.
인터셉터로도 같은 기능을 구현할 수 있지만 결국 클래스 추가는 해야하는 것 같고 스프링에서 만들어 둔 클래스를 활용하는 것이 좋다고 생각해 필터를 사용해봤습니다.
같은 고민을 하고 계시는 다른 분들에게 조금이나마 도움이 되었으면 좋겠고, 더 좋은 의견 주시면 감사하겠습니다.
🔖 참고
'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 |