응답 형식을 통일
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로 응답을 직렬화 해서 전달 하면 다음과 같은 형태의 응답이 올 것임을 예상할 수 있습니다
@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를 통해 이제 예외도 같은 응답 형식으로 받을 수 있습니다
스프링 시큐리티 필터 내에서 발생하는 예외도 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);
}
}
어? 예상한 응답 형식이 아닙니다
무엇이 문제일까요?
예외 처리가 제대로 되지 않은 이유
이 이유를 알기 위해 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();
}
}
이제 다시 같은 예외를 발생시키면 원하던 결과를 얻을 수 있습니다
'프로젝트' 카테고리의 다른 글
Nginx를 이용하여 HTTP를 HTTPS로 리다이렉트하는 방법 (0) | 2024.09.29 |
---|---|
GitLab, Jenkins, Docker, S3, EC2를 활용한 CI/CD 파이프라인 구축 - (1) Webhook 설정하기 (0) | 2024.09.15 |
Spring 프로젝트에서 일관된 API 응답 구조를 위한 BaseResponse 설계 및 구현 (0) | 2024.08.18 |