1. 서론
이번에 무료로 ChatGPT 5를 쓰게 되어서 시험삼아 Codex를 사용해서 프로젝트를 분석해봤다.
질문 1. 이 Spring 프로젝트를 분석하고 너가 생각했을 때 수정할만한거 있어?
돌아오는 답변 중 하나
src/main/java/lostark/todo/global/config/filter/JwtAuthenticationFilter.java:53 이후:
토큰 없음/검증 실패 시 sendErrorResponse만 호출하고 바로 filterChain.doFilter로 넘어가서 컨트롤러가
그대로 실행됩니다. 에러 응답을 썼다면 return으로 체인을 중단하거나 HttpServletResponse를 이용해 명시적으로
종료해야 합니다.
질문 2. 즉, 내가 만든 인증 필터에서 걸려도 그대로 실행된다는 건가? 이 문제 관련해서 HttpServletResponse.getWriter로 응답 리턴해서 괜찮지 않나?
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
if (response != null) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
String jsonResponse = String.format("{\"message\": \"%s\"}", message);
response.getWriter().write(jsonResponse);
} else {
log.error(message); // 응답 객체가 없는 경우 로그로 기록
}
}
답변
sendErrorResponse(...)가 403 본문을 써 주는 건 맞지만, 그 뒤에
filterChain.doFilter(request, response)를 계속 호출한다는 게 핵심입니다.
OncePerRequestFilter에서 체인을 한 번이라도 태우면 나머지 Spring Security 필터들과 컨트롤러까지
그대로 실행됩니다. 이렇게 되면 두 가지 문제가 생깁니다.
- 응답을 이미 커밋한 뒤 추가 처리가 계속됨: 예를 들어 토큰이 잘못된 경우 sendErrorResponse가 403 JSON을
내려도, FilterSecurityInterceptor는 여전히 요청을 받아 AccessDenied 처리를 시도합니다.
이미 커밋된 응답에 다시 sendError/write를 하게 돼 Tomcat 기준으로 Response already committed,
IllegalStateException 같은 경고나 예외가 떨어질 수 있고, 컨트롤러 코드가 실행되면 부수효과(DB 저장 등)는
그대로 발생합니다. 결국 “403을 받았지만 서버는 실제 작업을 해 버린” 상황이 될 수 있습니다.
- 관리자 권한 체크가 사실상 무력화: checkAdminRole에서 /admin URI 검증에 실패하면
sendErrorResponse(null, ADMIN_REQUIRED_MESSAGE)를 호출하는데, null을 넘겼기 때문에
실제 HTTP 응답에는 아무것도 쓰지 않습니다. 이어서 체인을 계속 태우니 컨트롤러까지 도달하고,
추가 보안 장치가 없다면 일반 사용자가 /admin API를 이용할 수 있게 됩니다.
그래서 403 응답을 내려야 하는 분기에서는 꼭 return;으로 체인을 끊거나, filterChain.doFilter를 호출하지 않는 방식으로 “요청 처리가 여기서 끝났다”는 걸 명시해 줘야 합니다.
2. 테스트
1. 토큰 에러 메시지가 보이지 않음
유효하지 않은 토큰으로 `/v1/character-list`을 호출했더니, 기대했던 {"message":"유효하지 않은 토큰입니다."} 대신 기본 403 응답만 내려왔다. 분명 sendErrorResponse는 JSON을 쓰고 있는데 결과가 달랐다.
2. 관리자 API가 의도대로 막히지 않음
일반 사용자 토큰으로 `/admin/dash-board/member` 경로에 접근하면 필터에서 권한을 체크하고 막아야 하지만, 실제로는 서비스 레이어가 실행됐다. 로그만 남고 응답은 정상적으로 내려가는 것이다.
3. 원인 분석
1. doFilterInternal 메서드
if (token != null && !token.equalsIgnoreCase("null")) {
processToken(request, response, securityContext, token);
} else {
processNoToken(request, response, securityContext);
}
filterChain.doFilter(request, response);
processToken과 processNoToken에서 sendErrorResponse로 403을 쓰고도 바로 filterChain.doFilter가 이어진다. OncePerRequestFilter의 특성상 체인을 태우는 순간 뒤에 있는 ExceptionTranslationFilter나 FilterSecurityInterceptor까지 모두 실행된다. 이미 커밋된 응답에 다시 sendError를 호출하려다 IllegalStateException이 나거나, 컨트롤러에 도달해서 에러가 발생할 수 있다.
2. checkAdminRole 메서드
private void checkAdminRole(HttpServletRequest request, String username) throws IOException {
if (request.getRequestURI().startsWith("/admin")) {
Member member = memberService.get(username);
if (member.getRole() != Role.ADMIN) {
sendErrorResponse(null, ADMIN_REQUIRED_MESSAGE);
}
}
}
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
if (response != null) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
String jsonResponse = String.format("{\"message\": \"%s\"}", message);
response.getWriter().write(jsonResponse);
} else {
log.error(message); // 응답 객체가 없는 경우 로그로 기록
}
}
관리자 권한 체크는 더 심각했다. checkAdminRole 메소드가 HttpServletResponse를 받지 않고 있어서, 권한 부족 시 sendErrorResponse(null, ...)만 호출하고 끝난다. 실제 HTTP 응답은 건드리지 않으니 체인이 계속 진행되고, 스프링 시큐리티는 “인증된 사용자”라고 보고 `/admin`을 통과시킨다.
정리하면, 직접 에러 응답을 쓰면서도 체인을 끊지 않은 구조가 문제였다.

4. 수정
1. 처리 결과를 명시적으로 반환
- processToken과 processNoToken이 boolean 값을 리턴하도록 바꾸고, doFilterInternal에서 false가 반환되면 곧바로 return
- 실패 케이스에서 더 이상 filterChain.doFilter가 호출되지 않도록 했다.
// 토큰이 존재하면 검증 및 인증 컨텍스트 설정, 없으면 비인증 처리
boolean proceed = (token != null && !token.equalsIgnoreCase("null"))
? processToken(request, response, securityContext, token)
: processNoToken(request, response, securityContext);
if (!proceed) {
return; // 필터 체인 중단
}
SecurityContextHolder.setContext(securityContext);
filterChain.doFilter(request, response);
2. 관리자 권한 검증 개선
- checkAdminRole에 HttpServletResponse를 전달해 실제 403 응답을 작성하도록 변경.
- 권한이 없으면 false를 반환해 체인을 즉시 중단.
private boolean checkAdminRole(HttpServletRequest request, HttpServletResponse response, String username) throws IOException {
if (request.getRequestURI().startsWith("/admin")) {
Member member = memberService.get(username);
if (member.getRole() != Role.ADMIN) {
sendErrorResponse(response, ADMIN_REQUIRED_MESSAGE);
return false;
}
}
return true;
}
5. 회고
- 필터에서 응답을 썼다고 끝난 것이 아니다. filterChain.doFilter를 호출하는 순간 요청은 계속 흐른다. 응답을 커밋했다면 반드시 체인을 중단해야 한다.
- 권한 체크에는 항상 응답 객체를 붙이기. 로그만 찍으면 아무 일도 일어나지 않는다. HttpServletResponse로 명확하게 403을 내려줘야 한다.
- 1인 서비스라 내가 직접 관리해서 관리자 API가 많이 없는게 다행이지... 큰 서비스였으면 관리자 API 그냥 뚫려있던 거였다... 테스트 잘하자...