[Spring Boot] @RestControllerAdvice 를 사용한 예외 처리
들어가며
지난 게시글에서 @ControllerAdvice, @RestControllerAdvice에 대해 알아보았다. 이번 게시글에서는 해당 어노테이션을 사용하여 예외 처리하는 방법에 대해서 알아보려고 한다.
Rest API 구현 중 오류 메시지는 개발자가 의도한 오류(Custom Exception)와 예상치 못한 오류(System Exception)로 구분된다. 이번 게시글에선 이러한 예외 상황에 대한 공통 예외 처리(Exception Handler)를 적용하는 방법에 대해 알아보겠다.
네이버 오픈 API 오류 메시지 형식 가이드처럼 API 오류 메시지에 대해 일관된 형식으로 응답하도록 설계해야 한다. 신규 API를 구현할 때마다 작성하도록 설계하는 것은 작업자마다 일관된 응답 구조를 보장하기 어렵기 때문에, 작업자의 별도 작업 없이 일관된 오류 메시지 형식을 응답할 수 있게 해야 한다.
@ControllerAdvice, @RestControllerAdvice에 대한 설명은 해당 게시글을 참고하면 된다.
링크 : https://jangjjolkit.tistory.com/51
1. Exception 응답 모델 생성
일관된 오류 메시지 형식으로 응답하기 위한 공통 Entity를 생성한다.
@Getter
@ToString
public class ApiExceptionEntity {
private String errorCode;
private String errorMsg;
@Builder
public ApiExceptionEntity(String errorCode, String errorMsg) {
this.errorCode = errorCode;
this.errorMsg = errorMsg;
}
}
2. Exception Enum Class 선언
Enum Class를 활용하여 Custom 오류 메시지를 선언한다.
@Getter
@ToString
public enum ExceptionEnum {
RUNTIME_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR, "E0001")
, ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "E0002", "인증되지 않은 사용자입니다.")
, BAD_REQUEST(HttpStatus.BAD_REQUEST, "E0003", "필수 요청 변수가 누락되었습니다.")
, METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "E0004", "요청 메소드를 확인해주세요.")
, INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E0005", "기타 오류")
, FORBIDDEN(HttpStatus.FORBIDDEN, "S0001", "권한이 없습니다.");
private final HttpStatus status;
private final String code;
private String message;
ExceptionEnum(HttpStatus status, String code) {
this.status = status;
this.code = code;
}
ExceptionEnum(HttpStatus status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
}
3. Custom Exception 구현
개발자가 의도한 예외 상황에서 사용될 공통 Exception을 구현한다.
@Getter
public class ApiException extends RuntimeException{
private ExceptionEnum error;
public ApiException(ExceptionEnum e) {
super(e.getMessage());
this.error = e;
}
}
그러면 아래와 같이 예외 처리를 할 수 있다.
throw new ApiException(ExceptionEnum.RUNTIME_EXCEPTION);
RuntimeException을 상속받은 이유는 Unchecked Exception 처리를 위함이다.
Unchecked Exception은 컴파일 단계가 아닌 런타임 단계에서 발생하기 때문에 에러 발생을 예측하기 쉽지 않다.
때문에 전부 예상할 수 없는 예외처리에 대한 일괄된 결과 코드, 메시지 등 공통 처리를 위하여 Runtime Exception을 상속받았다.
만약 Checked Exception에 대한 예외처리를 처리하고 싶다면 예외 전환을 하는 방법이 있다.
try {
// ...
} catch(SQLException e) {
// 커스텀 Exception을 사용하여 직관적인 확인이 가능
// 해당 커스텀 Exception은 RuntimeException을 상속
throw new CustomException(e.getMessage);
}
추가적으로 이렇게 예외 전환을 하게 되면 Checked Exception에 대한 Transaction Rollback 사용도 가능해진다.
자세한 내용은 해당 글을 참고하면 된다.
링크 : https://jangjjolkit.tistory.com/2
4. Exception Handler 구현
@RestControllerAdvice를 이용하여 공통된 오류 메시지를 반환하는 기능을 구현한다.
@RestControllerAdvice
public class ApiExceptionAdvice {
// custom exception
@ExceptionHandler({ApiException.class})
public ResponseEntity<ApiExceptionEntity> exceptionHandler(HttpServletRequest req, ApiException e) {
// e.printStackTrace();
return ResponseEntity
.status(e.getError().getStatus())
.body(ApiExceptionEntity.builder()
.errorCode(e.getError().getCode())
.errorMsg(e.getError().getMessage())
.build());
}
// runtime(checked) exception
@ExceptionHandler({RuntimeException.class})
public ResponseEntity<ApiExceptionEntity> exceptionHandler(HttpServletRequest req, final RuntimeException e) {
e.printStackTrace();
return ResponseEntity
.status(ExceptionEnum.RUNTIME_EXCEPTION.getStatus())
.body(ApiExceptionEntity.builder()
.errorCode(ExceptionEnum.RUNTIME_EXCEPTION.getCode())
.errorMsg(e.getMessage())
.build());
}
// access denied exception
@ExceptionHandler({AccessDeniedException.class})
public ResponseEntity<ApiExceptionEntity> exceptionHandler(HttpServletRequest req, final AccessDeniedException e) {
e.printStackTrace();
return ResponseEntity
.status(ExceptionEnum.ACCESS_DENIED.getStatus())
.body(ApiExceptionEntity.builder()
.errorCode(ExceptionEnum.ACCESS_DENIED.getCode())
.errorMsg(ExceptionEnum.ACCESS_DENIED.getMessage())
.build());
}
// bad request exception
@ExceptionHandler({HttpRequestMethodNotSupportedException.class})
public ResponseEntity<ApiExceptionEntity> exceptionHandler(HttpServletRequest req, final HttpRequestMethodNotSupportedException e) {
e.printStackTrace();
return ResponseEntity
.status(ExceptionEnum.METHOD_NOT_ALLOWED.getStatus())
.body(ApiExceptionEntity.builder()
.errorCode(ExceptionEnum.METHOD_NOT_ALLOWED.getCode())
.errorMsg(ExceptionEnum.METHOD_NOT_ALLOWED.getMessage())
.build());
}
// unchecked exception
@ExceptionHandler({Exception.class})
public ResponseEntity<ApiExceptionEntity> exceptionHandler(HttpServletRequest req, final Exception e) {
e.printStackTrace();
return ResponseEntity
.status(ExceptionEnum.INTERNAL_SERVER_ERROR.getStatus())
.body(ApiExceptionEntity.builder()
.errorCode(ExceptionEnum.INTERNAL_SERVER_ERROR.getCode())
.errorMsg(e.getMessage())
.build());
}
}
@ExceptionHandler에 지정된 예외 클래스가 ApiException 클래스인 경우는 개발자가 의도한 예외 처리이다. 그 외 예외처리 역시 추가했다. 응답 코드와 메시지는 상황에 따라 지정된 메시지를 반환하거나, 에러 메시지를 반환해줘도 된다. 해당 게시글에선 테스트를 위해 각각의 방법을 모두 사용했다.
또한, 의도한 예외 발생 시에는 에러 로그를 남기지 않지만, 의도하지 않은 예외 발생 시에는 에러 로그를 남기도록 했다.
5. 결과
Enum Class에 선언된 HTTP 상태 코드(HttpStatus)와 ApiExceptionEntity 형식에 맞게 응답이 된다.
해당 기능을 활용하면 http 상태 코드 값을 200이 아닌 다른 값으로도 설정할 수 있다.
정리하며
이번 게시글에서는 @RestControllerAdvice를 이용하여 공통 예외 처리 및 에러코드 반환 기능을 만들어보았다. 해당 기능을 사용하면 각각의 작업자가 에러 코드나 메시지를 일일이 확인하지 않고, 정해진 문서에 따라 Exception만 발생시키기만 해도 된다. 해당 예외 처리에 대한 응답은 자동으로 반환된다. 에러 코드는 다른 작업자나 사용자가 확인할 수 있게 문서로 관리하면 된다. 이렇게 하면 코드도 깔끔해지고 작업자의 생산성도 향상될 수 있을 것이다.
관련 소스 코드는 깃허브를 참고하면 된다.
링크 : https://github.com/JangDaeHyeok/spring_boot_common_exception_handle
참고
https://javachoi.tistory.com/253
https://developers.naver.com/docs/common/openapiguide/errorcode.md