[GDSC] @RestControllerAdvice + @ExceptionHandler로 GlobalExceptionHandler 적용하기

KIM TAEHYUN·2023년 6월 17일
1
post-thumbnail

Introduction

이전 프로젝트인 [밍글]을 진행하며 비즈니스 로직에 수많은 예외처리가 필요하다는걸 느꼈고 비슷한 예외처리들을 try-catch로 하다보니 코드가 너무 길어지고 가독성도 떨어졌었다.

이에, 더 나은 예외처리 방식을 고민하였고, GDSC에서 새로 시작한 프로젝트 [Sweater]에서 @RestControllerAdvice 와 @ExceptionHandler를 사용해 전역적으로 에러를 처리하는 GlobalExceptionHandler를 적용해보았다.

기존 방식 및 문제점

이전 프로젝트를 진행하며 받았던 BaseResponseStatus라는 enum클래스는, exception들을 enum으로 한 클래스에서 관리하고 일관된 형식으로 클라이언트에게 응답값을 내려줄 수 있었다.
하지만, 에러 발생 시 응답을 일관된 BaseResponse 형태로 리턴하면서 try-catch 구문을 피할 수 없었고, exception이지만 http status를 무조건 200으로 내려줄 수 밖에 없다는 치명적인 문제가 있었다. 클라이언트는 BaseResponseStatus의 isSuccess 필드로 성공적인 응답인지 판단해야 했다.

하지만 프로젝트 초반에 서버와 클라이언트 모두 이 설계의 문제점을 깨닫지 못했고, 이는 클라이언트가 이 응답에 맞춰 API 연결을 하면서 점점 리팩토링하기 어려워졌다.

아래는 문제의 코드이다.

  • BaseResponseStatus.java

한눈에 봐도 많아보이는 에러들. 위와 같은 에러 코드들의 3배는 있었다.

  • BaseException.java
    • BaseException은 BaseResponseStatus 인자로 받아 그대로 생성해준다.

- BaseResponse.java

첫번째 생성자의 SUCCESS 는 다음과 같다.
SUCCESS(true, 1000, "요청에 성공하였습니다."),

BaseResponse를 쓰면서 요청에 실패 시 BaseResponseStatus 포맷을 내려준다. (httpStatus는 200으로 OK이다.)
요청에 성공해도 BaseResponseStatus 포맷에 result 만 추가한 json 형식을 반환한다.

  • 요청 실패 예시: BaseResponse의 두번째 생성자 형식으로 리턴된다. (httpStatus는 200 OK이다.)
  • 요청 성공 예시: 실패 시 응답값 + result 필드를 보내주고있다.

  • AuthController

일관된 Response를 리턴하기 위해 에러인 경우에도 throw를 해 에러 응답을 리턴하는게 아닌 BaseResponse를 리턴하고 있습니다. 이는 에러인 경우에도 HttpStatus 200을 내려주는 좋지 않은 방식입니다.
또한, 서비스단에서 throw하는 BaseException들을 catch해 리턴하기 위해서 모든 Controller API에서 try-catch 구문이 들어가 있습니다. 이는 가독성을 저하시키며 동일한 try-catch 구문의 중복을 야기했습니다.

  • AuthService

서비스단에서는 발생하는 런타임 에러들을 모두 try-catch로 처리해 BaseException으로 throw 해주었습니다.


이에 새로운 프로젝트를 할때는 꼭 더 나은 예외처리를 도입하고자 마음먹었고, 이에 Spring에서 제공하는 전역적으로 예외처리를 할 수 있는 @RestControllerAdvice와 @ExceptionHandler를 도입했다.

@RestControllerAdvice

@RestControllerAdvice 어노테이션이 붙어있는 클래스는 모든 @Controller 전역에서 발생하는 exception들을 Intercept해 전역적으로 에러를 처리할 수 있게 해준다.

이는 @ControllerAdvice 와 @ResponseBody 를 합친 어노테이션으로, exception을 ResponseBody 형식으로 반환할 수 있기에 Rest API에서 사용하기에 좋은 예외처리 방식이다.

이를 @ExceptionHandler(처리할 예외 타입exception.java) 와 함께 사용하면, 컨트롤러 전역에서 발생하는 특정 exception을 잡아 처리해준다.

  • GlobalExceptionHandler.java
@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()));
    }
}
  • error response의 response header를 set해줍니다.
  • 인자로 받아온 CustomException의 ErrorCode enum을 받아와 일관된 ErrorResponse를 만들어줍니다.
  • ResponseEntity의 body에 만든 ErrorResponse와 header를 넣어줍니다.
  • 그리고 HttpStatus.resolve 메소드를 통해 int 형식의 errorCode.status 코드를 이에 일치하는 HttpStatus 값으로 변경해 ResponseEntity에 넣어준 후 반환합니다.

위와 같이 @ControllerAdvice 를 통해 예외처리를 하게 된다면, 다음과 같은 이점을 누릴 수 있습니다.

  1. 하나의 GlobalExceptionHandler 클래스로 모든 컨트롤러에 대해서 전역적으로 예외처리를 할 수 있다.
  2. 직접 정의한 에러 응답을 일관성있게 클라이언트에게 전달할 수 있다.
  3. 별도의 try-catch문을 쓰지 않고 직접 정의한 에러를 throw만 해주면 되기에 코드의 가독성이 높아진다.

이제 위와 같이 전역적으로 커스텀한 에러를 처리하기 위해 쓰이는 클래스들을 알아보겠습니다.

1. ErrorCode

먼저 다음과 같은 필드를 가지고 있는 에러 코드를 정의했습니다.

한 곳에서 에러 코드를 한눈에 관리하기 위해 ErrorCode enum 클래스를 만들어 주었습니다.

  • private final int status: http상태 코드 (404, 등)
  • private final String code; 서버 식별용 코드 (1001, 1002,.. 도메인 별 분류)
  • private final String message; 클라이언트에게 전달할 에러 메시지
@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;
    }
}

2. CustomException

그리고 우리가 발생한 예외를 처리해줄 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을 넣은 생성자를 만들어주었습니다.

3. ErrorResponse

이제 에러 응답 포맷을 정의하기 위해 다음과 같은 에러 응답 클래스를 만들었습니다.

나중에 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();
    }
}
  • ErrorCode enum을 인자로 받아, 각 필드들을 ErrorResponse에 넣어줍니다.

결과

완성된 API Exception 형식입니다.

이렇게 @RestControllerAdvice와 @ExceptionHandler를 이용해 GlobalExceptionHandler를 만들어 보았습니다.

감사합니다.


0개의 댓글