목표:
BE와 FE 간의 협업에서 서비스 지속성과 로직 보호를 위해 모든 API 응답을 일관된 JSON 포맷(예: ApiResponse<ErrorData>
)으로 통일하고자 함.
문제 발생 배경:
컨트롤러에서 발생하는 예외는 @RestControllerAdvice
의 GlobalExceptionHandler가 잡아 일관된 응답을 제공하지만, Spring Security 필터(JwtAuthorizationFilter) 단계에서 발생하는 예외는 GlobalExceptionHandler의 대상이 아니므로 기본 에러 처리 로직에 의해 처리됩니다.
/error
경로를 통한 HTML 혹은 기본 JSON 구조)을 받게 되어, 전체 응답 포맷이 통일되지 않습니다.필터와 컨트롤러 처리 차이:
컨트롤러 단계:
예외가 컨트롤러에서 발생하면 DispatcherServlet이 이를 GlobalExceptionHandler로 전달해 ApiResponse<ErrorData>
형태의 일관된 응답을 생성합니다.
필터 단계:
JwtAuthorizationFilter와 같은 서블릿 필터에서 발생한 예외는 DispatcherServlet에 도달하지 않고, 기본 에러 핸들러(BasicErrorController 등)가 처리하게 됩니다.
결과:
FE는 필터에서 발생하는 예외에 대해 예상하지 않은 응답 구조를 수신하게 되어 클라이언트 로직에 혼선이 발생할 수 있습니다.
필터 내 예외 직접 처리:
JwtAuthorizationFilter의 doFilterInternal
메서드 내에서 try-catch 구문을 사용해 발생하는 CustomException을 직접 잡고, ObjectMapper를 이용해 ApiResponse<ErrorData>
형태의 JSON 응답을 클라이언트에 전송합니다.
통일된 응답 생성:
이렇게 하면, 컨트롤러와 필터 모두에서 동일한 API 응답 포맷을 유지할 수 있으므로 FE에서 에러 처리가 일관되게 동작합니다.
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
try {
String tokenValue = jwtUtil.getTokenFromRequest(req);
log.info("Extracted Token: {}", tokenValue);
if (StringUtils.hasText(tokenValue)) {
Optional<UserToken> tokenEntityOpt = tokenRepository.findByAccessToken(JwtUtil.BEARER_PREFIX + tokenValue);
if (tokenEntityOpt.isPresent() && tokenEntityOpt.get().isBlacklisted()) {
log.error("Token is blacklisted");
throw new CustomException(ErrorCode.AUTH006, "로그아웃된 토큰");
}
if (!jwtUtil.validateToken(tokenValue)) {
throw new CustomException(ErrorCode.AUTH005, "Token validation failed");
}
Claims info = jwtUtil.getUserInfoFromToken(tokenValue);
log.info("Claims: {}", info);
setAuthentication(info.getSubject());
}
filterChain.doFilter(req, res);
} catch (CustomException e) {
// 필터 단계에서 발생한 CustomException을 잡아서 일관된 JSON 응답 생성
res.setContentType("application/json;charset=UTF-8");
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 필요에 따라 다른 상태 코드 사용
ErrorData errorData = new ErrorData(
e.getErrorCode().getCode(),
e.getErrorCode().getMessage(),
e.getDetails()
);
ApiResponse<ErrorData> errorResponse = new ApiResponse<>("fail", errorData);
ObjectMapper mapper = new ObjectMapper();
String jsonResponse = mapper.writeValueAsString(errorResponse);
res.getWriter().write(jsonResponse);
res.getWriter().flush();
return;
}
}
서비스 안정성:
필터 단계에서 발생하는 예외도 컨트롤러 예외와 동일한 ApiResponse<ErrorData>
구조로 반환함으로써 FE와의 협업에서 응답 형식의 일관성을 보장할 수 있습니다.
로직 보호:
에러 발생 시, 예상치 못한 기본 스프링 부트 에러 페이지 대신 사전에 정의한 포맷으로 에러를 전달하여, 클라이언트에서 로직 변경 없이 일관된 처리가 가능하게 됩니다.
추가 검증:
배포 전 다양한 에러 시나리오(예: 로그아웃된 토큰, 만료된 토큰, 형식 오류 등)를 충분히 테스트하여 모든 경우에 대해 일관된 응답이 생성되는지 확인이 필요합니다.