본문 바로가기

프로젝트

Spring Security에서 Exception Handler(예외 처리) 사용 하기

응답 형식을 통일

 

BaseResponse와 같이 응답을 통일하면 다음과 같은 장점이 있습니다

  • 모든 API 응답이 동일하므로 프론트엔드에서 처리하기 용이
  • 응답을 한 곳에서 관리할 수 있으므로 유지 보수에 용이
  • 일관된 응답을 제공하므로 테스트 작성에 용이

 

@Getter
@Setter
public class BaseResponse<T> {
  private HttpStatus status;
  private String message;
  private T data;

  @Builder
  public BaseResponse(HttpStatus status, String message, T data) {
    this.status = status;
    this.message = message;
    this.data = data;
  }
}

 

이 BaseResponse로 응답을 직렬화 해서 전달 하면 다음과 같은 형태의 응답이 올 것임을 예상할 수 있습니다

 

{
    "status": "CREATED",
    "message": "회원가입에 성공했습니다!",
    "data": {
        "id": "kim1234"
    }
}
 
 
예외도 더 명확한 의미와 처리의 용이성을 위해 같은 방식으로 응답을 보내봅시다
 
 
@Getter
@RequiredArgsConstructor
public class BaseException extends RuntimeException {
  private final BaseFailureResponse baseResponseStatus;
}

 

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(BaseException.class)
  protected ResponseEntity<BaseResponse<?>> handleBaseException(BaseException ex) {
    BaseResponse<?> baseResponse = BaseResponse.builder()
        .status(ex.getBaseResponseStatus().getStatus())
        .message(ex.getBaseResponseStatus().getMessage())
        .data(null)
        .build();
    return new ResponseEntity<>(baseResponse, ex.getBaseResponseStatus().getStatus());
  }
}

 

BaseException을 만들고 이 예외를 처리할 ExceptionHandler를 통해 이제 예외도 같은 응답 형식으로 받을 수 있습니다

 

{
    "status": "CONFLICT",
    "message": "중복된 아이디입니다!",
    "data": null
}

 

스프링 시큐리티 필터 내에서 발생하는 예외도 BaseException을 활용해 같은 형식의 응답이 반환되도록 해봅시다

 

public class LoginFilter extends UsernamePasswordAuthenticationFilter {

// 생략

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        throw new BaseException(BaseFailureResponse.LOGIN_FAIL);
    }
}

 

{
    "timestamp": "2024-08-04T01:05:30.070+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/api/login"
}

 

어? 예상한 응답 형식이 아닙니다

무엇이 문제일까요?

 

예외 처리가 제대로 되지 않은 이유

 

이 이유를 알기 위해 Spring MVC Request Life Cycle을 통해 요청에서 응답까지의 과정을 살펴봅시다

 

 

@RestControllerAdvice를 사용해서 만든 ExceptionHandler는 Handler Exception Resolver에서 작동을 하게 됩니다

하지만 스프링 시큐리티의 각종 인증 과정은 Filter에서 작동을 하고 필터는 서블릿 필터 체인의 일부로서 스프링 MVC의 요청 처리와는 별도로 작동합니다 즉, 필터에서 발생한 예외는 Handler Exception Resolver에 도달하지 못하고 필터에서 바로 응답으로 반환이 됩니다

추가로 ModelAndView, ResponseEntity와 같은 반환 타입도 Controller에서 Handler Adaptor와의 상호작용에서 사용되기에 Filter에서는 사용할 수 없습니다

 

따라서 각 필터마다 그 필터에서 발생하는 예외에 따라 적절한 response를 반환해야합니다

하지만 이는 유지보수가 힘들기에 ExceptionHandler와 같은 역할을 하는 Filter를 만들어서 예외를 한 곳에서 처리하는 방법을 사용하겠습니다

 

Exception Handler Filter

 

public class SecurityExceptionHandlerFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (Exception exception) {
            if (exception instanceof BaseException baseException) {
                handleBaseException(response, baseException);
            } else {
                handleException(response, exception);
            }
        }
    }

// BaseException을 처리할 메서드
    private void handleBaseException(HttpServletResponse response, BaseException baseException) throws IOException {
        response.setStatus(baseException.getBaseResponseStatus().getStatus().value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        BaseResponse baseResponse = BaseResponse.builder()
                .status(baseException.getBaseResponseStatus().getStatus())
                .message(baseException.getBaseResponseStatus().getMessage())
                .build();
        try (var writer = response.getWriter()) {
            new ObjectMapper().writeValue(writer, baseResponse);
        }
    }

// 그 외 예외를 처리할 메서드
    private void handleException(HttpServletResponse response, Exception exception) throws IOException {
        response.setStatus(BaseFailureResponse.INTERNAL_SERVER_ERROR.getStatus().value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        BaseResponse baseResponse = BaseResponse.builder()
                .status(BaseFailureResponse.INTERNAL_SERVER_ERROR.getStatus())
                .message(BaseFailureResponse.INTERNAL_SERVER_ERROR.getMessage())
                .build();
        try (var writer = response.getWriter()) {
            new ObjectMapper().writeValue(writer, baseResponse);
        }
    }
}

 

이제 필터를 만들었으니 필터 체인의 적절한 위치에 이 필터를 넣어줍시다

 

 

필터 체인에서 중간에 예외가 발생한다면 바로 응답을 보내게 됩니다

JWT Filter에서 예외가 발생했다고 가정하면 요청은 Filter 1 -> Filter 2 -> JWT Filter(예외 발생) -> Filter 2 -> Filter 1 순서로 응답을 보냅니다

따라서 JWT Filter와 LoginFilter의 예외를 처리하기 위해서 그 앞에 필터를 넣어주면 됩니다

 

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
        // 생략
                .addFilterBefore(new SecurityExceptionHandlerFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new JWTFilter(), UsernamePasswordAuthenticationFilter.class)
                .addFilterAt(new LoginFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

 

이제 다시 같은 예외를 발생시키면 원하던 결과를 얻을 수 있습니다

{
    "status": "UNAUTHORIZED",
    "message": "아이디 혹은 비밀번호가 틀렸습니다!",
    "data": null
}