프로젝트에서 Spring Boot, Spring Security, JWT 인증을 사용해 REST API를 개발하던 중, 특정 비즈니스 로직에서 CustomException
이 발생했음에도 불구하고, 글로벌 예외 처리기(@RestControllerAdvice
) 가 이를 잡지 못하고 기본 에러 핸들링(500 에러 응답)으로 처리되는 문제가 발생했습니다.
CustomException
이 제대로 발생했음에도,200 + JSON(에러 정보)
” 형식인데, 전혀 핸들링되지 않은 채 예외가 터진 상황으로 보입니다.다음은 실제 로그 일부입니다.
2025-01-28T00:42:12.943+09:00 ERROR ...
Servlet.service() for servlet [dispatcherServlet] ...
Request processing failed: org.cheonyakplanet.be.presentation.exception.CustomException: 중복된 이메일 존재
... (중략) ...
Securing POST /error
Secured POST /error
"ERROR" dispatch for POST "/error"
Mapped to org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error(HttpServletRequest)
...
Writing [{timestamp=..., status=500, error=Internal Server Error, path=...}]
Exiting from "ERROR" dispatch, status 500
즉,
1. CustomException
이 발생.
2. BasicErrorController#error(...)
가 호출됨(스프링 기본 에러 처리).
3. 최종적으로 500 에러 응답이 내려옴.
글로벌 예외 처리기(@RestControllerAdvice)는 전혀 반응하지 않습니다.
로그에서 FilterChainProxy
, AnonymousAuthenticationFilter
, ExceptionTranslationFilter
등이 등장합니다.
이는 Spring Security 필터 체인에서 예외를 먼저 인터셉트(intercept)하고, 자체 로직에 따라 /error
경로로 포워딩하거나,
혹은 Security가 제공하는 기본 예외 처리 흐름을 태우고 있음을 의미합니다.
Spring Security의 기본 정책:
- 인증(Authentication) 또는 인가(Authorization) 과정에서 에러가 발생하면,
AuthenticationEntryPoint
(인증 실패 시),AccessDeniedHandler
(인가 실패 시)
로 에러를 처리하고, 보통/error
로 포워딩하게 됩니다.
그렇다 보니 DispatcherServlet
(스프링 MVC 컨트롤러)로 예외가 넘어가기 전에, Security가 “이미 처리 완료”하거나, “/error”로 넘겨 BasicErrorController가 500을 만드는 구조가 됩니다.
다시 말해, CustomException
이 컨트롤러나 서비스 내부에서 발생하더라도, Security 필터에서 해당 예외를 잡아 GlobalExceptionHandler
에게 전달하지 않을 수 있습니다.
Spring MVC(DispatcherServlet
) 단계에 도달하기 전에 보안 필터가 먼저 동작하므로, 필터 단계에서 발생하거나 감지된 예외는 작성한 @ExceptionHandler
메서드가 처리하기 어렵습니다.
정리하자면,
/error
로 포워딩 → BasicErrorController에서 기본 500 응답. GlobalExceptionHandler
까지 가지 않고, Security 흐름에서 마무리됩니다.또한, 로그상 Extracted Token: null
처럼 토큰 유효성 검증에서 예외가 발생하거나, UserService.signup()
과정에서 예외가 발생해도 Security가 이를 잡아서 /error
로 넘기고 있습니다.
스프링 시큐리티는 AuthenticationEntryPoint
, AccessDeniedHandler
를 통해 인증/인가 예외 상황을 처리합니다.
이를 직접 구현해주면, 기본 403/401 오류 페이지 대신 JSON 응답 등을 자유롭게 내려줄 수 있습니다.
또한, 필요하다면 GlobalExceptionHandler
로 예외를 재전달하도록 구성할 수도 있습니다.
CustomAuthenticationEntryPoint
구현@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// 원하는 메시지 혹은 더 상세한 에러 구조
response.getWriter().write(
"{\"status\":\"fail\",\"message\":\"Unauthorized access\"}"
);
}
}
CustomAccessDeniedHandler
구현@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException
) throws IOException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write(
"{\"status\":\"fail\",\"message\":\"Access Denied\"}"
);
}
}
이제 Spring Security 설정에서 두 구현체를 등록해줍니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@Autowired
private CustomAccessDeniedHandler customAccessDeniedHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/member/signup").permitAll()
.anyRequest().authenticated()
.and()
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler);
}
}
이렇게 하면, 인증/인가 관련 예외 발생 시, 우리가 정의한 JSON 응답을 내려주게 되며, 불필요하게 /error
로 포워딩되어 500 에러가 뜨는 일을 막을 수 있습니다.
OncePerRequestFilter
로 예외를 DispatcherServlet으로 재전달하기만약 Security 필터 체인에서 던져진 예외를 그대로 GlobalExceptionHandler
로 처리하고 싶다면, 다음과 같은 커스텀 필터를 이용해 예외를 직접 DispatcherServlet
까지 전달할 수 있습니다.
@Component
public class CustomFilterExceptionHandler extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (CustomException e) {
// 여기서 직접 예외를 처리하거나,
// 혹은 dispatcher로 넘길 수도 있음
request.setAttribute("javax.servlet.error.exception", e);
response.sendError(HttpServletResponse.SC_BAD_REQUEST, e.getMessage());
}
}
}
그리고 이를 Security Filter Chain에 가장 먼저(또는 적절한 위치에) 추가:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomFilterExceptionHandler customFilterExceptionHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.addFilterBefore(customFilterExceptionHandler, UsernamePasswordAuthenticationFilter.class)
// 그 외 Security 설정
;
}
}
이렇게 하면, Security 필터 체인에서 CustomException
이 발생해도 CustomFilterExceptionHandler
가 이를 감지하고, Spring MVC로 예외를 rethrow하거나 특정 방식으로 래핑하여 GlobalExceptionHandler
가 처리할 수 있도록 만들 수 있습니다.
다만, 설정 순서와 핸들링 로직을 꼼꼼히 살펴봐야 의도대로 동작합니다.
GlobalExceptionHandler
(ControllerAdvice) 설정 점검기본적으로, Spring에서 @RestControllerAdvice
는 모든 @RestController
를 대상으로 예외를 처리합니다. 하지만 경우에 따라 basePackages
, annotations
등의 속성을 커스터마이징하여 대상이 좁혀질 수 있습니다.
예를 들어,
@RestControllerAdvice(annotations = RestController.class)
public class GlobalExceptionHandler { ... }
로 선언했을 때,
@RestController
가 빠져 있다면(예: @Controller
만 달림) 예외 처리가 누락될 수 있습니다.또는,
@RestControllerAdvice(basePackages = "org.cheonyakplanet.be.presentation")
처럼 설정했는데 정작 컨트롤러가 다른 패키지에 있다면 잡히지 않을 수 있습니다.
확인 사항
@RestControllerAdvice
선언 시, 특별한 옵션을 쓰지 않았다면 보통 모든@RestController
예외를 잡습니다.- 혹시라도
@ControllerAdvice
또는@RestControllerAdvice
에 특정 옵션(basePackages, annotations 등)을 사용했다면, 그 범위가 올바른지 확인해야 합니다.
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<ErrorData>> handleCustomException(CustomException e) {
ErrorCode errorCode = e.getErrorCode();
ErrorData errorData = new ErrorData(
errorCode.getCode(),
errorCode.getMessage(),
e.getDetails()
);
ApiResponse<ErrorData> response = new ApiResponse<>("fail", errorData);
// 200으로 내려도 되고, 400 등의 상태코드로 내려도 됨
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
CustomException
발생 시 “fail
상태 + 에러 코드/메시지”를 내려주려면, 위와 같이 정확한 @ExceptionHandler
메서드가 있어야 합니다.
문제 원인
1) Spring Security가 인증/인가 단계의 예외를 먼저 처리한다.
2) 그 결과, GlobalExceptionHandler
까지 예외가 도달하지 못하고 /error
경로로 포워딩되어 BasicErrorController
에서 500 응답을 만든다.
3) 혹은 @RestControllerAdvice
설정(basePackages, annotations 등) 문제로 인해 실제 컨트롤러 예외가 Advice에 포착되지 않는 경우도 있다.
해결 방안
1) Spring Security 예외 처리(AuthenticationEntryPoint
, AccessDeniedHandler
)를 커스터마이징하여 원하는 JSON 형식으로 응답을 내보낸다.
2) OncePerRequestFilter 등을 추가하여, Security 필터에서 던져지는 예외를 DispatcherServlet으로 재전달(rethrow)해 GlobalExceptionHandler
가 처리하도록 한다.
3) @RestControllerAdvice
설정 범위(annotations, basePackages)를 다시 확인하여, 예외가 정상적으로 잡히는지 점검한다.
궁극적으로는 Security 필터가 예외를 처리할 것인지, 글로벌 예외 처리기로 넘길 것인지를 명확히 결정하고, 해당 설계에 맞춰서 Security 설정과 Advice 설정을 조정해야 합니다.
권장: 인증/인가 로직에서 발생하는 예외는 Security의 AuthenticationEntryPoint/AccessDeniedHandler를 통해 처리하고, 그 외의 비즈니스 로직 예외는
GlobalExceptionHandler
를 통해 일관된 JSON 응답으로 관리하는 방식이 보편적입니다.