Spring Security와 Spring Web을 사용한 프로젝트를 진행하던 중 이해하기 힘든 이슈를 만났다. Handler, 즉 Controller에서 발생한 예외를 HandlerExceptionResolver로 처리하지 못했는데 Spring Secuirty의 Filter 중 하나인 ExceptionTranslationFilter가 이를 처리하여 응답했다. 반환된 결과는 아래와 같다.
서버에 찍힌 로그는 아래와 같았다.
해당 필터는 Spring Security 인증/인가 과정에서 발생한 예외(AccessDeniedException, AuthenticationException)를 처리한다고 알고 있었는데 나중에 확인해 보니 인증/인가의 문제로 발생된 예외가 아니였고 해당 필터가 예외를 처리한 이유를 알 수 없었다. 또한 해당 응답 데이터로 Controller에서 어떤 이유로 에러가 발생했는지 판단하기도 힘들었다. 구글링해도 알 수 없었기에 직접 디버깅하며 원인을 찾고 해결했던 방법을 공유하고자 한다.
본 글을 이해하기 위해서 몇가지 사전 지식이 필요하다. 본 글에서 이를 설명하면 매우 길어질 것 같아 아래 소개하는 링크의 글들을 보고 오면 좋을 것 같아 안내한다.
스프링에서 Handler에서 예외가 발생하고 DispatcherServlet으로 전달되었을 경우 어떠한 방법으로 예외가 처리되는지와 만약 DispatcherServlet에서 예외가 처리되지 않을 경우 어떠한 흐름으로 예외가 처리되는지에 대한 지식이 필요하다. 깨알 홍보..😁
스프링 예외 처리 방법(1)
스프링 예외 처리 방법(2)
위 2개 링크의 글을 읽어보기 바란다.
Spring Security는 SpringFilterChain이라는 ServletFilterChain과 구분되는 Filter Chain을 가지고 있다. ServletFilterChain과 SpringFilterChain의 차이점을 알고 어떠한 아키텍처를 가지고 있는데 사전 지식이 필요하다.
위 링크의 글을 읽어보기 바란다.
Spring Secuirty는 SecurityFilterChain의 인증/인가 과정에서 발생한 예외를 처리하는 기능이 존재한다. ExceptionTranslationFilter가 발생된 예외를 처리하는 Security Filter이다.
사용자 요청이 들어오면 ExceptionTranslationFilter는
우선 FilterChain.doFilter(request, response); 메소드를 통해 애플리케이션 뒷 부분을 실행한다. 이때, Security Exception이 발생하면 해당 예외를 처리한다.
사용자가 인증되지 않았거나 AuthenticationException이 발생한 경우, 인증을 시작한다.
2-1. SecurityContextHolder를 비운다.
2-2. 추후 인증 이후 다시 사용하기 위해 요청을 저장한다.
2-3. AuthenticationEntryPoint 인터페이스의 commence()를 실행하여 로그인 페이지로 디라이렉트 하는 등 인증을 시작한다. 필자는 AuthenticationEntryPoint 인터페이스 구현체를 만들어 AuthenticationException이 발생했다고 Json 형태로 응답하도록 코드를 구현하였다. 아래 코드가 그것이다.
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
log.error("AuthenticationException due to not authenticated request");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
try {
response.getWriter().write(objectMapper.writeValueAsString(
ApiErrorResponse.builder()
.code(HttpStatus.UNAUTHORIZED.value())
.message(authException.getMessage())
.build()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.error("AccessDeniedException due to not authority request");
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
try {
response.getWriter().write(objectMapper.writeValueAsString(
ApiErrorResponse.builder()
.code(HttpStatus.FORBIDDEN.value())
.message(accessDeniedException.getMessage())
.build()));
} catch (IOException e) {
e.printStackTrace();
}
}
}
윗 설명이 맞는지 구체적인 코드를 뜯어보자!
위 코드는 ExceptionTranslationFilter의 doFilter()메소드이다. 코드를 보면 우선 chain.doFilter(request,response);를 통해 애플리케이션을 실행시킨다. 이때, 예외가 발생하면 catch(Exception ex){...}이 실행되고 내부의 handleSpringSecurityException(); 메소드가 실행된다. 메소드 이름에서 알 수 있듯이 해당 메소드를 통해 Security Exception(AccessDeniedException, AuthenticationException)를 처리한다.
handleSpringSecurityException() 메소드는 발생된 예외가 Security Exception(AccessDeniedException, AuthenticationException)인지 확인하고 각각의 예외에 따라 다른 함수를 호출한다. AuthenticationException인 경우, handleAuthenticationException()를 호출하고
내부적으로 다시 sendStartAuthentication()를 호출하여 인증을 시작하고 AccessDeniedException인 경우, handleAccessDeniedException()를 호출한다.
handleAccessDeniedException()함수 내부 로직을 살펴보자. isAnonymous() 함수를 사용하여 접근 사용자가 익명 사용자인지 즉, 인증이 된 사용자인지 판단한다. 이유는 접근 사용자가 단순히 인증을 하지 않아 접근 권한이 없는 것인지,, 아니면 해당 인증 사용자가 해당 Resource에 대해 접근 권한이 없는 것인지 판단하기 위함이다.
전자의 경우 handleAuthenticationException()와 마찬가지로 sendStartAuthentication()를 호출하여 인증을 시작한다. 즉, 위 2번에서 설명했듯이 사용자가 인증되지 않았거나 AuthenticationException이 발생한 경우, 인증을 시작한다라는 내용이 코드로 확인됨을 알 수 있다! 해당 경우가 아니면 this.accessDeniedHandler.handle()메소드를 호출하는데 이는 위 3번에서 설명했듯이 AccessDeniedHandler 인터페이스의 handle()메소드가 실행된다. 해당 사실도 코드로 확인됨을 알 수 있다!
마지막으로 sendStartAuthentication()함수를 살펴보자.
해당 함수에서 위에서 설명한 2-1, 2-2, 2-3의 사실을 코드로 확인됨을 알 수 있다! SecurityContextHolder를 비우고 요청을 저장한 다음 최종적으로 commence()메소드를 실행함을 알 수 있다.
위에서 설명한 전체 과정을 스프링 공식문서에서 슈도 코드로 표현했는데 코드는 아래와 같다.
try {
filterChain.doFilter(request, response); ...1
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication(); ...2
} else {
accessDenied(); ...3
}
}
필자의 애플리케이션 Handler에서 예외에 대한 코드를 설명하겠다.
@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException {
ErrorCode errorCode;
}
RuntimeException을 상속하는 CustomException을 만들고 변수로 ErrorCode enum을 추가하였다. ErrorCode enum의 내용은 아래와 같다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum ErrorCode {
NOT_FOUND(HttpStatus.NOT_FOUND, 404, "존재하지 않는 데이터입니다."),
INVALID_TOKEN(HttpStatus.BAD_REQUEST, 400, "잘못된 형식의 토큰입니다."),
EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, 401, "만료된 토큰입니다."),
INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, 401, "잘못된 JWT 서명입니다."),
EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 503, "외부 API 호출을 실패하였습니다."),
CLAIM_NOT_FOUND(HttpStatus.BAD_REQUEST, 400, "토큰 속 Claim 정보가 존재하지 않습니다.");
HttpStatus httpStatus;
private int code;
private String message;
}
예외의 종류를 조금 더 구체화하기 위해 CustomException 클래스의 자식 클래스를 만들었는데 그 중 하나는 아래와 같다.
@Getter
public class DataNotFoundException extends CustomException {
public DataNotFoundException(ErrorCode errorCode) {
super(errorCode);
}
}
Refresh Token을 사용하여 Access Token을 재발급하는 API에서 DB에 클라이언트에서 전달된 Refresh Token이 없으면 DataNotFoundException()를 throw하는 코드이다. 본 글에서는 조금 더 쉬운 이해를 위해 아래와 같이 예외를 throw하였다.
DataNotFoundException을 Handler 내부에서 또는 HandlerExceptionResolver에서 실수로 처리하지 않았는데 Spring Security Filter가 AccessDeniedException을 catch하여 예외처리를 해버린 것이다.
@ExceptionHandler(DataNotFoundException.class)를 통해 ExceptionResolver가 해당 예외를 처리하면 더 이상 Security Filter가 예외를 처리하지 않았지만 필자가 원하는 것은 개발자가 실수로 Handler에서 발생한 Exception을 처리하지 않아도 Security Filter가 이를 처리하지 않고 에러 데이터를 반환하여 어느 부분에서 예외가 발생하였는지 대략적으로라도 확인이 가능하도록 하는 것이다. 그래야 개발자가 대충이라도 어느 부분을 수정해야하는지 알 수 있기 때문이다!
이번 글에서는 배경 지식을 설명하느라 많은 부분을 차지해버렸다. 다음 글에서 이에 대한 이슈 디버깅과 해결방법을 말하고자 한다.