이 글은 Spring Web Application Project에서 작성한 내용을 가져온 것입니다.
전체 코드는 위 링크 혹은 github에서 확인 할 수 있습니다.
웹 어플리케이션을 사용하다보면 예측 가능한 유저의 행동을 제어하고 서버가 다운되는 것을 막기 위해 여러가지 예외처리가 필요하다. 이를 위해 커스텀 Exception과 Specification을 활용하게 되는데 Exception이 발생하면 throw를 통해서 비즈니스 로직을 중단 시키는 것에 그치지않고 아니라 client에 어떤 문제가 있었는지 전달할 필요가 있다.
예를 들면, 사용자가 비밀번호를 잘못입력한 경우 로그인 작업을 중단하고 비밀번호에 오류가 있음을 알려줘야한다.
Spring에서는 이런 필요성을 충족하기 위해 강력한 어노테이션을 제공하는데 바로 @ExceptionHandler
이다.
@ControllerAdvice
@Controller
어노테이션을 가지거나, xml 설정 파일에서 컨트롤러로 명시된 클래스에서 Exception이 발생되면 이를 감지하겠다는 것이다.Controller
와 RestController
만 ExceptionHandler의 감시 대상이 된다.Service
만 감시 대상으로 등록할 수는 없단 소리@ControllerAdvice(com.freeboard01.api.BoardApi)
와 같이 특정한 클래스만 명시하는 것도 가능하다.@ResponseBody
String
또는 ModelAndView
만 가능하다.@ExceptionHandler
RuntimeException.class
나 더 상위 클래스인 Exception.class
등을 넘기면 된다.나는 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
이라는 예외 처리 클래스를 만들었다.
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을 추가하였다.
domain
패키지 하위에 BaseExceptionType
클래스를 만들었다.
public interface BaseExceptionType {
int getErrorCode();
int getHttpStatus();
String getErrorMessage();
}
BaseExceptionType
을 만든 이유는 모든 예외처리를 수행할 클래스가 freeboardException
이기에 각 도메인의 ExceptionType Enum을 업캐스팅하여 받아 올 수 있게 하기 위해서이다.
또한 이를 Error
객체로 만들 때 다운캐스팅하여 Enum 내 attribute를 convert 하기 위해서 @Getter
어노테이션을 사용했을 시 만들어지는 메소드와 동일한 추상 메소드를 포함하도록 하였다.
freeboardException | static 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인 클래스 또한 빈으로 명시해줄 필요가 있는가보다.
join
메소드 변경
@PostMapping
private void join(@RequestBody UserForm user){
userService.join(user);
}
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 요청을 날려보도록 하겠다.