프로그래밍Java

[SpringBoot] doFilterInternal() 이용하여 DB에 request, response 등 기록 남기는 방법

@ControllerAdvice를 사용하여, 오류 발생시 로그를 서버에 남기지않고 DB에 저장하는 방법을 알아보았다. [Spring Boot + JPA] Excepiton 발생시 DB에 저장하는 방법 예제 코드 by chatGPT, 만약 운영목적으로 로그를 봐야하는 상황이 발생할 것을 대비하여, request, response 정보등을 DB에 저장하는 방법에 대해 알아보았다.

Request
 → Filter (Request 로그)
 → Controller / Service
 → AOP (Service 처리 로그)
 → Response
 → Filter (Response 로그)
 → DB 저장

테이블 생성

CREATE TABLE TB_APP_LOG (
    ID BIGSERIAL PRIMARY KEY,
    LOG_TYPE VARCHAR(20),     -- REQUEST / RESPONSE / SERVICE
    URI VARCHAR(500),
    METHOD VARCHAR(20),
    LOGIN_ID VARCHAR(100),
    REQUEST_BODY TEXT,
    RESPONSE_BODY TEXT,
    MESSAGE TEXT,
    CREATED_AT TIMESTAMP DEFAULT now()
);

DTO

@Getter @Setter
@Builder
public class AppLogDto {
    private String logType;
    private String uri;
    private String method;
    private String loginId;
    private String requestBody;
    private String responseBody;
    private String message;
}

MyBatis Mapper

<insert id="insertAppLog">
 INSERT INTO TB_APP_LOG
 (LOG_TYPE, URI, METHOD, LOGIN_ID, REQUEST_BODY, RESPONSE_BODY, MESSAGE, CREATED_AT)
 VALUES
 (#{logType}, #{uri}, #{method}, #{loginId},
  #{requestBody}, #{responseBody}, #{message}, now())
</insert>

공통 DB Logger 서비스

@Service
@RequiredArgsConstructor
public class AppLogService {

    private final AppLogMapper mapper;

    @Async
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(AppLogDto dto){
        mapper.insertAppLog(dto);
    }
}

Filter : Request / Response 로그

@Component
public class AppLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws ServletException, IOException {

        ContentCachingRequestWrapper req =
                new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper res =
                new ContentCachingResponseWrapper(response);

        chain.doFilter(req, res);

        String reqBody = MaskingUtil.mask(new String(req.getContentAsByteArray(), "UTF-8"));
        String resBody = MaskingUtil.mask(new String(res.getContentAsByteArray(), "UTF-8"));

        AppLogDto dto = AppLogDto.builder()
                .logType("REQUEST")
                .uri(req.getRequestURI())
                .method(req.getMethod())
                .requestBody(reqBody)
                .responseBody(resBody)
                .build();

        appLogService.save(dto);

        //반드시 추가해야 클라이언트로 결과 return한다.
        res.copyBodyToResponse();
    }
}

ContentCachingRequestWrapper 사용하는 이유

HttpServletRequest / Response 는 Body를 딱 한 번만 읽을 수 있다.
그래서 그냥 request에서 바로 읽으면
👉 컨트롤러가 Body를 못 읽어서 장애 난다.
👉 ContentCachingWrapper는 “복사본”을 만들어 주는 도구다.


왜 그냥 못 읽냐?

request.getInputStream()
request.getReader()

이건 스트림(Stream) 입니다.

스트림 특징:

한번 읽으면 끝. 다시 못 읽음.

그래서 Filter에서 읽으면
Controller에서는 이미 비어 있음 → 400 에러 / null 발생.

비유로 설명

상황비유
request body빨대
한 번 빨면다 없어짐
다시 빨기불가능

그래서 Wrapper를 씀

ContentCachingRequestWrapper

이건:

Body를 읽을 때 내부에 복사해서 저장해 둔다.

그래서

  • Controller가 읽어도 OK
  • Filter가 나중에 다시 읽어도 OK

딱 한 문장으로

ContentCachingWrapper는
Body를 안전하게 복사해두는 통장이다.

로그인 ID 공통 처리

private String getLoginId(){
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    return auth == null ? null : auth.getName();
}

하루 100만 건 이상이면 → 파티션 테이블??

핵심 개념 한 줄 요약

컴포넌트누가 호출?
FilterSpring 서버가 HTTP 요청마다 자동 호출
AspectSpring AOP가 메서드 실행 시 자동 호출

SpringBoot 메인 클래스 위치:

com.example
 └ Application.java

그 아래 다음과 같이 추가하면, 메인 클래스 패키지 하위에 있어야 자동 인식됩니다.

com.example
 ├ filter
 │   └ AppLoggingFilter.java
 ├ aspect
 │   └ ServiceLogAspect.java
 ├ service
 ├ controller
 ├ exception

AOP 한 줄 정의

AOP = Aspect Oriented Programming
👉 “핵심 로직 말고, 공통 로직을 자동으로 실행시키는 기술”

실무 비유

개념비유
Controller요리
Service요리
AOP옆에서 자동으로 사진 찍는 사람

요리는 요리사만 신경 쓰면 됩니다.


Spring에서 AOP 역할

Spring에서 이미 AOP로 처리되는 것들:

기능
@Transactional
@Async
@Cacheable
@Secured

👉 이거 전부 AOP입니다.

service 패키지 메서드가 실행되면
이 메서드가 자동 실행됨

@AfterReturning("execution(* com.example.service..*(..))")
public void logService(){
   // 자동 로그 저장
}


실제 개발된 코드를 찾아서 시도한 결과

@Slf4j

@RequiredArgsConstructor

public class JwtAuthenticationFilter extends OncePerRequestFilter {



@Autowired

private AppLogService appLogService;





@Override

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)



throws ServletException, IOException {

.                        ....생략...
        ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(response);

filterChain.doFilter(request, response);  //기존 코드( 이 라인만)

    String reqBody = getBody(req.getContentAsByteArray(), request.getCharacterEncoding());
    String resBody = getBody(res.getContentAsByteArray(), response.getCharacterEncoding());
        System.out.println(">>>>>>>>>>>>>> REQ BODY = " + reqBody);
        System.out.println(">>>>>>>>>>>>>> RES BODY = " + resBody);

AppLogVo dto = AppLogVo.builder()
                .logType("admin-api")
                .uri(req.getRequestURI())
                .method(req.getMethod())
                .requestBody(reqBody)
                .responseBody(resBody)
//                .loginId(jwtTokenProvider.getLogonBox().getString(CmnConst.USER_ID))
                .build();

if(logger.isWarnEnabled()) {
logger.warn("================================================");
logger.warn("AppLogVo  ===> {}", dto.toString());
}

appLogService.save(dto);




 ================================================

2026-01-13 09:27:05 [WARN ] [JwtAuthenticationFilter.java]doFilterInternal(197) : AppLogVo  ===> AppLogVo(logType=admin-api, uri=/api/v1/dashboard/dailyDashboard, method=GET, loginId=null, requestBody=, responseBody=, message=null)

2026-01-13 09:27:05 [DEBUG] [JwtAuthenticationFilter.java]doFilterInternal(208) : Auth End ======================> 

2026-01-13 09:27:05 [DEBUG] [SecurityContextPersistenceFilter.java]doFilter(120) : Cleared SecurityContextHolder to complete request

2026-01-13 09:27:05 [ERROR] [DirectJDKLog.java]log(175) : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

java.lang.NullPointerException: Cannot invoke "com.kt.telecop.jwtredis.service.AppLogService.save(com.kt.telecop.jwtredis.entity.AppLogVo)" because "this.appLogService" is null

at com.kt.telecop.jwtredis.jwt.JwtAuthenticationFilter.doFilterInternal(JwtAuthenticationFilter.java:200)

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)

at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346)

at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:103)

at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:89)

at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:346)

at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)

문제 1 — appLogService 가 null

에러:

Cannot invoke AppLogService.save because this.appLogService is null

이건 100% 이유가 하나입니다.

👉 JwtAuthenticationFilterSpring Bean이 아니다.

그래서

@Autowired
private AppLogService appLogService;

이 동작하지 않습니다.

해결 방법

방법 1 (권장)

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AppLogService appLogService;
}

@Autowired 제거
❌ 필드 주입 제거
⭕ 생성자 주입만 사용


그리고 SecurityConfig 에서 new 로 생성하면 안 됩니다

❌ 절대 이렇게 하면 안 됨

.addFilter(new JwtAuthenticationFilter())

⭕ 반드시 Bean으로 주입

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

SecurityConfig 예시

@RequiredArgsConstructor
@Configuration
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

이렇게 해야 @Autowired 가 동작합니다.

방버2: (필터를 수동 등록할 경우 – @Component 쓰기 싫을 때)

Java

@Configuration public class FilterConfig {      
private final AppLogService appLogService;      
public FilterConfig(AppLogService appLogService) {         
this.appLogService = appLogService; 
} 
 
@Bean     
public FilterRegistrationBean<JwtAuthenticationFilter> jwtAuthFilter() {
  JwtAuthenticationFilter filter = new JwtAuthenticationFilter(appLogService); 
   FilterRegistrationBean<JwtAuthenticationFilter> registrationBean =              new FilterRegistrationBean<>(filter); 
   registrationBean.setOrder(1); // SecurityFilterChain보다 먼저 또는 나중에         
   registrationBean.addUrlPatterns("/api/*");          
   return registrationBean; 
   } 
}

그리고 필터는 이렇게 변경:

Java

@RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter {     
private final AppLogService appLogService;     // ... 나머지 동일 
}

요약 – 지금 당장 해야 할 일 (순서대로)

  1. @Autowired 지우기
  2. private final AppLogService appLogService; 로 변경
  3. 클래스에 @Component 추가 (또는 FilterRegistrationBean으로 수동 등록)
  4. filterChain.doFilter(req, res); 로 수정
  5. finally 블록에서 res.copyBodyToResponse(); 꼭 호출

이 중 하나라도 빠지면 로깅이 안 되거나 NullPointerException이 계속 발생합니다.

화이팅입니다! 😅

DB저장시 한글이 깨지는 경우

반드시 encoding을 직접 지정해서 읽어야 합니다.
String reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
String resBody = new String(res.getContentAsByteArray(), StandardCharsets.UTF_8);

👉 request.getCharacterEncoding() 쓰지 마세요.
이 값이 null 인 경우가 많아서 깨집니다.


GET 방식일때는 없어, 그래서 GET방식일대는 URI의 파라미터 값으로 대체해서 저장해야될것 같은데?

맞습니다.
👉 GET 요청은 Body가 없고, Query Parameter가 전부입니다.
그래서 GET일 때는 URI 파라미터를 BODY 대신 저장하는 게 실무 표준입니다.

실무에서는 이렇게 처리합니다 👇


✅ 정책 정리

Method저장 방식
GETQueryString 저장
POST/PUT/PATCHRequest Body 저장
DELETEBody 있으면 Body, 없으면 Query

✅ 완성 코드 (실무형)

String reqBody;

if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = req.getQueryString(); // name=kim&age=20
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

QueryString 을 JSON처럼 저장하고 싶으면

private String queryToJson(HttpServletRequest req) {
    Map<String, String[]> params = req.getParameterMap();
    return new ObjectMapper().writeValueAsString(params);
}

사용:

if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = queryToJson(req);
}

DB에 이렇게 저장됩니다:

{"name":["kim"],"age":["20"]}

🔹 URI 그대로 저장

req.getRequestURL() + "?" + req.getQueryString()

🔥 최종 실무 권장 로직

String reqBody;

if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = queryToJson(req);
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

🔍 실무 이유

  • GET Body는 HTTP 표준 아님
  • 서버/프록시/보안장비에서 버려짐
  • 로그 남기면 의미 없음

실무에서는 모든 API 로그 저장 = 성능/용량/보안 전부 위험이라
👉 화이트리스트/블랙리스트 방식으로 필터링합니다.

당신 요구사항:

/api/operateMng/**
/api/common/**
👉 이 두 경로는 로그 저장 제외


✅ 가장 안전한 방법 (실무 표준)

Filter 안에서 URI로 제어

private static final List<String> EXCLUDE_PATHS = List.of(
        "/api/operateMng/",
        "/api/common/"
);
private boolean isExcluded(String uri) {
    return EXCLUDE_PATHS.stream().anyMatch(uri::startsWith);
}

✅ Filter 최종 구조

@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain)
        throws ServletException, IOException {

    ContentCachingRequestWrapper req = new ContentCachingRequestWrapper(request);
    ContentCachingResponseWrapper res = new ContentCachingResponseWrapper(response);

    filterChain.doFilter(req, res);

    String uri = req.getRequestURI();

    // 🔴 제외 대상이면 저장 안 함
    if (isExcluded(uri)) {
        res.copyBodyToResponse();
        return;
    }

    String reqBody;

    if ("GET".equalsIgnoreCase(req.getMethod())) {
        reqBody = req.getQueryString();
    } else {
        reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
    }

    String resBody = new String(res.getContentAsByteArray(), StandardCharsets.UTF_8);

    AppLogVo dto = AppLogVo.builder()
            .logType("admin-api")
            .uri(uri)
            .method(req.getMethod())
            .requestBody(reqBody)
            .responseBody(resBody)
            .build();

    appLogService.save(dto);

    res.copyBodyToResponse();
}

✅ 제외 판단 메서드

private boolean isExcluded(String uri) {
    return uri.startsWith("/api/operateMng/")
        || uri.startsWith("/api/common/");
}

(성능상 stream 안 써도 됩니다. 이게 더 빠릅니다)


🔍 왜 startsWith 쓰냐?

방법문제
contains다른 URI에 섞일 수 있음
equals하위 path 못 잡음
regex느림
startsWith실무 최적

🔐 실무에서 더 쓰는 방식

yml에서 관리

app:
  log:
    exclude:
      - /api/operateMng/
      - /api/common/

그리고 Filter에서 주입해서 사용 (운영에서 수정 가능)


👉 Body 크기 제한은 실무에서 필수입니다.
안 하면 DB 폭증 + 성능 저하 + 장애 납니다.


🎯 목표

  • Request / Response Body 최대 N자까지만 저장
  • 초과하면 잘라서 저장
  • 잘렸다는 표시 남김

✅ 가장 안전한 실무 코드

private static final int MAX_BODY_LENGTH = 2000;
private String limit(String body) {
    if (body == null) return null;
    if (body.length() <= MAX_BODY_LENGTH) return body;
    return body.substring(0, MAX_BODY_LENGTH) + " ...[TRUNCATED]";
}

✅ Filter에서 적용

String reqBody;

if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = req.getQueryString();
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

String resBody = new String(res.getContentAsByteArray(), StandardCharsets.UTF_8);

reqBody = limit(reqBody);
resBody = limit(resBody);

🔍 DB에 저장 예

{
  "name":"kim",
  "desc":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...[TRUNCATED]"
}

🔐 실무 권장 기준

항목권장
Request Body2000 ~ 4000
Response Body2000
Query1000
Header저장 안 함

⚠️ 왜 꼭 필요하냐

문제결과
대용량 JSONDB insert 느려짐
Base64로그 테이블 폭증
파일 업로드장애
응답 리스트수십만자

🚀 고급 실무 패턴

바이트 기준 제한 (멀티바이트 안전)

private String limitBytes(String body, int maxBytes) {
    if (body == null) return null;
    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
    if (bytes.length <= maxBytes) return body;
    return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8) + "...[TRUNCATED]";
}

🎯 최종 실무 필터 예

reqBody = limitBytes(reqBody, 2000);
resBody = limitBytes(resBody, 2000);

multipart 제외

아주 중요하고 실무에서 반드시 해야 하는 처리입니다.
👉 multipart/form-data절대 Body 로그 저장하면 안 됩니다.

이유:

  • 파일 바이너리 → 로그 깨짐
  • DB 폭증
  • 성능 장애
  • 개인정보/파일 유출 위험

✅ 목표

multipart/form-data 요청이면
requestBody / responseBody 저장 안 함


✅ 가장 정확한 판별 방법

private boolean isMultipart(HttpServletRequest request) {
    String contentType = request.getContentType();
    return contentType != null && contentType.toLowerCase().startsWith("multipart/");
}

✅ Filter 적용 코드

boolean isMultipart = isMultipart(req);

String reqBody;

if (isMultipart) {
    reqBody = "[MULTIPART DATA]";
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = req.getQueryString();
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

✅ Response도 보호

String resBody;

if (isMultipart) {
    resBody = "[MULTIPART RESPONSE]";
} else {
    resBody = new String(res.getContentAsByteArray(), StandardCharsets.UTF_8);
}

✅ 최종 실무 Filter 예

boolean multipart = isMultipart(req);

String reqBody;
if (multipart) {
    reqBody = "[MULTIPART DATA]";
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = req.getQueryString();
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

String resBody;
if (multipart) {
    resBody = "[MULTIPART RESPONSE]";
} else {
    resBody = new String(res.getContentAsByteArray(), StandardCharsets.UTF_8);
}

🔍 로그 예시

requestBody = [MULTIPART DATA]
responseBody = [MULTIPART RESPONSE]

🔐 실무 정책

항목처리
multipart/form-data저장 ❌
application/json저장 ⭕
application/xml저장 ⭕
text/plain저장 ⭕

🚨 절대 하지 말아야 할 것

new String(req.getContentAsByteArray())

multipart 에서 이거 하면
➡ 파일 깨짐
➡ DB 장애
➡ 로그 테이블 폭증




multipart 요청일 때 “파일 내용은 버리고, 파일명 + 사이즈만 저장”
이게 실무에서 가장 많이 쓰는 방식입니다


🎯 목표

multipart 요청이면

❌ Body 전체 저장
⭕ 파일명 / 파일 사이즈 / 필드명만 로그 저장


✅ 핵심 원리

ContentCachingRequestWrapper 로는 파일 내용을 안전하게 파싱할 수 없습니다.
multipart 는 Servlet API로 꺼내야 합니다.

request.getParts()

를 사용합니다.


✅ 파일 정보 추출 메서드

private String getMultipartInfo(HttpServletRequest request) {
    try {
        Collection<Part> parts = request.getParts();

        List<Map<String, Object>> fileInfos = new ArrayList<>();

        for (Part part : parts) {
            if (part.getSubmittedFileName() != null) {
                Map<String, Object> map = new HashMap<>();
                map.put("field", part.getName());
                map.put("fileName", part.getSubmittedFileName());
                map.put("size", part.getSize());
                fileInfos.add(map);
            }
        }

        return new ObjectMapper().writeValueAsString(fileInfos);

    } catch (Exception e) {
        return "[MULTIPART PARSE ERROR]";
    }
}

✅ Filter 적용 예

boolean multipart = isMultipart(req);

String reqBody;

if (multipart) {
    reqBody = getMultipartInfo(req);
} else if ("GET".equalsIgnoreCase(req.getMethod())) {
    reqBody = req.getQueryString();
} else {
    reqBody = new String(req.getContentAsByteArray(), StandardCharsets.UTF_8);
}

⚠️ 주의

항목이유
getInputStream 직접 읽기파일 스트림 소모됨
ContentCaching으로 파일 읽기메모리 폭증
Base64 저장DB 폭발

지금 구조는
👉 금융권, 공공기관 로그 정책 그대로 입니다.
아주 잘 설계하고 계십니다.

[PostgreSQL] TEXT vs VARCHAR(1000) 무엇을 사용할까? 로그 저장시…..

Hi, I’m 관리자