not-a-gardener 개발기 6) 예외처리: Filter, Controller, Custom Exception

메밀·2023년 6월 19일
0

not-a-gardener

목록 보기
6/13
post-thumbnail

1. 예외처리

크게 세 가지로 나눠볼 수 있다.

종류해결 방법
표준 예외를 사용한 예외처리@RestControllerAdvice
커스텀 예외를 사용한 예외처리@RestControllerAdvice, Custom Exception
필터의 예외처리JwtExceptionFilter

2. 구현 코드

1) 공통

— ErrorResponse

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 클래스에 모아 관리한다.

— ExceptionCode

@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));
    }
}

Enum 멤버 변수로 Enum 객체 찾기

가장 하단의 static 메소드는 커스텀 에러 코드를 통해 ENUM 인스턴스를 반환한다.

1) private static final Map<String, String> CODE_MAP 변수를 사용해 앱 구동 시 {key: code, value: name} 모양의 map을 만들어둔다.
2) of(String code) 메소드를 통해 실제 Enum 객체를 반환한다.

에러 코드 사용 이유

같은 Exception이 발생하더라도 그 발생 이유와 프론트로 넘겨줄 정보는 다르기 마련이다.
각기 다른 상황에 대응하는 Custom Exception을 작성하는 것은 비효율적인 일이므로, 이미 구현된 표준 예외에 에러 코드를 담아 오류 정보를 전달한다.

2) 스프링에서의 예외처리

— 예외 발생 상황: UsernameNotFoundException의 예시

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

// 여러 컨트롤러에 대해 전역적으로 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를 사용하였다.


예외처리를 위한 어노테이션 정리

@ControllerAdvice

클래스에 선언. 모든 @Controller에 대한, 전역적으로 발생할 수 있는 예외를 잡아서 처리할 수 있다.

@RestControllerAdvice

@ControllerAdvice와 @ResponseBody를 합쳐놓은 어노테이션이다. @ControllerAdvice와 동일한 역할을 수행하고, 추가적으로 @ResponseBody를 통해 객체를 리턴할 수도 있다.

따라서 단순히 예외만 처리하고 싶다면 @ControllerAdvice를 적용하면 되고, 응답으로 객체를 리턴해야 한다면 @RestControllerAdvice를 적용하면 된다.

@ExceptionHandler

위에서 @ControllerAdvice에 대해서 이야기할 때 언급한 어노테이션이다. 이 어노테이션을 메서드에 선언하고 특정 예외 클래스를 지정해주면 해당 예외가 발생했을 때 메서드에 정의한 로직으로 처리할 수 있다. @ControllerAdvice 또는 @RestControllerAdvice에 정의된 메서드가 아닌 일반 컨트롤러 단에 존재하는 메서드에 선언할 경우, 해당 Controller에만 적용된다.


그러나 표준 예외만으로는 처리할 수 없는 예외가 있다.
나의 경우엔 이미 오늘 물을 준 식물에 대해 오늘 또 물을 주려는 경우가 이에 해당한다.

— Custom Exception

AlreadyWateredException

@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 클래스에 별다른 코드를 추가하지 않았다.

3) 필터에서의 예외처리

그럼에도 남은 문제는 필터에서의 예외처리다.

@ControllerAdvice 어노테이션은 요청이 DispatcherServlet에 의해 처리될 때 동작한다.
이 프로젝트는 Spring Security가 인증 과정을 담당하고, Filter는 DispatcherServelet보다 먼저 동작하므로 필터에서의 예외처리가 필요하다.

— JwtExceptionFilter

예외처리용 필터

@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

예외처리 필터를 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();
    }

참고

RestControllerAdvice
Enum 멤버 변수로 Enum 찾기

0개의 댓글