[Spring] ExceptionHandler

rin·2020년 5월 8일
6
post-thumbnail

이 글은 Spring Web Application Project에서 작성한 내용을 가져온 것입니다.
전체 코드는 위 링크 혹은 github에서 확인 할 수 있습니다.

웹 어플리케이션을 사용하다보면 예측 가능한 유저의 행동을 제어하고 서버가 다운되는 것을 막기 위해 여러가지 예외처리가 필요하다. 이를 위해 커스텀 Exception과 Specification을 활용하게 되는데 Exception이 발생하면 throw를 통해서 비즈니스 로직을 중단 시키는 것에 그치지않고 아니라 client에 어떤 문제가 있었는지 전달할 필요가 있다.

예를 들면, 사용자가 비밀번호를 잘못입력한 경우 로그인 작업을 중단하고 비밀번호에 오류가 있음을 알려줘야한다.

Spring에서는 이런 필요성을 충족하기 위해 강력한 어노테이션을 제공하는데 바로 @ExceptionHandler 이다.

ExceptionHandler

@ControllerAdvice

  • 말그대로 컨트롤러를 보조하는 클래스임을 명시한다.
  • 즉, @Controller 어노테이션을 가지거나, xml 설정 파일에서 컨트롤러로 명시된 클래스에서 Exception이 발생되면 이를 감지하겠다는 것이다.
  • 유사하게 @RestControllerAdvice라는 어노테이션도 존재한다.
  • ControllerRestController만 ExceptionHandler의 감시 대상이 된다.
  • 즉, Service만 감시 대상으로 등록할 수는 없단 소리
  • 하지만 Controller에서 Service를 호출한 경우, Service에서 Exception이 발생해도 결국은 Controller로 부터 문제가 발생했음을 감지 → Handler가 작동한다.
  • @ControllerAdvice(com.freeboard01.api.BoardApi)와 같이 특정한 클래스만 명시하는 것도 가능하다.

@ResponseBody

  • @RestController@Controller@ResponseBody를 합친 것이란 건 앞에서 언급하였다.
  • ExceptionHandler는 컨트롤러 혹은 레스트컨트롤러가 아니다.
  • 응답값은 컨트롤러처럼 String 또는 ModelAndView만 가능하다.
  • 따라서 응답 객체를 반환하고자 하면 @ResponseBody 어노테이션을 메소드 위에 명시하여야 한다.
  • String이나 ModelAndView를 이용해 에러 코드 혹은 메세지만 반환하거나 Map으로 반환하는 것 client 측에서 Error를 처리함에 있어 일관성이 부족한 응답으로 인해 불편함이 많을 것이기에 객체를 활용해 통일된 형식을 지키도록 한다.

@ExceptionHandler

  • @ControllerAdvice 이 명시된 클래스 내부 메소드 에 사용한다.
  • Attribute로 Exception 클래스를 받는다.
  • 즉, RuntimeException.class 나 더 상위 클래스인 Exception.class 등을 넘기면 된다.
  • Custom Exception을 만들었다면 (보통은 RuntimeException을 상속 받았을 것이다.) 이를 넘기면 된다.
  • @ExceptionHandler(XXException.class) 라고 작성한 경우, @ControllerAdvice에서 명시한 클래스에서 throw new XXException( .. ) 이 발생하면 핸들러는 이를 감지하고 해당 메소드를 수행한다.
  • 메소드는 여러개 작성 할 수 있으며 이에 따라 @ExceptionHandler 에 다른 Attribute값을 넘김으로써 각 Exception을 다르게 처리 할 수 있다.

나는 RuntimeException을 상속받는 FreeBoardException을 만들었는데, 현시점에서는 이 클래스에서 모든 커스텀 예외 처리를 수행하도록 할 것이다.

util 패키지 하위에 exception 패키지를 만들고 ExceptionHandler 클래스를 추가하였다.
완성된 코드는 다음과 같다.

@ControllerAdvice
public class ExceptionHandler {

    @ResponseBody
    @org.springframework.web.bind.annotation.ExceptionHandler(FreeBoardException.class)
    public ResponseEntity<Error> exception(FreeBoardException exception){
        return new ResponseEntity<>(Error.create(exception.getExceptionType()), HttpStatus.OK);
    }

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    static class Error{
        private int code;
        private int status;
        private String message;

        static Error create(BaseExceptionType exception){
           return new Error(exception.getErrorCode(), exception.getHttpStatus(), exception.getErrorMessage());
        }
    }

}

freeboardException

동일한 댑스에 freeboardException이라는 예외 처리 클래스를 만들었다.

public class FreeBoardException extends RuntimeException {

    @Getter
    private BaseExceptionType exceptionType;

    public FreeBoardException(BaseExceptionType exceptionType){
        super(exceptionType.getErrorMessage());
        this.exceptionType = exceptionType;
    }

}

사실상 Client로 전달되는 값은 static class Error이므로 본 클래스는 Handler가 감시할 커스텀 예외를 명시하기 위한 수단이라고 생각할 수 있다.
따라서 멤버변수로 ExceptionType을 추가하였다.

BaseExceptionType, UserExceptionType

domain 패키지 하위에 BaseExceptionType 클래스를 만들었다.

public interface BaseExceptionType {
    int getErrorCode();
    int getHttpStatus();
    String getErrorMessage();
}

BaseExceptionType 을 만든 이유는 모든 예외처리를 수행할 클래스가 freeboardException 이기에 각 도메인의 ExceptionType Enum을 업캐스팅하여 받아 올 수 있게 하기 위해서이다.
또한 이를 Error 객체로 만들 때 다운캐스팅하여 Enum 내 attribute를 convert 하기 위해서 @Getter 어노테이션을 사용했을 시 만들어지는 메소드와 동일한 추상 메소드를 포함하도록 하였다.

freeboardExceptionstatic class Error in ExceptionHandler
업캐스팅다운캐스팅

domain/user/enums 패키지 하위에 UserExceptionType 클래스를 만들었다.

@Getter
public enum UserExceptionType implements BaseExceptionType {

    NOT_FOUND_USER(1001, 200, "해당하는 사용자가 존재하지 않습니다."),
    DUPLICATED_USER(1002, 200, "이미 존재하는 사용자 아이디입니다."),
    WRONG_PASSWORD(1003, 200, "패스워드를 잘못 입력하였습니다."),
    LOGIN_INFORMATION_NOT_FOUND(1004, 200, "로그인 정보를 찾을 수 없습니다. (세션 만료)");

    private int errorCode;
    private int httpStatus;
    private String errorMessage;

    UserExceptionType(int errorCode, int httpStatus, String errorMessage) {
        this.errorCode = errorCode;
        this.httpStatus = httpStatus;
        this.errorMessage = errorMessage;
    }
}

❗️NOTE. ExceptionHandler의 Bean 설정

한참을 또 삽질했는데 🤔 아무리 Exception을 발생시켜도 Handler가 작동하지 않는 것이었다.
인터넷에 찾아봐도 다른 configuration에 대한 언급이 없었기에 최후의 시도라는 마음으로 dispatcher-servlet.xml<bean class="com.freeboard01.util.exception.ExceptionHandler"></bean>를 추가하였더니 그제서야 정상작동 되는 것을 볼 수 있었다.

예측하건데, Controller가 어노테이션 사용 설정으로 Bean으로 등록되고 필요 종속들이 자동 주입되는 것처럼 (혹은 Handler를 빈으로 등록하는 것처럼) ControllerAdvice인 클래스 또한 빈으로 명시해줄 필요가 있는가보다.

UserApiController

join 메소드 변경

    @PostMapping
    private void join(@RequestBody UserForm user){
        userService.join(user);
    }

UserService

join 메소드 변경

     public void join(UserForm user) {
        UserEntity userEntity = userRepository.findByAccountId(user.getAccountId());
        if (userEntity != null){
            throw new FreeBoardException(UserExceptionType.DUPLICATED_USER);
        }
        UserEntity newUser = user.convertUserEntity();
        newUser.setRole(UserRole.NORMAL);
        userRepository.save(newUser);
    }

컨트롤러 어드바이저 이름을 ExceptionHandler로 지어서 헷갈릴 수도 있을테니 이해를 돕기 위해 흐름을 그려보았다.
@ControllerAdvice 어노테이션을 가진 "클래스" ExceptionHandler는 빈으로 등록되며 모든 Controller를 주시하는데 이 컨트롤러 또한 빈으로 등록된 경우를 뜻한다. 컨트롤러를 주시한다는 이야기는 컨트롤러가 최상위 계층으로 존재하고 컨트롤러 내부에서 호출된 다른 빈들에 대해서도 계층적으로 모두 바라보고 있다. 즉 컨트롤러-클래스A-클래스B로 이어진 계층에서 클래스B의 메소드에서 익셉션 발생 시에서도 @ControllerAdvice 어노테이션을 가진 클래스에게 다음 작업이 위임된다.

@ControllerAdvice 어노테이션을 가진 모든 클래스는 자신이 가진 메소드 중 @ExceptionHandler의 Attribute가 예외를 발생시킨 Exception 클래스와 동일한(위 예에서는 FreeBoardException) 것을 찾아 실행시킨다.

따라서 결과적으로 ExceptionHandler "class"의 exception "method"가 수행되어 생성된 "Error" 객체를 클라이언트로 반환해준다.

톰캣을 실행 한 뒤 포스트맨으로 join 요청을 날려보도록 하겠다.

profile
🌱 😈💻 🌱

0개의 댓글