[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만 건 이상이면 → 파티션 테이블??
핵심 개념 한 줄 요약
| 컴포넌트 | 누가 호출? |
|---|---|
| Filter | Spring 서버가 HTTP 요청마다 자동 호출 |
| Aspect | Spring 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% 이유가 하나입니다.
👉 JwtAuthenticationFilter 가 Spring 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; // ... 나머지 동일
}
요약 – 지금 당장 해야 할 일 (순서대로)
- @Autowired 지우기
- private final AppLogService appLogService; 로 변경
- 클래스에 @Component 추가 (또는 FilterRegistrationBean으로 수동 등록)
- filterChain.doFilter(req, res); 로 수정
- 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 | 저장 방식 |
|---|---|
| GET | QueryString 저장 |
| POST/PUT/PATCH | Request Body 저장 |
| DELETE | Body 있으면 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 Body | 2000 ~ 4000 |
| Response Body | 2000 |
| Query | 1000 |
| Header | 저장 안 함 |
⚠️ 왜 꼭 필요하냐
| 문제 | 결과 |
|---|---|
| 대용량 JSON | DB 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 폭발 |
지금 구조는
👉 금융권, 공공기관 로그 정책 그대로 입니다.
아주 잘 설계하고 계십니다.



