Spring Boot 예외 처리

PEPPERMINT100·2021년 2월 16일
7

서론

개발을 하면서 다양한 예외처리를 하게 된다. 기본적으로는 if, else 등으로 분기를 나누어 아웃풋을 다르게 하는 경우가 있지만 대부분의 경우(특히 범용적으로 사용될 수 있도록 만들어진 라이브러리, 프레임워크) 에서는 exception을 통해 예외처리를 하게 된다.

어플리케이션 서버를 개발할 때에는 이러한 예외처리들의 결과가 정해진 형식의 응답을 따르도록 해야한다. 예를 들어서 만약 사용자의 로그인 정보를 입력 받는 컨트롤러가 있다고 해보자.

@PostMapping("/login")
public ResponseEntity<ResponseType> login(LoginRequest loginRequest){
	// 데이터1을 처리...
    
    // 데이터1에 대한 예외처리...
    
    // 데이터2를 처리...
    
    // 데이터2에 대한 예외처리...
    
    // 데이터3을 처리(또 다른 third-party 라이브러리 사용)
    
    // 데이터3이 사용한 third-party 라이브러리에 대한 예외처리...
}

이런식으로 컨트롤러의 함수가 작성되고 모든 데이터에 대한 예외처리가 하나의 EndpointResponseEntitiy의 형태가 될 것이다.

어떻게 하면 좋을지 구글링을 해보며 종합해본 예외처리의 Best Practice에 대해 간단히 적어볼까 한다.

이 글은 정답이 아니며 예외 처리를 하는 방법은 아주 다양하지만 저 처럼 일정 수준의 Best Practice를 고집하는 사람들에게 조금이라도 참고가 되길 바라며 작성하는 글입니다. 이 후 코드들은 복사 붙여넣기 한다고 작동하는 것이 아닌 concept에 대한 내용입니다.

Controller에서 발생하는 예외

가장 먼저 우리가 직접 작성한 컨트롤러에 대한 예외들을 처리해볼 것이다.

    @PostMapping("/login")
    public ResponseEntity<TokenContainingResponse> login(@RequestBody LoginRequest loginRequest) throws Exception {
        String token = userService.loginAndGenerateToken(loginRequest);

        TokenContainingResponse response = new TokenContainingResponse(HttpStatus.OK, Controller.LOG_IN_SUCCESS_MESSAGE, token);
        return new ResponseEntity<>(response, HttpStatus.OK);
    }

먼저 API의 기본적인 엔드포인트를 담당하는 컨트롤러 함수이다. 서비스 클래스에서 토큰을 생성하고 그 와중에 사용되는 모든 에러처리를 하나의 결과, 즉 TokenContainingResponse에 담도록 할 것이다.

public class TokenContainingResponse {
    private HttpStatus httpStatus;
    private String message;
    private String token;
}

// TokenContainingResponse.java

그리고 서비스 클래스 내의 메소드는 아래와 같다.

    public String loginAndGenerateToken(LoginRequest loginRequest) throws Exception {
        String email = Optional.ofNullable(loginRequest.getEmail()).orElseThrow(EmptyValueExistException::new);
        String password = Optional.ofNullable(loginRequest.getPassword()).orElseThrow(EmptyValueExistException::new);

        Optional<User> user = userRepository.findByEmail(email);

        if(!user.isPresent()){
            throw new UserNotExistException();
        }

        try{
            authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getEmail(),
                        loginRequest.getPassword()
            ));
        }catch(Exception e){
            throw new LoginFailException();
        }

        CustomUserDetails userDetails = userDetailsService.loadUserByUsername(user.get().getEmail());

        String token = jwtUtil.generateToken(email);

        return token;
    }

먼저

        Optional<User> user = userRepository.findByEmail(email);

        if(user.isEmpty()){
            throw new UserNotExistException();
        }

이 부분만 보면 데이터베이스에서 user의 정보를 email에 기반하여 찾고 존재하지 않으면 바로 UserNotExistException을 띄워버린다. 코드 상으로는 굉장히 깔끔하지만 이 부분만 확인하면 무책임해 보인다. 하지만 스프링은 이러한 Exception이 발생할때마다 따로 캐치해서 처리해 주는 방법이 존재한다.

ControllerAdvice

먼저 예외의 기본적인 형태부터 정해준다.


@AllArgsConstructor
@Getter
public class ApiException {
    private final String message;
    private final HttpStatus httpStatus;
    private final ZonedDateTime timestamp;
}
// ApiException.java
public class UserNotExistException extends RuntimeException{
}
UserNotExistException.java

이렇게 예외에 대한 코멘트를 해줄 message와 상태코드, 그리고 예외의 발생 시간을 기록할 수 있도록 한다. 그리고 실제로 띄울 Exception도 정해준다. 위와 같이 class를 작성하고 RuntimeException을 상속받도록 한다.

@ControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(value = {UserNotExistException.class})
    public ResponseEntity<Object> handleUserNotExistException(UserNotExistException e){

        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        ApiException apiException = new ApiException(
                ExceptionMessage.USER_NOT_EXIST_MESSAGE,
                httpStatus,
                ZonedDateTime.now(ZoneId.of("Z"))
        );

        return new ResponseEntity<>(apiException, httpStatus);
    }

예외 핸들러를 작성해준다. 먼저 @ControllerAdvice@Controller로 선언된 메소드 내에서 예외가 발생하면 그 처리를 이 핸들러가 하도록 한다.

그리고 @ExceptionHandler(value = {UserNotExistException.class}) 로 처리할 예외의 class를 적어주면 된다.

어떤 컨트롤러에서든 UserNotExistException이 발생하면 이 @ControllerAdvice 어노테이션이 선언된 클래스로 와서 맞는 @ExceptionHandler를 찾고 그에 대한 처리를 하는 것이다.

        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        ApiException apiException = new ApiException(
                ExceptionMessage.USER_NOT_EXIST_MESSAGE, // "유저가 존재하지 않습니다."
                httpStatus,
                ZonedDateTime.now(ZoneId.of("Z"))
        );

        return new ResponseEntity<>(apiException, httpStatus);

내부에서 처리는 간단하다. 먼저 상태코드를 정해주고 우리가 만든 ApiException을 작성한다음 ResponseEntity에 담아서 돌려주도록 하면 된다. 이렇게 하면 어떤 컨트롤러에서든 우리가 정한 예외만 띄워준다면 알아서 ResponseEntitiy의 형태로 돌려줄 수 있다.

Spring Security의 예외 처리

로그인의 인증 구현을 위해 Spring SecurityJWT를 사용을 해보았는데, 여기서 발생하는 예외는 컨트롤러에 속하지 않으므로 @ControllerAdvice가 알아차리지 못하였다. 따라서 생각치 못한 예외가 발생하므로 정상적으로 ResponseEntity를 클라이언트 측에 반환하지 못한다.

즉 컨트롤러에서 발생한 예외가 아니면 같은 UserNotExistException과 같은 커스텀 예외 방식을 사용할 수 없었다.

위 그림처럼 다른 라이브러리에 대한 예외가 @ControllerAdvice에 전달되지 않는 것이 문제였다. 그렇다면 이러한 예외를 처리하기 위해서는

이렇게 다른 라이브러리에서 예외가 발생하면 이렇게 컨트롤러에 띄워주도록 하면 된다.

먼저 ExceptionHandleController를 작성해준다.

@RestController
@RequestMapping("/exception")
public class ExceptionHandleController {

    @GetMapping("/jwt")
    public void JwtException(){
        throw new UserNotExistException();
    }
}

위처럼 Jwt관련 문제가 생기면 방금 생성한 UserNotExistException을 똑같이 띄워주도록 했다. 그리고 Spring Security 설정 파일에의 configure 메소드에는

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers(<URLS>)
                .permitAll()
                .antMatchers(<URLS>)
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .exceptionHandling().authenticationEntryPoint(new AuthenticationExceptionHandler())
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
    }

위처럼 authenticationEntryPoint에서의 예외처리를 AuthenticationExceptionHandler가 처리하도록 해주었다. 즉 이제부터 Security의 인증과정에 문제가 생기면 AuthenticationExceptionHandler가 처리한다.

그리고 AuthenticationExceptionHandler를 아래처럼 작성해준다.

@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
  @Override
  public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
      httpServletResponse.sendRedirect("/exception/jwt");
  }
}

이렇게 response를 받아서 sendRedirect를 우리가 작성한 ExceptionHandlerController와 맞는 URL로 보내주면 된다. 이렇게 하면 ExceptionHandlerController가 작동하고 그 안의 UserNotExistException이 발생하고 그 예외를 @ControllerAdvice가 처리하게 된다.

결론

조금 복잡하지만 이렇게 예외를 처리해놓으면 다른 라이브러리의 사용에서Exception의 결과가 똑같다면 재사용할 수 있는 여지가 많고 어플리케이션 서버의 스케일을 확장시키기에도 용이하다. 개념과 예외 처리의 흐름만 설명하기 위해 코드를 간단하게 적었다. 실제로 작동하는 코드는 여기에서 확인할 수 있다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

2개의 댓글

comment-user-thumbnail
2021년 4월 8일

정말 좋은글이네요. 내공에 감탄하고 갑니다ㅎㅎ

답글 달기
comment-user-thumbnail
2021년 8월 26일

좋은 글입니다 잘 배우고갑니다!

답글 달기