이전 글에서는 프로젝트에 spring-security 설정과 구글 oAuth 처리를 위해 Firebase 설정을 적용해보았다. 이번 글에서는 프로젝트 스프링의 예외처리를 해주는 ControllerAdvice 와 ExceptionHandler 를 적용시켜보겠다.
오류 처리는 프로그램을 개발하는데 있어서 매우 큰 부분을 차지한다. 오류를 예측해서 비정상적인 상황이 발생하지 않게 하는 것은 중요하다. 과하다할 만큼 상세하고 다양하게 예외를 잡아 처리해준다면, 클라이언트도 그렇고 서버도 그렇고 더 안정적인 프로그램이 될 수 있게 도와준다. 예외 처리에 집중 하다 보면, 비즈니스 로직에 집중하기 어렵고, 비즈니스 로직과 관련된 코드보다 예외 처리를 위한 코드가 더 많아지는 경우도 생기게 된다. 이런 문제를 조금이라도 개선하기 위해 @ExceptionHandler와 @ControllerAdvice를 사용한다.
@Controller나 @RestController에서 발생한 예외를 한 곳에서 관리하고 처리할 수 있게 도와주는 어노테이션이다. 즉 스프링에서 예외처리를 전역적으로 핸들링하기 위해 @ControllerAdvicde 어노테이션을 사용할 수 있다. View 를 사용하지 않고 Rest API 로만 사용할 때 쓸 수 있는 @RestControllerAdvice 를 사용합니다.
package couch.camping.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
//일반 에러
@ExceptionHandler
protected ResponseEntity<Object> handleCustomException(CustomException e) {
return ErrorResponse.toResponseEntity(e);
}
//요청 바디 검증 실패
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
CustomException e = new CustomException(ErrorCode.BAD_REQUEST_VALIDATION, ex.getMessage());
return ErrorResponse.toResponseEntity(e);
}
//모델 검증 실패
@Override
protected ResponseEntity<Object> handleBindException(BindException ex,
HttpHeaders headers,
HttpStatus status,
WebRequest request) {
CustomException e = new CustomException(ErrorCode.BAD_REQUEST_VALIDATION, ex.getMessage());
return ErrorResponse.toResponseEntity(e);
}
}
위의 코드에서 ResponseEntityExceptionHandler.class 를 상속받는 것을 확인할 수 있다. 이는 스프링에서 구현한 추상 클래스로 Bean Validation 을 통해 예외가 터진 경우 처리해줄 수 있는 ExceptionHandler 를 사용하기 위함이다.
@ExceptionHandler 어노테이션이 붙은 메서드는 의미 그대로 예외처리를 해줄 수 있는 Handler가 된다.
메서드마다 @ExceptionHandler(예외처리클래스.class)와 같이 어노테이션에 value 로 예외처리클래스.class 라고 명시해줘야 하는데, 위의 코드와 같이 메서드에 파라미터로 예외처리 클래스가 명시되 있는 경우 어노테이션에 추가적으로 어떠한 예외처리 클래스인지 value 로 명시하지 않아도 된다.
package couch.camping.exception;
import lombok.Getter;
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
public CustomException(ErrorCode errorCode, String message ) {
super(message);
this.errorCode = errorCode;
}
}
CustomException.class 는 RuntimeException.class 를 상속받은 예외클래스이다. 이는 스프링에서 발생하는 오류를 해당 클래스로 예외를 터트리기 위함이다. 해당 클래스로 객체를 생성할때는 ErrorCode 를 생성자에 넣어 생성한다.(어떠한 에러인지 사용자가 작성한 enum 값으로 명시하기 위해)
package couch.camping.exception;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
@AllArgsConstructor
public enum ErrorCode {
//공통 예외
BAD_REQUEST_PARAM(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
BAD_REQUEST_VALIDATION(HttpStatus.BAD_REQUEST, "검증에 실패하였습니다."),
//회원 예외
UNAUTHORIZED_MEMBER(HttpStatus.UNAUTHORIZED, "해당 요청은 로그인이 필요합니다."),
NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, "해당 유저를 찾을 수 없습니다."),
EXIST_MEMBER(HttpStatus.BAD_REQUEST, "이미 등록된 유저입니다."),
//인증 인가 예외
FORBIDDEN_MEMBER(HttpStatus.FORBIDDEN, "해당 요청에 권한이 없습니다."),
INVALID_AUTHORIZATION(HttpStatus.BAD_REQUEST, "인증 정보가 부정확합니다."),
//캠핑장 예외
EXIST_CAMP(HttpStatus.BAD_REQUEST, "이미 좋아요한 캠핑장입니다."),
NOT_FOUND_CAMP(HttpStatus.NOT_FOUND, "해당 캠핑장을 찾을 수 없습니다."),
NOT_FOUND_CAMP_DETAIL(HttpStatus.NOT_FOUND, "상세검색에 해당하는 캠핑장을 찾을 수 없습니다."),
//리뷰 예외
NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, "해당 리뷰를 찾을 수 없습니다."),
//알림 예외
NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "해당 알림을 찾을 수 없습니다.");
private final HttpStatus httpStatus;
private final String detail;
}
위의 열거형인 ErrorCode 와 CustomExcpetion.class 를 사용하여 예외를 터트릴 경우
throw new CustomException(ErrorCode.BAD_REQUEST_PARAM, "잘못된 요청입니다.")
와 같이 생성하여 예외를 터트린다.
package couch.camping.exception;
import lombok.Builder;
import lombok.Getter;
import org.springframework.http.ResponseEntity;
import java.time.LocalDateTime;
@Getter
@Builder
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status;
private final String error;
private final String code;
private final String detail;
private final String message;
public static ResponseEntity<Object> toResponseEntity(CustomException e) {
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(
ErrorResponse.builder()
.status(errorCode.getHttpStatus().value())//httpStatus 코드
.error(errorCode.getHttpStatus().name())//httpStatus 이름
.code(errorCode.name())//errorCode 의 이름
.detail(errorCode.getDetail())//errorCode 상세
.message(e.getMessage())//에러 메시지
.build()
);
}
}
위의 클래스는 ExceptionHandler 에서 예외 발생 시 응답을 위한 Format 입니다.
{
"timestamp": "2021-03-14T03:29:01.878659",
"status": 404,
"error": "NOT_FOUND",
"code": "MEMBER_NOT_FOUND",
"message": "해당 유저 정보를 찾을 수 없습니다"
}
{
"timestamp": "2022-02-22T14:54:36.4020031",
"status": 400,
"error": "BAD_REQUEST",
"code": "BAD_REQUEST_PARAM",
"detail": "잘못된 요청입니다.",
"message": "sort 의 값을 distance 또는 rate 만 입력가능합니다."
}
Spring 에는 프로젝트 전역에서 발생하는 Exception 을 한 곳에서 처리할 수 있다.
Enum 클래스로 ErrorCode 를 정의하면 Exception 클래스를 매번 생성하지 않아도 된다.(객체지향 적이다.)
실제 클라이언트에게 날라가는 응답에서 code 부분만 확인하면 어떤 에러가 발생했는지 쉽게 파악 가능하다.