스프링에서 Custom Exception 깔끔하게 정리하기 (feat. API 응답 포맷)

재훈·2024년 4월 24일
post-thumbnail

서론

예외 처리는 서비스가 안정적으로 동작하게 하는데 필수적인 요소이다. 스프링 어플리케이션의 기능이 늘어남에 따라 도메인 별로 발생할 수 있는 예외들을 추가로 정의하고 예외 처리를 적용하는 과정에서 발생한 문제와 해결 과정을 정리하였다.

문제점(기존 방식)

사용자 정의 예외마다 클래스를 추가하는 방식

예외 하나마다 클래스를 정의하다보니 각 Custom Exception마다 ExceptionHandler 코드를 작성해야 했다.

⇒ 불필요하게 코드 길이 증가, 유지 보수의 어려움

PostNotFoundException

public class PostNotFoundException extends IllegalArgumentException {

    public PostNotFoundException(Long postId) {
        super("해당 게시글이 없습니다.(게시글 ID: " + postId +")");
    }

}

복수의 @RestControllerAdvice 사용

아래와 같은 방법으로 도메인마다 @RestControllerAdvice를 붙여 ExceptionHandler를 정의하였다가 일부 ExceptionHandler만 작동하는 문제가 발생하였다.

⇒ @RestControllerAdvice가 여러군데 선언되어 있어 발생하는 오류
⇒ 하나만 사용하거나 @Order를 사용해서 순서를 지정해줘야한다!

PostExceptionHandler

@RequiredArgsConstructor
@RestControllerAdvice
public class PostExceptionHandler {

    private final ApiResponse apiResponse;

    // 게시글을 찾을 수 없는 경우 예외 처리
    @ExceptionHandler(PostNotFoundException.class)
    public ResponseEntity<?> handlePostNotFoundException(PostNotFoundException e) {
        return apiResponse.error(e.getMessage(), HttpStatus.NOT_FOUND);
    }

    // 게시글 수정, 삭제 권한이 없는 경우 예외처리
    @ExceptionHandler(PostOwnershipException.class)
    public ResponseEntity<?> handlePostOwnershipException(PostOwnershipException e) {
        return apiResponse.error(e.getMessage(), HttpStatus.UNAUTHORIZED);
    }

}

ExceptionHandler를 이용한 예외 처리

@ExceptionHandler

@(Rest)Controller에서 발생하는 예외를 처리하는 기능. Exception 클래스들을 값으로 받아 처리할 예외를 지정할 수 있다.

⇒ 각 Controller 마다 발생하는 예외를 ExceptionHandler로 처리할 수 있지만, Controller의 코드가 길어지는 문제가 발생한다.

@RestController
public class PostController {
	
    // ...
	
	@ExceptionHandler(RuntimeException.class)
	public ResponseEntity handleRuntimeException(RuntimeException ex) {
	// ...
	}
	
}

@RestControllerAdvice

여러 컨트롤러에서 발생하는 예외를 전역적으로 처리하는 역할을 한다.

⇒ 컨트롤러 내부에 ExceptionHandler를 정의할 필요가 없이, 하나의 클래스에서 예외 처리를 담당할 수 있다.

@ResponseBody가 들어 있어서 Json 형식의 응답을 반환한다.
@Component도 포함되어 있어 스프링 빈에 등록된다.

문제 해결 과정

enum 타입으로 상태 코드 정의

ErrorCode

public interface ErrorCode {

    String name();
    HttpStatus getHttpStatus();
    String getMessage();
}

도메인 별로 상태코드를 관리하여 유지/보수가 용이하도록 하였다.

PostErrorCode

@Getter
@RequiredArgsConstructor
public enum PostErrorCode implements ErrorCode {

    NO_PERMISSION(HttpStatus.UNAUTHORIZED, "User not have permission to post"),
    POST_NOT_FOUND(HttpStatus.NOT_FOUND, "Post not found"),
    ;

    private final HttpStatus httpStatus;
    private final String message;
}

단일 CustomException 정의 Handler 적용

기존에 사용했던 방식(예외마다 클래스를 생성)과 다르게 하나의 CustomException을 정의하고 앞서 정의한 에러코드만 바꿔서 사용하는 방식을 적용하였다.

CustomException

@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;
}

사용 예시

public Post validatePostExists(Long postId) {
            Post post = postRepository.findById(postId)
                    .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND));
            return post;
        }

CustomException을 처리하는 ExceptionHandler가 하나만 필요하므로 @RestControllerAdvice도 기존처럼 여러 개를 사용할 필요가 없다!

GlobalExceptionHandler

@RequiredArgsConstructor
@RestControllerAdvice
public class GlobalExceptionHandler {

    private final ApiResponse apiResponse;

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<?> handleCustomException(RestApiException e) {
        ErrorCode errorCode = e.getErrorCode();
        return apiResponse.error(errorCode.getMessage(), errorCode.getHttpStatus());
    }
    
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException e) {
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        return apiResponse.error(errorCode.getMessage(), errorCode.getHttpStatus());
    }
    
    // ...

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> handleAllException(Exception e) {
        ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
        return apiResponse.error(e.getMessage(), errorCode.getHttpStatus());
    }
}

미리 정의되어 있는 예외는 handleCustomException에서 처리하고, CustomException에 속하지 않는 예외는 다른 ExceptionHandler에 의해 처리된다.

응답 예시

미리 정의한 POST_NOT_FOUND 예외가 정상적으로 적용된 것을 알 수 있다.

// 404 Not Found
{
  "status": "error",
  "message": "Post not found"
}

정리

지금까지 ExceptionHandler를 사용한 스프링에서의 예외 처리 방법에 대해 알아보았다.
기존에 사용자 정의 예외를 모두 클래스로 선언하고 여러 개의 RestControllerAdvice에 적용하는 방식 대신에 하나의 사용자 정의 예외 클래스를 선언하고 enum 타입의 상태코드를 적용하여 코드의 유지/보수 및 확장성을 향상시킬 수 있었다.

레퍼런스

[Spring] 스프링의 다양한 예외 처리 방법(ExceptionHandler, ControllerAdvice 등) 완벽하게 이해하기 - (1/2)

[Spring] @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)

[공부/Spring] @ExceptionHandler 예외처리

부록) API 응답 포맷

REST API Response Format, 응답 객체는 어떤 형식이 좋을까?

위 예제에서 사용된 ApiResponse는 아래와 같이 JSend 형식의 응답 포맷을 정의해두고 사용한 것이다. JSON 타입으로 API 요청 성공/예외 여부와 메시지(+데이터)를 반환한다.

@Component
public class ApiResponse {

    private static final String STATUS_SUCCESS = "success";
    private static final String STATUS_ERROR = "error";

    private  <T, E> ResponseEntity<Object> get(String status, @Nullable String message, @Nullable T data, @Nullable E errors, HttpStatus httpStatus) {

        if (status.equals(STATUS_SUCCESS)) {
            return new ResponseEntity<>(SucceededBody.builder()
                    .status(status)
                    .message(message)
                    .data(data)
                    .build(),
                    httpStatus);
        } else if (status.equals(STATUS_ERROR)) {
            return new ResponseEntity<>(ErroredBody.builder()
                    .status(status)
                    .message(message)
                    .build(),
                    httpStatus);
        } else {
            throw new RuntimeException("Api Response Error");
        }
    }

//     성공 응답 반환 (상태, 메시지, 데이터)
//     {
//          "status" : "success",
//          "message" : "success message",
//          "data" : "배열 또는 단일 데이터"
//     }
    public <T> ResponseEntity<Object> success(String message, T data, HttpStatus httpStatus) {
        return get(STATUS_SUCCESS, message, data, null, httpStatus);
    }

//     성공 응답 반환 (상태, 데이터)
//     {
//          "status" : "success",
//          "message" : null,
//          "data" : "배열 또는 단일 데이터"
//     }
    public <T> ResponseEntity<Object> success(T data, HttpStatus httpStatus) {
        return get(STATUS_SUCCESS, null, data, null, httpStatus);
    }

//     성공 응답 반환 (메시지, 데이터)
//     {
//          "status" : "success",
//          "message" : "success message",
//          "data" : null
//     }

    public <T> ResponseEntity<Object> success(String message, HttpStatus httpStatus) {
        return get(STATUS_SUCCESS, message, null, null, httpStatus);
    }

//     성공 응답 반환 (상태)
//     {
//          "status" : "success",
//          "message" : null,
//          "data" : null
//     }
    public ResponseEntity<Object> success(HttpStatus httpStatus) {
        return get(STATUS_SUCCESS, null, null, null, httpStatus);
    }

//     예외 발생 시 에러 응답 반환
//     {
//          "status" : "error",
//          "message" : "custom error message"
//     }
    public ResponseEntity<Object> error(String message, HttpStatus httpStatus) {
        return get(STATUS_ERROR, message, null, null, httpStatus);
    }

    // 성공 응답 객체 바디
    @Builder
    @Setter
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public static class SucceededBody<T> {

        private String status;
        private String message;
        private T data;
    }

    // 오류 응답 객체 바디
    @Builder
    @Setter
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public static class ErroredBody {

        private String status;
        private String message;
    }

}

1개의 댓글

comment-user-thumbnail
2024년 5월 6일

좋은 글 감사합니다~

답글 달기