크게 세 가지로 나눠볼 수 있다.
종류 | 해결 방법 |
---|---|
표준 예외를 사용한 예외처리 | @RestControllerAdvice |
커스텀 예외를 사용한 예외처리 | @RestControllerAdvice, Custom Exception |
필터의 예외처리 | JwtExceptionFilter |
Http 상태 코드만으로는 원하는 정보를 모두 담을 수 없어서 ErrorResponse 클래스를 작성했다.
@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class ErrorResponse {
private String code;
private String title;
private String message;
public static ErrorResponse from(ExceptionCode code){
return new ErrorResponse(code.getCode(), code.getTitle(), code.getMessage());
}
}
이 클래스에 담을 code, title, message 등의 정보는 아래 ENUM 클래스에 모아 관리한다.
@Getter
public enum ExceptionCode {
ACCESS_TOKEN_EXPIRED("B001", "액세스 토큰 만료", "액세스 토큰 만료"),
REFRESH_TOKEN_EXPIRED("B002", "리프레쉬 토큰 만료", "로그인 시간이 만료되었습니다"),
WRONG_ACCOUNT("B003", "아이디/비밀번호 오류", "아이디 또는 비밀번호를 다시 확인해주세요."),
NO_ACCOUNT("B004", "계정 정보 없음", "해당 유저를 찾을 수 없어요"),
ALREADY_WATERED("B005", "오늘 이미 물 줌", "이미 오늘 물을 줬어요"),
NO_SUCH_ITEM("B006", "해당 아이템 없음", "해당 아이템을 찾을 수 없어요"),
NO_ACCOUNT_FOR_EMAIL("B007", "해당 이메일의 가입 계정 없음", "해당 이메일로 가입한 회원이 없어요"),
WRONG_PASSWORD("B008", "비밀번호 오류", "비밀번호를 확인해주세요"),
NO_TOKEN_IN_REDIS("B009", "레디스에 사용자 없음", "로그인 정보가 없습니다");
private String code;
private String title;
private String message;
private static final Map<String, String> CODE_MAP = Collections.unmodifiableMap(
Stream.of(values()).collect(Collectors.toMap(ExceptionCode::getCode, ExceptionCode::name))
);
ExceptionCode(String code, String title, String message){
this.code = code;
this.title = title;
this.message = message;
}
public static ExceptionCode of(String code){
return ExceptionCode.valueOf(CODE_MAP.get(code));
}
}
가장 하단의 static 메소드는 커스텀 에러 코드를 통해 ENUM 인스턴스를 반환한다.
1) private static final Map<String, String> CODE_MAP
변수를 사용해 앱 구동 시 {key: code, value: name} 모양의 map을 만들어둔다.
2) of(String code)
메소드를 통해 실제 Enum 객체를 반환한다.
같은 Exception이 발생하더라도 그 발생 이유와 프론트로 넘겨줄 정보는 다르기 마련이다.
각기 다른 상황에 대응하는 Custom Exception을 작성하는 것은 비효율적인 일이므로, 이미 구현된 표준 예외에 에러 코드를 담아 오류 정보를 전달한다.
UsernameNotFoundException이 발생하는 조건은 다음과 같다.
발생 경우 | 커스텀 에러 코드 |
---|---|
로그인 시 해당 username 없음 | ExceptionCode.NO_ACCOUNT |
계정 찾기 시 해당 이메일로 가입한 계정 정보 없음 | ExceptionCode.NO_ACCOUNT_FOR_EMAIL |
gardenerRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException(ExceptionCode.NO_ACCOUNT.getCode()));
throw new UsernameNotFoundException(ExceptionCode.NO_ACCOUNT_FOR_EMAIL.getCode());
BadCredentialsException(String msg)
생성자의 인수로 Enum 클래스의 code를 넘겨 예외를 던진다.
이렇게 던진 예외는 아래 ExceptionHandler로 처리한다.
// 여러 컨트롤러에 대해 전역적으로 ExceptionHandler 적용
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/* 생략 */
/**
* 해당 계정 없음
* @param e UsernameNotFoundException
* @return
*/
@ExceptionHandler(UsernameNotFoundException.class)
public HttpEntity<ErrorResponse> handleUsernameNotFoundException(UsernameNotFoundException e){
// UsernameNotFoundException의 인자로 넘긴 에러코드를 사용하여,
// 실제 Enum 객체를 찾는다.
ExceptionCode exceptionCode = ExceptionCode.of(e.getMessage());
// 위에서 찾은 Enum 객체를 응답용 객체로 변환해 response에 담아 보낸다.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ErrorResponse.from(exceptionCode));
}
/* 생략 */
}
예외를 처리하고 Response로 ErrorResponse 객체를 리턴해주기 위해 @RestControllerAdvice를 사용하였다.
클래스에 선언. 모든 @Controller에 대한, 전역적으로 발생할 수 있는 예외를 잡아서 처리할 수 있다.
@ControllerAdvice와 @ResponseBody를 합쳐놓은 어노테이션이다. @ControllerAdvice와 동일한 역할을 수행하고, 추가적으로 @ResponseBody를 통해 객체를 리턴할 수도 있다.
따라서 단순히 예외만 처리하고 싶다면 @ControllerAdvice를 적용하면 되고, 응답으로 객체를 리턴해야 한다면 @RestControllerAdvice를 적용하면 된다.
위에서 @ControllerAdvice에 대해서 이야기할 때 언급한 어노테이션이다. 이 어노테이션을 메서드에 선언하고 특정 예외 클래스를 지정해주면 해당 예외가 발생했을 때 메서드에 정의한 로직으로 처리할 수 있다. @ControllerAdvice 또는 @RestControllerAdvice에 정의된 메서드가 아닌 일반 컨트롤러 단에 존재하는 메서드에 선언할 경우, 해당 Controller에만 적용된다.
그러나 표준 예외만으로는 처리할 수 없는 예외가 있다.
나의 경우엔 이미 오늘 물을 준 식물에 대해 오늘 또 물을 주려는 경우가 이에 해당한다.
@NoArgsConstructor
public class AlreadyWateredException extends RuntimeException {
}
// ExceptionHandler
/**
* 이미 오늘 물 줬는데 또 물 주기 누름
* @param e AlreadyWateredException(Custom)
* @return
*/
@ExceptionHandler(AlreadyWateredException.class)
public HttpEntity<ErrorResponse> handleAlreadyWateredException(AlreadyWateredException e){
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ErrorResponse.from(ExceptionCode.ALREADY_WATERED));
}
AlreadyWateredException의 경우 Response로 보낼 데이터(에러 코드 등)가 하나라 Exception 클래스에 별다른 코드를 추가하지 않았다.
그럼에도 남은 문제는 필터에서의 예외처리다.
@ControllerAdvice 어노테이션은 요청이 DispatcherServlet에 의해 처리될 때 동작한다.
이 프로젝트는 Spring Security가 인증 과정을 담당하고, Filter는 DispatcherServelet보다 먼저 동작하므로 필터에서의 예외처리가 필요하다.
예외처리용 필터
@Component
@Slf4j
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response); // go to 'JwtAuthenticationFilter'
} catch (JwtException e) {
setErrorResponse(HttpStatus.UNAUTHORIZED, response, e);
}
}
public void setErrorResponse(HttpStatus status, HttpServletResponse response, Throwable e) throws IOException {
response.setStatus(status.value());
response.setContentType("application/json; charset=UTF-8");
ObjectMapper mapper = new ObjectMapper();
response.getWriter().write(mapper.writeValueAsString(ErrorResponse.from(ExceptionCode.ACCESS_TOKEN_EXPIRED)));
}
}
ObjectMapper로 Response에 ErrorResponse 객체를 담아 보낸다.
예외처리 필터를 SecurityConfig에 등록한다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic().disable() // 로그인 인증창 해제
.csrf().disable() // REST Api이므로 csrf disable
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT 토큰 인증이므로 세션은 stateless
.and()
.authorizeRequests() // 리퀘스트 설정
.requestMatchers(CorsUtils::isPreFlightRequest).permitAll() // Preflight 요청 허용
.antMatchers("/login", "/token", "/oauth", "/register").permitAll() // 누구나 접근가능
.antMatchers("/garden/**").authenticated() // 인증 권한 필요
.and()
.addFilter(this.corsFilter()) // CORS 필터 등록
// 기본 인증 필터인 UsernamePasswordAuthenticationFilter 대신 Custom 필터 등록
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
// ⭐️⭐️⭐️⭐️⭐️
.addFilterBefore(jwtExceptionFilter, JwtFilter.class)
// OAuth2 로그인 설정
.oauth2Login().loginPage("/")
// 성공 시 수행할 핸들러
.successHandler(successHandler)
// OAuth2 로그인 성공 이후 설정
.userInfoEndpoint().userService(oAuth2MemberService);
return httpSecurity.build();
}
참고