개발자는 코드를 작성할 때 언제나 런타임 시 발생하는 예외 상황에 대해 고려 해야한다. 코드가 항상 의도대로 동작하는 것은 아니기 때문이다. 예상하지 못한 상황들을 적절하게 핸들링 함으로써 원하는 대로 로직이 작동하지 않은 경우에도 어플리케이션을 올바르게 작동하게 한다거나, 아니면 예외를 의도적으로 발생시켜 여러가지 시나리오를 핸들링 하게 만들 수 있다.
1. RestControllerAdvice
예외 처리를 위해서는 기본적으로 RestControllerAdvice 어노테이션을 사용 할 수 있다. RestControllerAdvice 어노테이션은 Controller 계층의 예외를 전역적으로 처리하는 AOP 기술의 일종이다. Advice 라는 단어에서 프록시 객체를 생성하거나 바이트 코드 조작하는 AOP 를 떠올릴 수 있지만, 이와는 조금 다르게 작동한다.
서버에 Http 요청이 들어오면 요청은 여러 필터를 거쳐 가장 먼저 Dispatcher Servlet 에 진입하게 된다. 여기서 Dispatcher Servlet 은 각각의 요청을 핸들러(Controller)에 라우팅하는데, 예외 처리 또한 이 과정에서 수행된다. 핸들러에서 예외가 발생한 경우 Dispatcher Sevlet 은 등록되어있는 예외 처리기(ExceptionResolver) 를 뒤져 예외를 처리를 진행한다. 가장 먼저 Controller 내부에 있는 ExceptionHandler 를 확인하고, 그 다음으로 전역적으로 선언된 ControllerAdvice 에서 해당 예외를 처리할 수 있는지 확인하여 예외를 처리한다.
다시말해 Advice 는 빈으로 등록되어 DispatcherServlet 에서 예외처리를 위한 도구로 사용 되는 것이다. Advice 라는 이름 때문에 프록시라고 생각할 수 있으나 사실 프록시는 전혀 쓰이지 않는다.
현재 프로젝트에서는 현재 총 3개의 패턴에 대해서 예외를 핸들링 중이다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> CustomExceptionHandler(CustomException exception) {
return ErrorResponse.toResponseEntity(exception.getErrorCode());
}
@ExceptionHandler(BindException.class)
public ResponseEntity<ValidationResponse> ValidationExceptionHandler(BindException e) {
return ValidationResponse.toResponseEntity(e);
}
@ExceptionHandler(TokenException.class)
public ResponseEntity<TokenErrorResponse> TokenExceptionHandler(TokenException exception) {
return TokenErrorResponse.toHttpResponseEntity(exception.getErrorCode(), exception.getReissuedAccessToken());
}
}
모든 요청은 Dispatcher Servlet 을 거치기 때문에 ControllerAdvice 어노테이션으로 예외 로직을 구현해 놓으면 모든 요청에 대한 예외 처리가 가능해진다. 하지만 문제가 있는데, Dispatcher Sevlet 을 거치기 이전에 발생하는 예외에 대해서는 예외 핸들링이 불가하다는 점이다. 요청은 Dispatcher Sevlet 진입 이전에도 여러 필터를 거치는데, 이 과정에서 예외가 발생하는 경우가 있다.
본 프로젝트에서는 보안에 Spring Security 프레임 워크를 적용하였는데, Spring Security 의 보안로직은 위에서 말한 Filter 레벨에서 동작한다. 때문에 이 과정에서 예외가 발생하게 되면 RestControllerAdvice 로는 예외 핸들링이 되지 않는다. 다른 방식의 예외 처리가 필요했다.
2. Filter 에서의 예외처리
1) try - catch
사실 프레임워크, 라이브러리들의 어노테이션을 활용하는 것에 익숙해져버려서 잠시 잊고 있었는데, 예외의 처리의 가장 기본적인 방법은 try ~ catch 문이다. 예외가 예상되는 코드를 try 문으로 감싸고 catch 를 통해 어떻게 핸들링 할지를 결정해주면 예외를 직접 핸들링 할 수 잇따. 이는 모든 예외처리의 근본적인 동작방식이다. 나는 그래서 Filter 레벨에서 직접 이걸 만들어 주기로 했다.
예외가 예상 되는 클래스는 Jwt 를 검증하고, 유저객체를 만들어주는 클래스였다.
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
private final JwtAuthenticator jwtAuthenticator;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtAuthenticator jwtAuthenticator) {
super(authenticationManager);
this.jwtAuthenticator = jwtAuthenticator;
}
/**
* JwtAuthenticationFilter
* jwt를 검증하고 authenticate 해주는 필터
* jwtAuthenticator 에서 jwt 가 유효함이 검증되면 authentication 을 생성해주며
* jwt 가 없거나 유효하지 않으면 아무 작업을 수행하지 않고 그냥 반환
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
jwtAuthenticator.authenticateRequest(request);
chain.doFilter(request, response);
}
(이전에 파사드 패턴을 적용해서 깔끔하게 정리된 모습이다. )
여기서 예외가 발생할만한 부분은
jwtAuthenticator.authenticateRequest(request);
이 부분 밖에 없는데 이를 아래 처럼 try ~ catch 문으로 감싸주면 된다.
try {
jwtAuthenticator.authenticateRequest(request);
} catch(Exception e) {
// 예외 핸들링
}
그러나 이와 같은 방식은 단순히 해당 메서드에서 발생하는 예외만 캐치하기 때문에 다른 필터에서 전파되는 예외는 핸들링 하지 못한다. 또한 예외를 처리하는 로직과 Jwt 검증 로직이 한군데에 있게 되므로 단일 책임 원칙에 위배 되기도 한다.
그러나 예외 처리의 경우에는 메서드를 추출 할 수도 없고, 템플릿 콜백 패턴을 적용하는 것도 영 보기 좋은 설계는 아니라는 판단이 들었다. 그래서 다음과 같은 방식을 생각했다.
2) Exception Handling Filter
아래와 같이 예외 처리를 위한 Filter 를 생성하여 JwtAuthentication 필터 바로 이전에 추가하여 필터 체인을 진행시키는 코드를 try catch 문으로 감싸는 것이다. 이렇게 되면 직접 예외를 핸들링 할수 있으면서도 책임의 분리가 가능해진다.
@Component
@RequiredArgsConstructor
public class ExceptionHandlingFilter implements Filter {
private final ObjectMapper objectMapper;
/**
* Security filter 내부의 Exception 을 핸들링 해주기 위한 필터입니다.
* Filter 또는 interceptor 내부에서 던져진 예외의 경우
* RestControllerAdvice 로 지정된 Exception handler 에 도달하지 않아서
* 아래와 같이 직접 핸들링 하였습니다.
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
chain.doFilter(request, response);
} catch (CustomException customException) {
handleException (httpResponse, FilterErrorResponse.toResponseEntity(customException.getErrorCode()));
} catch (TokenException tokenException) {
handleException(httpResponse, TokenErrorResponse.toFilterResponseEntity(tokenException.getErrorCode(), tokenException.getReissuedAccessToken()));
} catch (ServletException e) {
throw new RuntimeException(e);
}
}
private void handleException(HttpServletResponse response, Object o) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().write(objectMapper.writeValueAsString(o));
}
}
해당 필터는 아래와 같이 Security Configuration 클래스에서 추가해주었다.
.and()
.addFilter(corsConfig.corsFilter())
.addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtAuthenticator))
.addFilterBefore(exceptionHandlingFilter, JwtAuthenticationFilter.class)
3) 프레임워크를 이용한 예외 처리 핸들러 적용
사실 Spring Security 프레임 워크는 아래와 같이 자체적으로 예외처리를 위한 핸들러를 구성할 수 있도록 되어있다. 그 중 현재 프로젝트에는 두 가지를 적용하고 있다.
.exceptionHandling()
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
필터의 이름에서 각각의 역할을 유추할 수 있는데, authenticationEntryPoint 는 요청이 Security Filter 를 거치고 나서도 Authentication 객체를 생성하지 못한 경우 Authentication 을 위해 진입하는 엔트리 포인트이고, accessDeniedHandler 는 Authentication 객체가 생성되었으나, 요청 대상에서 접근이 거부된 경우(권한 없음) 이를 핸들링 해주는 클래스이다.
CustomAuthenticationEntryPoint 클래스
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final JwtCookieHandler jwtCookieHandler;
private final AccessTokenReissuer accessTokenReissuer;
/**
* Authentication 에 실패한 경우 진입하는 EntryPoint 입니다.
* 다른 Authentication 을 진행하지 않고 에러메세지를 반환하기 위해
* Custom 한 Exception 을 throw 하였습니다.
*/
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null && jwtCookieHandler.hasRefreshTokenCookie(request)) {
jwtCookieHandler.deleteAccessTokenCookie(request, response);
String reissuedAccessToken = accessTokenReissuer.reissueAccessToken(request)
.orElseThrow(() -> new CustomException(ErrorCode.REISSUE_TOKEN_FAILURE));
throw new TokenException(ErrorCode.ACCESS_TOKEN_REISSUE, reissuedAccessToken);
}
if(authentication == null) {
throw new CustomException(ErrorCode.NOT_LOGGED_IN);
}
}
}
AccessDeniedHandlerImpl 클래스
@Component
@RequiredArgsConstructor
@Slf4j
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
/**
* 인증은 되었으나 권한이 없는 요청에 대해 처리합니다.
*/
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null && isTemporalUser(authentication)) {
throw new CustomException(ErrorCode.SIGNUP_INCOMPLETE);
}
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
private boolean isTemporalUser(Authentication authentication) {
return ((PrincipalDetails) authentication.getPrincipal()).getRole().equals(Role.TEMPORAL);
}
}
두 클래스 모두 각각의 상황에 맞는 예외를 던지기 위한 위한 클래스로 사용중이다. 다시 말해, 예외를 발생시켜줌으로써 특정 시나리오를 구성하는 것이다. 예외 처리는 모두 Exception Handling Fliter 에서 진행된다. CustomAuthenticationEntryPoint 는 리프레시 토큰이 있는 경우 엑세스 토큰을 재발급 해주는 예외를 던지고, AccessDeniedHandler 는 각각의 Role 에 맞는 예외를 던져서 프론트 단에서 적절한 처리가 진행될 수 있도록 한다.
이러한 상황들은 어떻게 보면 예외라기 보다는 정상적인 어플리케이션의 작동 과정이다. 예외 발생 클래스에 따라 예외 상황들을 정리해보면
- JwtAuthenticationFilter : 토큰의 복호화가 실패, 유저정보를 없음 등 이례적인 상황
- CustomAuthenticationEntryPoint : AccessToken 이 만료되거나 누락되어 재발급이 필요한 경우
- AccessDeniedHandlerImpl : 권한 외의 요청
위와 같은 상황에서 예외를 발생시킨다.
3. 결론
위와 같이 예외의 처리는 단순히 예상되지 않은 오류를 핸들링 하는 것 뿐만 아니라, 의도적으로 특정 예외를 발생시켜 어플리케이션의 흐름을 컨트롤 하는 데에도 쓸 수 있다. 이렇게 예외 핸들링을 하면서 느낀 점은 아무리 프레임 워크에 의존해 개발을 진행한다고 하더라도, 프레임 워크는 꽤나 추상화 되어있으며 개발자가 이를 활용할 수 있도록 자율성을 많이 보장해주고 있다는 것이었다. 내가 이 포스팅에서 소개한 방법 외에도 필터를 더 추가한다든지, 아니면 프레임 워크가 특정 의도대로 만들어둔 설정을 개발자의 입맛에 맞게 수정해서 사용하는 등 다른 예외처리 방법을 고안할 수 있을 것 같다.
이렇게 구현한 후 위 예외 처리 로직을 검증하지 못했는데 (테스트 코드 작성 실력이 딸려서) 오늘에서야 잘 작동하는지 테스트 코드를 작성 했다. 사실 어제 TIL 에 테스트 코드의 중요성에 대해서 느꼈다는 내용을 적었는데 자꾸만 의도되지 않은 500 에러가 뜨고 수정을 해야할 일이 생겼기 때문이었다. 예외 처리를 보다 촘촘하고 잘하려면 "어떻게 인증을 해야할까?" 라는 접근 방법보다는 "어떻게 인증이 실패되어야 하는가?" 를 먼저 염두에 두고 테스트 코드를 작성해야할 것 같다.
테스트 주도 개발이라는 것에 조금 더 공감을 하게된 계기였다.