이전 프로젝트인 [밍글]을 진행하며 비즈니스 로직에 수많은 예외처리가 필요하다는걸 느꼈고 비슷한 예외처리들을 try-catch로 하다보니 코드가 너무 길어지고 가독성도 떨어졌었다.
이에, 더 나은 예외처리 방식을 고민하였고, GDSC에서 새로 시작한 프로젝트 [Sweater]에서 @RestControllerAdvice 와 @ExceptionHandler를 사용해 전역적으로 에러를 처리하는 GlobalExceptionHandler를 적용해보았다.
이전 프로젝트를 진행하며 받았던 BaseResponseStatus
라는 enum클래스는, exception들을 enum으로 한 클래스에서 관리하고 일관된 형식으로 클라이언트에게 응답값을 내려줄 수 있었다.
하지만, 에러 발생 시 응답을 일관된 BaseResponse 형태로 리턴하면서 try-catch 구문을 피할 수 없었고, exception이지만 http status를 무조건 200으로 내려줄 수 밖에 없다는 치명적인 문제가 있었다. 클라이언트는 BaseResponseStatus의 isSuccess 필드로 성공적인 응답인지 판단해야 했다.
하지만 프로젝트 초반에 서버와 클라이언트 모두 이 설계의 문제점을 깨닫지 못했고, 이는 클라이언트가 이 응답에 맞춰 API 연결을 하면서 점점 리팩토링하기 어려워졌다.
아래는 문제의 코드이다.
한눈에 봐도 많아보이는 에러들. 위와 같은 에러 코드들의 3배는 있었다.
- BaseResponse.java
첫번째 생성자의 SUCCESS 는 다음과 같다.
SUCCESS(true, 1000, "요청에 성공하였습니다."),
BaseResponse를 쓰면서 요청에 실패 시 BaseResponseStatus 포맷을 내려준다. (httpStatus는 200으로 OK이다.)
요청에 성공해도 BaseResponseStatus 포맷에 result 만 추가한 json 형식을 반환한다.
일관된 Response를 리턴하기 위해 에러인 경우에도 throw를 해 에러 응답을 리턴하는게 아닌 BaseResponse를 리턴하고 있습니다. 이는 에러인 경우에도 HttpStatus 200을 내려주는 좋지 않은 방식입니다.
또한, 서비스단에서 throw하는 BaseException들을 catch해 리턴하기 위해서 모든 Controller API에서 try-catch 구문이 들어가 있습니다. 이는 가독성을 저하시키며 동일한 try-catch 구문의 중복을 야기했습니다.
서비스단에서는 발생하는 런타임 에러들을 모두 try-catch로 처리해 BaseException으로 throw 해주었습니다.
이에 새로운 프로젝트를 할때는 꼭 더 나은 예외처리를 도입하고자 마음먹었고, 이에 Spring에서 제공하는 전역적으로 예외처리를 할 수 있는 @RestControllerAdvice와 @ExceptionHandler를 도입했다.
@RestControllerAdvice 어노테이션이 붙어있는 클래스는 모든 @Controller 전역에서 발생하는 exception들을 Intercept해 전역적으로 에러를 처리할 수 있게 해준다.
이는 @ControllerAdvice 와 @ResponseBody 를 합친 어노테이션으로, exception을 ResponseBody 형식으로 반환할 수 있기에 Rest API에서 사용하기에 좋은 예외처리 방식이다.
이를 @ExceptionHandler(처리할 예외 타입exception.java) 와 함께 사용하면, 컨트롤러 전역에서 발생하는 특정 exception을 잡아 처리해준다.
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> exceptionHandler(CustomException customException) {
HttpHeaders resHeaders = new HttpHeaders();
resHeaders.add("Content-Type", "application/json;charset=UTF-8");
ErrorCode errorCode = customException.getErrorCode();
ErrorResponse errorResponse = new ErrorResponse(errorCode);
return new ResponseEntity<>(errorResponse, resHeaders, HttpStatus.resolve(errorCode.getStatus()));
}
}
HttpStatus.resolve
메소드를 통해 int 형식의 errorCode.status 코드를 이에 일치하는 HttpStatus 값으로 변경해 ResponseEntity에 넣어준 후 반환합니다.위와 같이 @ControllerAdvice 를 통해 예외처리를 하게 된다면, 다음과 같은 이점을 누릴 수 있습니다.
이제 위와 같이 전역적으로 커스텀한 에러를 처리하기 위해 쓰이는 클래스들을 알아보겠습니다.
먼저 다음과 같은 필드를 가지고 있는 에러 코드를 정의했습니다.
한 곳에서 에러 코드를 한눈에 관리하기 위해 ErrorCode enum 클래스를 만들어 주었습니다.
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {
//member
MEMBER_NOT_FOUND(404, "1001", "없는 유저입니다."),
MEMBER_DUPLICATED(409, "1002", "중복된 유저입니다."),
//post
EMPTY_POST_LIST(404, "2001", "게시물이 없어요."),
POST_NOT_FOUND(404, "2002", "존재하지 않는 게시물이에요.");
private final int status; //http상태 응답 코드
private final String code; //식별용 코드
private final String message; //클라이언트에게 전달할 에러 메시지
ErrorCode(final int status, final String code, final String message) {
this.status = status;
this.code = code;
this.message = message;
}
public int getStatus() {
return status;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
그리고 우리가 발생한 예외를 처리해줄 RuntimeException을 상속하는 예외 클래스를 만들어 주었습니다.
public class CustomException extends RuntimeException {
private ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
RuntimeException에 이미 message를 포함하는 생성자가 있어서 super(message)를 해주고, 직접 만든 ErrorCode enum을 넣은 생성자를 만들어주었습니다.
이제 에러 응답 포맷을 정의하기 위해 다음과 같은 에러 응답 클래스를 만들었습니다.
나중에 GlobalExceptionHandler가 에러를 잡아 응답을 내려줄 때 response body에 들어갈 형식입니다.
@Getter
@Setter
public class ErrorResponse {
private int status;
private String message;
private String code;
public ErrorResponse(ErrorCode errorCode){
this.status = errorCode.getStatus();
this.message = errorCode.getMessage();
this.code = errorCode.getCode();
}
}
완성된 API Exception 형식입니다.
이렇게 @RestControllerAdvice와 @ExceptionHandler를 이용해 GlobalExceptionHandler를 만들어 보았습니다.
감사합니다.