저번 글에서 배경 지식을 배우고 내부 구현 코드를 뜯어보았다. 이번 글에서는 디버깅 통해 실재 코드에서 예외가 어떻게 처리되는지 공유해보자 한다.
그 전에 필자의 Spring Security Filter Chain은 아래와 같이 구성되어 있다.
JwtAuthenticationExceptionHandlerFilter, JwtAuthenticationFilter은 필자가 구현하고 추가 삽입한 security filter이다. 여기서 주의 깊게 봐야할 Filter는 ExceptionTranslationFilter와 AuthorizationFilter이다.
API 요청이 오면
ApplicationFilterChain의 doFilter() 메소드가 호출된 것을 확인할 수 있다. ApplicationFilterChain는 FilterChain의 구현체인데 FilterChain은 Servlet Container에서 요청에 대한 Filter의 Chain을 제공하기 위한 인터페이스이다. 즉, ApplicationFilterChain도 ServletFilterChain이다. doFilter() 메소드는 내부적으로 internalDoFilter()를 호출한다.
이때 request의 DispatcherType은 REQUEST이다. 해당 사실은 해결책의 실마리임으로 잘 기억하자.
internalDoFilter()는 다음 필터를 호출하는 메소드이다.
internalDoFilter() 함수의 내부 코드를 살펴보면 filter.doFilter()를 통해 다음 필터를 호출하는 것을 확인할 수 있다.
프로그램을 계속 진행시켜보면 위 사진과 같이 ApplicationFilterChain의 internalDoFilter 메소드가 반복 호출됨을 확인할 수 있고 []안의 숫자도 증가하는 것을 확인할 수 있다. 이는 Application Filter Chain속의 Filter들을 호출하는 것을 의미한다.
애플리케이션을 계속 진행시키다 보면 AuthorizationFilter doFilter() 메소드 내부의 중단점인 chain.doFilter() 메소드를 호출하는 것을 확인할 수 있다. 추가 설정을 하지 않는 경우 AuthorizationFilter는 Security의 마지막 Filter이다.
다른 글들을 살펴보면 FilterSecurityInterceptor라는 용어가 등장하는데 공식 문서에 따르면 해당 클래스는 현재 Deprecated 되었음으로 "Use AuthorizationFilter instead" 즉, AuthorizationFilter를 대신 사용하라고 안내되어 있다.
chain.doFilter()를 통해 애플리케이션 뒷 부분을 실행한다. AuthorizationFilter는 시큐리티의 마지막 Filter임으로 뒤에서 확인하겠지만 ApplicationFilterChain의 Filter가 실행된다. 이때
chain.doFilter() 중단점을 지날 때 decision:
"AuthorizationDecision [granted=true]"라고 나와있는데 이것은 Security Filter Chain을 만들 때 "/api/test" URL 경로는 인가(Authorization)를 거치지 않는 즉, 권한(Authority)이 없어도 접근이 가능한 경로로 설정을 하였기 때문이다. 설정한 코드는 아래와 같다.
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/test").permitAll()
.anyRequest().denyAll());
앞서 이야기 했듯이 AuthorizationFilter는 Securtiy Filter의 마지막 Filter이다. 따라서 AuthorizationFilter에서 doFilter()를 호출하면 다음 Filter가 호출 되는데 필자의 애플리케이션에서는 ApplicationFilterChain에 속한 Filter임을 확인할 수 있다. 전에 호출되었던 ApplicationFilterChain 속의 Filter들과 마찬가지로 doFilter(), internalDoFilter() 함수를 호출함으로써 Filter Chain속 Filter들을 차례로 지난다.
어느덧 요청은 Handler안으로 진입하여 예외를 발생시켰다. 이후 과정부터 예외 흐름이 나옴으로 잘 따라오길 바란다. 발생된 예외는 Handler(Controller) 특별한 예외 처리를 하지 않았음으로 Controller밖으로 던져진다. 던져진 exception은 DispatcherServlet으로 전달되는데
DispatcherServlet이 해당 예외를 catch한다. catch한 예외를 null로 초기화 되어있던 dispatchException 변수에 넣는다. 이후 다음 중단점인 processDispatchResult() 함수를 실행하는데
해당 함수는 DispatcherServlet으로 전달된 예외가 존재할 경우 processHandler()함수 통해 전달된 예외를 처리할 HandlerExceptionResolver를 찾아 해당 예외를 처리하도록 하는 함수이다. 이때 반환 값은 mv 변수로 삽입되는데 ModelAndView가 아닌 exception이 그대로 반환될 경우 해당 exception을 해결할 HandlerExceptionResolver를 찾지 못했다는 의미로 예외가 처리되지 않고 상위 계층(최종적으로 Was)으로 전달된다.
스프링에서 구현한 ExceptionResolver 3가지가 존재하는데 processHandler() 메소드 안에서 확인이 가능하다. 앞서 설명한 대로 해당 exception을 3가지의 ExceptionResolver가 처리할 수 없는 경우 Exception을 그대로 반환한다고 되어 있는데
processHandlerException()함수의 마지막 중단점을 확인하면 처리가 되지 않은 DataNotFoundException이 반환되는 것을 확인할 수 있다. 만약, @ExceptionHandler를 사용하여 DataNotFoundException을 처리하는 메소드를 Controller내에 구현하였다면 ExceptionHandlerExceptionResolver에 의해 해당 예외는 처리가 되었을 것이다.
결론적으로 DispatcherServlet에서 처리하지 못한 DataNotFoundException은 상위 계층(Filter Chain)으로 thrown 되는데
처음 요청이 들어왔을 때 호출했던 Filter들을 역순으로 거치며 상위 계층으로 예외는 thrown된다.
해당 코드는 ApplicationFilterChain클래스의 internalDoFilter()메소드 인데 하위 계층에서 thrown되는 exception을 catch하여 다시 상위 계층으로 throw하는 코드이다. 상위 계층으로 계속 전달된 exception은 ExceptionTranslationFilter에 도달하는데
DataNotFoundException은 Security Exception이 아님으로 해당 Filter는 별다른 처리 없이 상위 계층으로 throw한다. 이렇게 최종적으로 Was에 도달한 DataNotFoundException은 처리 되지 않은 exception임으로 스프링은 /error 경로로 해당 exception을 처리하도록 BasicErrorController를 호출한다. 이때 별다른 설정을 하지 않아 첫 요청 때 거쳤던 Filter들을 동일하게 호출한다.
하지만 첫 요청과의 차이점은 dispatcherType이 ERROR로 변경되었다는 것이다. 이는 Was 에러를 처리하도록 내부적으로 요청을 다시 Controller에 보냈다는 것을 의미한다. 요청은 Filter Chain의 Filter들을 호출하면서 AuthorizationFilter에 도달한다.
이때 granted=false가 확인되면서 AccessDeniedException을 throw하는데 해당 이유는 아래와 같다.
2. 그런데 우리는 SecurityFilterChain에 /api/test URL과 마찬가지로 /error 경로에 인가를 적용하지 않는다는 설정(.permitAll())를 적용하지 않았다.
이러한 이유로 Was가 /error 경로로 호출한 요청에 대해 권한(Authority)을 확인했지만 아무런 권한이 확인 되지 않았음으로 AccessDeniedException을 throw하게 된 것이다. AuthorizationFilter에서 발생한 AccessDeniedException은 ExceptionTranslationFilter로 전달되고 해당 필터는 예외를 처리한다. 이때
익명 사용자인지 판단하는 isAnonymous()가 true를 반환함으로 인증을 요청하는 sendStartAuthentication()메소드가 실행된다. 필자는 Restful한 환경에서 해당 프로젝트를 개발하고 있음으로 Json 형태로 에러 메시지를 반환해야했고 이전 글처럼 AuthenticationEntryPoint의 구현체를 만들었다. 이러한 이유로
서버에 위와 같은 로그가 남았던 것이고
sendStartAuthentication()함수를 호출할 때 생성한 InsufficientAuthenticationException의 인자 값으로 넣은 "Full authentication is required to access this resource" message가 Json 형태로 반환이 되었던 것이다.
처음 해당 이슈를 접했을 때 구글링을 하여도 해결책을 찾지 못했는데 이유는 나에게 스프링의 예외처리 방식, 스프링 시큐리티 아키텍처 등의 사전 지식이 없었기 때문이다...😂
관련 내용들을 공부하면서 해결책은 역시나 구글에 존재하였다. 심지어 공식문서에 적혀져 있었다..
Security Filter Chain을 설정하는 코드에서 요청의 DispatcherType이 ERROR인 경우 인가(Authorization)를 적용하지 않는다는 설정 즉, 권한 검사를 하지 않는다는 코드를 추가하면 되었다. 코드는 아래와 같다.
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/api/login/**", "/api/register", "/api/oauth/token", "/api/test")
.permitAll()
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
//해당 부분에 Spring Security Authorization 적용 여부 endpoint 설정
.anyRequest().denyAll());
더 간단한 방법이 존재하는데 /error 경로는 인가를 적용하지 않는다는 것이다. 하지만 추후 기본 경로인 /error를 변경하는 경우도 존재함으로 나는 위와 같은 방법을 선택하였다.
이제 미처 처리하지 못한 exception이 발생하였을 경우 위와 같이 반환된다. 물론 해당 에러 메시지는 클라이언트 입장에서 좋지 못한 형태이다. 따라서 Controller에서 발생한 exception는 개발자가 모두 처리할 수 있도록 노력하자!!!!!
앞으로 해당 기술을 사용할 때 기본적인 내용은 확실히 학습을 하고 사용해야 한다고 느꼈다. 그렇지 않으면 어떠한 이슈가 발생하였을 경우 이유와 해결법을 알지 못해 고생한다는 것을 느낄 수 있었다. 또한 이번에 Spring Security를 공부하면서 공식문서를 많이 참고하였다. 많이가 아니라 공식문서로 거의 공부를 했던 것 같다. 공식문서로 공부하는 것을 습관화해야 할 것 같다. 제일 정확하고 많은 내용이 들어가 있기 때문이다. 사실상 모든 내용이 있다고 해도 과언이 아닌 것 같다...
좋은 글 정말 감사합니다. 잘 읽었습니다.
어떻게 해결해 나가는지 방법을 잘 몰랐는데, 공부방법까지 배워갈 수 있는 글이였습니다.
정말 감사합니다!