[Spring] REST API 오류 응답하기

dondonee·2024년 7월 20일
0
post-thumbnail

REST API 오류 응답하기

비즈니스 로직에서 예측 가능한 오류가 발생했을 때, 클라이언트에게 상세한 오류메세지를 REST API로 전달하는 방법에 대해 공부한 뒤 리팩토링 해보았다.



응답 객체

Best Practices for REST API Error Handling에서는 REST API를 통해 오류 처리를 할 때 다음 세 가지 규칙을 지켜야 한다고 강조한다.

  1. 적절한 HTTP 상태 코드 제공
  2. 응답 바디에 추가 정보 제공
  3. 일관적인 예외 처리

이 규칙을 충족하기 위해서는 응답용 객체가 필요하다.


객체 표준

객체 형식에 관해서는, 스프링에서 기본 제공하는 형식도 있고 RFC9457이라는 표준도 있다. Spring 6 부터는 ProblemDetail이라는 클래스도 도입되었다. 하지만 아직 많은 곳에서 사용하지는 않는 것 같다.

사람들은 어떻게 사용하고 있는지 검색을 해 보았는데 약간의 차이는 있지만, 대부분 자체 오류 코드오류 메세지 정보는 기본적으로 포함하고 있었다. 다음은 트위터(X)와 페이스북(메타)의 예시이다.

{
    "errors": [
        {
            "code":215,
            "message":"Bad Authentication data."
        }
    ]
}
{
    "error": {
        "message": "Missing redirect_uri parameter.",
        "type": "OAuthException",
        "code": 191,
        "fbtrace_id": "AWswcVwbcqfgrSgjG80MtqJ"
    }
}


응답 객체 만들기

@Getter
public class ErrorResponse {

    private String code;
    private String message;


    public ErrorResponse(ErrorCode errorCode, String message) {
        this.code = errorCode.getCode();
        this.message = message;
    }
}

나는 간단하게 자체 오류 코드와 오류 메세지를 담는 ErrorResponse를 생성했다.

ErrorCodeEnum 클래스이다. 내가 의도적으로 발생시키는 예측 가능한 오류들을 한 곳에 묶어두기 위한 것이다.



Enum으로 예외 종류 관리하기

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // 검증
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "400001", null),
    // 인증.인가
    NOT_WRITER(HttpStatus.FORBIDDEN, "403002", "작성자가 아닙니다."),
    // 리소스
    COMMENT_NOT_EXIST(HttpStatus.NOT_FOUND, "404003", "댓글이 존재하지 않습니다."),
    POST_NOT_EXIST(HttpStatus.NOT_FOUND, "404002", "게시글이 존재하지 않습니다."),
    // 기타
    COMMENT_HAS_CHILD(HttpStatus.BAD_REQUEST, "400300", "댓글이 존재하는 경우 삭제할 수 없습니다.");

    private final HttpStatus status;
    private final String code;
    private final String message;
}

예외를 얼마나 구체적으로 나눠야 하는 지에 대해서 고민을 많이 했다. 클라이언트에게 오류를 상세하게 안내해주는 것은 좋지만 케이스가 너무 많아지면 좋지 않다. 상수는 범용으로 만들고 예외 발생시마다 메세지를 정해주는 방법도 있겠지만 일관성이 떨어질 것 같았다.

최종 결정한 형태는 위와 같다. status는 HTTP 상태코드로, 응답시 사용할 것이다. code는 자체 오류 코드로, HTTP 상태코드를 참조하여 만들었다.

message는 오류 메세지인데 ErrorCode의 값을 사용할 수도 있고 예외를 발생시키면서 지정할 수도 있게 만들 것이다. INVALID_INPUT 상수의 경우 Bean Validator를 통한 유효성 검증 결과에 따라 동적으로 메세지를 주입할 것이다.



표준 예외 vs 커스텀 예외

컨트롤러에서는 throw를 통해 예외를 던지기만 하고 응답 처리는 다른 곳에서 하도록 @RestControllerAdvice를 사용하려고 한다.

이 때 표준 예외를 던질 수도 있고 커스텀 예외를 사용할 수도 있다.


표준 예외

IllegalArgumentException과 같은 표준 예외는 익숙하기 때문에 동료들이 이해하기도 쉽고 예외를 생성하는 별도 비용이 들지 않는다.

단순히 오류메세지를 보내기 위해서라면 표준 예외로도 충분히 가능하다. 따라서 커스텀 예외를 사용하려면 명확한 목적과 이익이 있어야 한다.


커스텀 예외

표준 예외는 범용성이 장점이지만 단점이 되기도 한다. 재사용성은 좋지만, 여러 곳에서 예외가 발생했을 때 발생 위치를 명확하게 파악하기 어렵다는 문제가 있다.

또한 Exception이나 RuntimeException는 상위 예외이기 때문에 예측하지 못했던 하위 예외까지 모조리 잡아버린다. 커스텀 예외를 사용하면 예측 가능한 상황에서 의도적으로 발생시킨 예외를 구분하여 처리할 수 있다.

예외는 기본적으로 Stack Trace를 사용한다. 예외가 발생했을 때 Call Stack에 있는 메소드 리스트를 저장하여 위치 추적을 돕는다. 하지만 try/catchAdvice를 통해 처리한 예외들은 예측한 것이기 때문에 비싼 비용을 들여 만든 Stack Trace를 사용하지 않는 경우가 많다.

Stack Trace는 조상인 TrowablefillInStackTrace()를 통해 만들어지는데, 커스텀 예외는 이 메소드를 오버라이드하여 기능을 꺼줌으로써 예외 생성 비용을 절감할 수 있다.


커스텀 예외를 언제 사용하면 좋을 지에 대해서는 다음 글에서 잘 정리되어 있다.



커스텀 예외 만들기

public class BusinessException extends RuntimeException {

	// 캐싱
    public static final BusinessException COMMENT_HAS_CHILD = new BusinessException(ErrorCode.COMMENT_HAS_CHILD);
    ...


    private final ErrorCode code;


    public BusinessException(ErrorCode code) {
        super();
        this.code = code;
    }

    public BusinessException(ErrorCode code, String message) {
        super(message);
        this.code = code;
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        return this; // stack trace 사용하지 않음
    }

    public ResponseEntity<ErrorResponse> makeResponseEntity() {
        String message = getMessage();
        if (message == null) {
            message = code.getMessage();
        }
        return ResponseEntity.status(code.getStatus()).body(new ErrorResponse(code, message));
    }
}

비즈니스 로직에서 클라이언트에게 안내해 주어야 하는, 예측 가능한 예외를 다루는 BusinessException을 만들어주었다. 런타임 중에 발생하는 언체크 예외이기 때문에 RuntimeException을 상속한다.

BusinessException에서 다루는 예외의 종류는 모두 ErrorCode에 정의되어 있다. BusinessExceptionErrorCode에 미리 정의되어 있는 상태 코드, 자체 코드, 메세지를 사용하여 인스턴스를 생성할 것이다.

HTTP 응답시 makeResponseEntity 메소드를 사용하여 ResponseEntity를 만들 수 있도록 했다. 이 때 생성자에서 지정된 메세지가 있으면 그 메세지를 사용하고, 없으면 ErrorCodemessage를 사용하도록 했다. (생성자를 통해 받은 message는 상위 객체 Throwable에 저장된다.)

또한 동적으로 생성될 필요가 없는 예외의 경우 캐싱을 해 두었다. 커스텀 예외의 비용 절감에 대해서는 Java Exception 생성 비용은 비싸다. 글을 참고했다.



@RestControllerAdvice

@RestControllerAdvice를 통해 예외 처리 로직을 분리했다.


@Slf4j
@RestControllerAdvice
public class RestExceptionHandlerAdvice {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(final BusinessException e) {
        return e.makeResponseEntity();
    }
    
    @ExceptionHandler(MethodArgumentNotValidException.class)  // @Validated 바인딩 오류
    public ResponseEntity<ErrorResponse> handleBusinessException(final MethodArgumentNotValidException e) {
        return new ResponseEntity<>(new ErrorResponse(ErrorCode.INVALID_INPUT, e.getAllErrors().get(0).getDefaultMessage()), HttpStatus.BAD_REQUEST);
    }
}

예를 들어, 컨트롤러에서 throw BusinessException.COMMENT_HAS_CHILD를 한 경우 RestExceptionHandlerAdvice에서 이 예외를 받아 처리한다.

첫 번째 메소드 RestExceptionHandlerAdviceBusinessException을 처리한다. 파라미터로 받은 BusinessExceptionmakeResponseEntity 메소드를 통해 ResponseEntity를 만들어 반환한다. (ResponseEntity는 HTTP 응답을 추상화한 객체이다. HttpEntity의 확장이며 헤더와 바디 뿐 아니라 HTTP 상태 코드도 설정할 수 있다.)


두 번째 메소드 handleValidationException의 경우 Bean Validation 유효성 검증에서 발생하는 MethodArgumentNotValidException 오류를 핸들링한다. 이것을 ErrorCode로 관리하는게 맞나 고민했는데, 내가 의도적으로 발생시키는 예측 가능한 비즈니스 오류이기 때문에 ErrorCode에서 다루기로 했다.

단, 이것은 검증 내용에 따라 오류메세지가 달라지기 때문에 handleValidationException에서 직접 ResponseEntity를 만들어 반환하도록 했다.



예외 처리는 어렵다.

그래도 리팩토링을 통해 컨트롤러 로직이 더 깔끔해지고, 예외 처리를 한 곳에서 처리함으로써 유지보수성도 전보다 훨씬 좋아진 것 같다. 😀👍




🔗 References

0개의 댓글