현재 전역 에러를 처리하는 Filter를 만들어서 사용하고 있는데 운영에서도 본인 몫을 잘 해내고 있어서 해당 경험을 공유해보려고 한다.
스프링 시큐리티를 많이 만지다보면 내 코드는 그렇게 치밀하지가 않아서 (특히 코틀린은 모든게 다 unchecked 예외다보니…) 한 번씩 예외가 터지게 된다.
null 예외는 예사고, IO 관련 객체들을 직접 건드리다보니 특히 더 예외 사항이 많은 것 같다.
해당 에러들은 인증, 인가 에러가 아니기 때문에 시큐리티에서 자동으로 처리해주지 않는다.
이런 코드들을 하나하나 대비해서 예외 처리를 하는게 베스트겠지만 ControllerAdvice처럼 전역으로 에러를 처리할 수 있는 클래스가 있으면 좋겠다고 생각했다.
스프링 예외 처리에 대한 기본 지식이 있는 분들은 바로 본론을 읽어도 무방하다.
사용 환경
- kotlin 1.9.*, Spring-Security 6.2.6
들어가기 앞서
ControllerAdvice를 사용할 수 없는 이유
ControllerAdvice는 컨트롤러가 들어가는 이름답게 DispatcherServlet을 거쳐야 사용할 수 있다.
필터는 DispatcherServlet이전에 실행되기 때문에 ControllerAdvice를 사용할 수 없다.
스프링은 처리되지 않은 예외에 대해서 보통 2가지 유형의 응답을 반환한다.
1. Html 페이지. 이른바 Whitelabel Error Page
- 이 페이지는 스프링 개발 초기에 자주 만나게 되는 페이지일 것이다.
2. JSON 형식의 응답
- 이 친구도 꽤 익숙한 에러로 포스트맨 사용시에 만날 수 있다
이 두 개는 사실상 같은 에러 메세지를 담고 있다.
두 응답은 요청 시 ContentType 에 따라 나뉜다.
스프링은 필터를 포함해서 처리되지 않은 에러가 터지면 WAS까지 흘러가 /error 의 엔드포인트로 forward 처리한다.
Default로 설정된 /error는 따로 설정을 하지 않는 이상 BasicErrorController가 응답을 만들어낸다.
BasicErrorController 내부 코드는 다음과 같다.
여기서 첫번째 메소드인 ModelAndView를 반환하면 1번이, 두번째 메소드인 ResponseEntity를 반환하면 2번이 응답되는 것이다.
(결국 필터에서 에러가 터져도 /error 처리되면서 DispatcherServlet을 타긴 탄다)
보통 필터를 지나 발생한 에러들은 ControllerAdvice에서 만든 응답을 반환하게 될테니 필터에서 터지는 에러는 따로 처리하지 않으면 내가 원하지 않은 응답을 발생시킨다.
이 문제를 해결하기 위해선 필터의 error가 was까지 흘러가지 않도록 어디선가 예외처리를 해줘야한다.
본론
구현은 사실 별거 없다.
바로바로
에러 처리용 필터를 하나 만들어 제일 첫번째에 등록해주면 된다.
필터는 책임 연쇄 패턴으로 되어있다.
스프링 시큐리티 필터체인인 FilterChainProxy의 가장 유명한 사진(현재 버전에서는 조금 달라졌다)을 보면 파란색 화살표로 내려갔다 올라가는게 보인다. 본질은 Stack 구조다. 가장 먼저 실행된 필터가 가장 나중에 끝난다.
따라서 전역 에러 처리 필터는 첫 번째에 등록한다.
1. 전역 에러 처리 필터 만들기
코드는 몇줄 없어도 된다. chain.doFilter()를 try catch문으로 한번 감싸주면 끝이다.
여기서 예외 사항 공통처리 부분에 작성한 코드가 에러 발생시 향하게 되는 지점이다.
나는 예외 발생시 로그를 찍고 봉투패턴 형식으로 응답을 내려주고 싶었다.
필터에서 응답을 내려주기 위해선 ServletResponse 객체를 HttpServletResponse 타입으로 캐스팅해 사용해야 한다.
필터 단에서 은근히 Response 응답을 직접 내려주는 경우가 많아 이부분은 따로 클래스로 구현했다.
class ExceptionWriter {
companion object {
val CHARACTER_ENCODING = "UTF-8"
val CONTENT_TYPE = "application/json; charset=utf-8"
private val objectMapper = ObjectMapper()
fun write(ex: Exception, response: HttpServletResponse) {
response.characterEncoding = CHARACTER_ENCODING
response.contentType = CONTENT_TYPE
response.status = 500
val res = ApiResponse.error(errorCode, ex.message)
objectMapper.writeValue(response.writer, res)
}
}
}
SecurityExceptionHandlerFilter의 최종 코드는 다음과 같다.
class SecurityExceptionHandlerFilter : GenericFilterBean() {
override fun doFilter(
servletRequest: ServletRequest,
servletResponse: ServletResponse,
filterChain: FilterChain
) {
try {
filterChain.doFilter(servletRequest, servletResponse)
} catch (ex: Exception) {
ExceptionWriter.write(ex, servletResponse as HttpServletResponse)
} finally {
RequestContextHolder.clearContext()
}
}
}
finally 에서는 혹시 모를 상황에 대비해 Custom TheardLocal 객체를 clear 해주었다.
2. 전역 에러 처리 필터 등록하기
전역 에러 처리 필터를 만들었으니 다음으로는 필터를 등록해줘야한다.
시큐리티 설정시 addFilter*() 로 필터를 추가 등록할 수 있다.
그중 addFilterBefore() 를 사용해 원래 첫번째 순서인 필터 앞에 해당 예외처리 필터를 등록한다.
스프링 시큐리티 필터순서는 여기서 확인할 수 있다.
시큐리티 설정에 따라 순서가 바뀔수도 있으니 정확한 순서는 FilterChainProxy 내부에서 디버깅 포인트를 걸어 확인하면 편하다. (더 쉽게는 시큐리티 로그를 확인하는 방법도 있다.)
오른쪽 filterChains 객체 내부에서 DisableEencodeUrlFilter가 첫번째인걸 알 수 있다.
따라서 시큐리티 설정의 DisableEncodeUrlFilter 앞에 전역예외 처리 필터를 등록한다.
설정 후 다시 필터 순서를 찍어보면 정상적으로 필터가 등록된 것을 확인할 수 있다.
코딩을 잘하면 어디까지나 할 일이 없는 클래스긴 하지만(…) 필터를 많이 튜닝해서 쓰는 사람이라면 보험상 있어도 무방한 것 같다.
필터에서 터지는 에러인지 Dispatcher 이후에 터지는 에러인지 알 수 있기 때문에 로그만 잘 찍어 놓으면 디버깅 할때도 더 편해진다.
우리 회사의 경우에는 AuthenticationEntryPoint, AccessDeniedHandler 를 설정하는 대신 전역 예외 처리를 한곳에 모아두고 처리하고 있다.
시큐리티를 사용할수록 정석에 가깝게 사용하기보다는(...) 야매가 많아지고 있는데 ㅋㅋ 너무 정석에만 집착하다 자유도가 떨어지는 부분이 분명히 존재해서 만들 수 있으면 만들어 사용하는것도 방법이라고 생각한다.
'Spring' 카테고리의 다른 글
Spring Cloud Eureka에서 AWS 외부(public) 서버 주소 동적으로 알아내기 (8) | 2024.09.02 |
---|