cso6005 / TIL-Troubleshooting

배움 기록 및 트러블 슈팅 정리
0 stars 0 forks source link

[Spring Boot] 예외 처리 ExceptionHandler 예시 #32

Open cso6005 opened 1 year ago

cso6005 commented 1 year ago

1. RestAPI 요청 시, 발생할 수 있는 400 대 ClientRequest(Series.CLIENT_ERROR)에러와 사용자 정의 예외 처리를 제외한 모든 500 에러(Internal Server Error) 예외 처리 알아보기

⇒ “스프링 예외를 미리 처리해둔 추상 클래스ResponseEntityExceptionHandler

를 상속받을 것인데, 내가 클라이언트에게 응답하고자 하는 페이로드로 재정의한다."

1. 먼저, 코드 작성 전에 아래 두 가지를 살펴봐야 한다.

  1. HttpStatus(org.springframework.http.HttpStatus)

    아래 url 확인
    [spring-framework/HttpStatus.java at 4.3.x · spring-projects/spring-framework](https://github.com/spring-projects/spring-framework/blob/4.3.x/spring-web/src/main/java/org/springframework/http/HttpStatus.java#L505)

        // --- 4xx Client Error ---

    /**
     * {@code 400 Bad Request}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.1">HTTP/1.1: Semantics and Content, section 6.5.1</a>
     */
    BAD_REQUEST(400, Series.CLIENT_ERROR, "Bad Request"),
    /**
     * {@code 401 Unauthorized}.
     * @see <a href="https://tools.ietf.org/html/rfc7235#section-3.1">HTTP/1.1: Authentication, section 3.1</a>
     */
    UNAUTHORIZED(401, Series.CLIENT_ERROR, "Unauthorized"),
    /**
     * {@code 402 Payment Required}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.2">HTTP/1.1: Semantics and Content, section 6.5.2</a>
     */
    PAYMENT_REQUIRED(402, Series.CLIENT_ERROR, "Payment Required"),
    /**
     * {@code 403 Forbidden}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.3">HTTP/1.1: Semantics and Content, section 6.5.3</a>
     */
    FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"),
    /**
     * {@code 404 Not Found}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.4">HTTP/1.1: Semantics and Content, section 6.5.4</a>
     */
    NOT_FOUND(404, Series.CLIENT_ERROR, "Not Found"),
    /**
     * {@code 405 Method Not Allowed}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.5">HTTP/1.1: Semantics and Content, section 6.5.5</a>
     */
    METHOD_NOT_ALLOWED(405, Series.CLIENT_ERROR, "Method Not Allowed"),
    /**
     * {@code 406 Not Acceptable}.
     * @see <a href="https://tools.ietf.org/html/rfc7231#section-6.5.6">HTTP/1.1: Semantics and Content, section 6.5.6</a>
     */

스프링에서는 HttpStatus 하위에 내부 Enum으로 아래처럼 정의하고 있다. 약 80 여 개의 응답코드가 200대, 300대, 400대, 500대 로 나눠 정의되고 있다. 여기서 난 400대 에러와 500 에러를 잡을 것이기 때문에 이에 집중할 것이다. 코드 설명에서 나오겠지만, 내가 예외 처리하고자 하는 에러들을 enum 에 정의하여 한 곳에 모일 것인데, 이때 이 HttpStatus 를 보면서 진행할 것이다. 일단, 코드를 대강 보고 넘어가자.

  1. ResponseEntityExceptionHandler(org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler) 아래 url 확인 [spring-framework/ResponseEntityExceptionHandler.java at main · spring-projects/spring-framework](https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java)

위 코드를 보면 스프링에서 지원하는 Exception 이 모두 나온다. 이를 GlobalExceptionHandler (@RestControllerAdvice) 가 상속 받아 예외 처리를 진행 할 것인데, 이때, 살펴봐야 하는 부분은 106번 줄이다.

@ExceptionHandler({
            HttpRequestMethodNotSupportedException.class,
            HttpMediaTypeNotSupportedException.class,
            HttpMediaTypeNotAcceptableException.class,
            MissingPathVariableException.class,
            MissingServletRequestParameterException.class,
            ServletRequestBindingException.class,
            ConversionNotSupportedException.class,
            TypeMismatchException.class,
            HttpMessageNotReadableException.class,
            HttpMessageNotWritableException.class,
            MethodArgumentNotValidException.class,
            MissingServletRequestPartException.class,
            BindException.class,
            NoHandlerFoundException.class,
            AsyncRequestTimeoutException.class
        })

⭐ 중요 !!

위 Exception 의 경우에는 ResponseEntityExceptionHandler 에 내장되어 있는 Exception 으로 @ExceptionHandler(처리하고자하는 예외.class) 과 같은 방식으로 진행 하면 안 된다.

이미 구현되어 있는 예외인데, 이를 GlobalExceptionHandler 에서 동일한 예외처리를 하게 되면, Ambiguous 모호성 문제가 발생하게 된다. 즉, 이 예외에 대해 둘 중 어떤 방법으로 처리할 지를 스프링이 판단할 수 없게 되는 것이다.

그렇기에 위 예외의 경우에는 @ExceptionHandler 어노테이션 대신, ResponseEntityExceptionHandler 을 상속받아 오버라이딩하여 진행해야 한다.

방법은 코드에서 설명.

⭐ 중요 !!

2. 처리하고자 하는 클라이언트 에러를 정하고, 그 때 발생하는 HttpStatus 와 Exception 종류 알아보기

클라이언트 요청 시 발생할 수 있는 에러에 대해 백엔드에서는 어떤 Exception 이 발생하는지, 어떤 HttpStauts 를 보내는지에 대해 알아야 한다.

“GET API 를 POST 로 요청하였을 때 발생하는 에러로 예시를 들어본다.”

1) postman 나 뭐든 이용하여 클라이언트에서 Rest API 를 엉망으로 요청 해보자.

일단, GET 요청 API 를 POST 로 요청하였을 때 발생하는 에러로 예시를 들어본다.

2) 스프링부트에서 발생한 Exception 확인하기

[2023-01-30 21:18:08.748] [WARN ] [http-nio-8080-exec-6] o.s.w.s.m.m.a.ExceptionHandlerExceptionResolver Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' not supported]

스프링부트로 가서 콘솔창을 보면, ‘Request method 'POST' not supported’ 으로 인해 발생한 Exception 이 HttpRequestMethodNotSupportedException 임을 알 수 있다.

3) 응답 결과 확인하기

포스트맨 으로 요청하였을 때 위 사진과 같이 포스트맨에서는

응답 결과를 Status : 405Method Not AllowedTime 로 알려준다. 이를 통해, GET 요청 API 를 POST 로 요청하는 즉, 메소드 매칭이 안 되는 경우에 405번 에러가 발생하는 것을 알 수 있다.

그리고 아까 살펴본HttpStatus(org.springframework.http.HttpStatus) 를 통해서 이 에러는

METHOD_NOT_ALLOWED(405, Series.CLIENT_ERROR, "Method Not Allowed")

즉, HttpStatus가 METHOD_NOT_ALLOWED ****임을 알 수 있다.

즉, 정리하자면

클라이언트가 발생시킬 수 있는 여러 에러 상황을 포스트맨으로 발생시켜, 그에 대한 응답 결과를 보고 어떤 HttpStatus 인지 알아보는 것이다. !!

3-2) ResponseEntityExceptionHandler (org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler) 를 보는 것이다.

해당 에러 상황에서 스프링 부트에서 발생하는 특정 Exception 을 안다면 알 수 있다.

[spring-framework/ResponseEntityExceptionHandler.java at main · spring-projects/spring-framework](https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java)

@Nullable
    public final ResponseEntity<Object> handleException(Exception ex, WebRequest request) throws Exception {
        HttpHeaders headers = new HttpHeaders();

        if (ex instanceof HttpRequestMethodNotSupportedException) {
            HttpStatus status = HttpStatus.METHOD_NOT_ALLOWED;
            return handleHttpRequestMethodNotSupported((HttpRequestMethodNotSupportedException) ex, headers, status, request);
        }
        else if (ex instanceof HttpMediaTypeNotSupportedException) {
            HttpStatus status = HttpStatus.UNSUPPORTED_MEDIA_TYPE;
            return handleHttpMediaTypeNotSupported((HttpMediaTypeNotSupportedException) ex, headers, status, request);
        }
        else if (ex instanceof HttpMediaTypeNotAcceptableException) {
            HttpStatus status = HttpStatus.NOT_ACCEPTABLE;
            return handleHttpMediaTypeNotAcceptable((HttpMediaTypeNotAcceptableException) ex, headers, status, request);
        }
        else if (ex instanceof MissingPathVariableException) {
            HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
            return handleMissingPathVariable((MissingPathVariableException) ex, headers, status, request);
        }
        else if (ex instanceof MissingServletRequestParameterException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleMissingServletRequestParameter((MissingServletRequestParameterException) ex, headers, status, request);
        }
        else if (ex instanceof ServletRequestBindingException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleServletRequestBindingException((ServletRequestBindingException) ex, headers, status, request);
        }
        else if (ex instanceof ConversionNotSupportedException) {
            HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
            return handleConversionNotSupported((ConversionNotSupportedException) ex, headers, status, request);
        }
        else if (ex instanceof TypeMismatchException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleTypeMismatch((TypeMismatchException) ex, headers, status, request);
        }
        else if (ex instanceof HttpMessageNotReadableException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleHttpMessageNotReadable((HttpMessageNotReadableException) ex, headers, status, request);
        }
        else if (ex instanceof HttpMessageNotWritableException) {
            HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR;
            return handleHttpMessageNotWritable((HttpMessageNotWritableException) ex, headers, status, request);
        }
        else if (ex instanceof MethodArgumentNotValidException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleMethodArgumentNotValid((MethodArgumentNotValidException) ex, headers, status, request);
        }
        else if (ex instanceof MissingServletRequestPartException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleMissingServletRequestPart((MissingServletRequestPartException) ex, headers, status, request);
        }
        else if (ex instanceof BindException) {
            HttpStatus status = HttpStatus.BAD_REQUEST;
            return handleBindException((BindException) ex, headers, status, request);
        }
        else if (ex instanceof NoHandlerFoundException) {
            HttpStatus status = HttpStatus.NOT_FOUND;
            return handleNoHandlerFoundException((NoHandlerFoundException) ex, headers, status, request);
        }
        else if (ex instanceof AsyncRequestTimeoutException) {
            HttpStatus status = HttpStatus.SERVICE_UNAVAILABLE;
            return handleAsyncRequestTimeoutException((AsyncRequestTimeoutException) ex, headers, status, request);
        }
        else {
            // Unknown exception, typically a wrapper with a common MVC exception as cause
            // (since @ExceptionHandler type declarations also match first-level causes):
            // We only deal with top-level MVC exceptions here, so let's rethrow the given
            // exception for further processing through the HandlerExceptionResolver chain.
            throw ex;
        }
    }

4) 우린, 위 작업을 통해, GET 을 POST 으로 잘 못한 요청에 대해 스프링부트에서 HttpRequestMethodNotSupportedException 을 발생시키고 HttpStatus 는 405 METHOD_NOT_ALLOWED 를 응답함을 알 수 있다.

각 에러 마다 이 두 가지를 알아야, 코드를 작성할 수 있으므로, 다른 에러 또한 위 작업으로 이 둘을 알아보아야 한다.

아래는 내가 잡은 에러에 대해 정리한 표이다.

**** 정상 요청 ⇒ .get([http://localhost:8080/elk/getWeather?id=”서울”])

HttpStatus | HttpStatus.value() | series | message | Exception | 처리 방식 -- | -- | -- | -- | -- | -- HttpStatus.BAD_REQUEST | 400 | Series.CLIENT_ERROR | 잘못된 파라미터가 존재하는 경우 예를 들어, Params 의 값을 넣지 않은 경우(http://localhost:8080/elk/getWeather?id=) | IllegalArgumentException.class | 핸들러 사용 @ExceptionHandler(IllegalArgumentException.class) HttpStatus.BAD_REQUEST | 400 | Series.CLIENT_ERROR | 메소드 요청 매개 변수 자체가 없는 경우 (http://localhost:8080/elk/getWeather) | handleMissingServletRequestParameter | @Override 사용 handleMissingServletRequestParameter 를 오버라이딩 HttpStatus.UNAUTHORIZED | 401 | Series.CLIENT_ERROR | 권한 정보 없는 토큰인 경우 (JWT 적용 프로젝트의 경우 발생) | handleAccessDeniedException | 핸들러 사용 @ExceptionHandler(AccessDeniedException.class) HttpStatus.NOT_FOUND | 404 | Series.CLIENT_ERROR | 리소스가 존재하지 않을 경우. 즉 URL 자체가 틀린 경우 (http://localhost:8080/elk/getWeatheraaaaaaaa) |   | 이는 아예 URL 이 틀렸기에 Controller 에 도착 조차 안 한다. 그러므로 스프링부트에서 처리할 수 없다. 서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달하여, 기본 페이로드를 내보낸다. HttpStatus.METHOD_NOT_ALLOWED | 405 | Series.CLIENT_ERROR | 메소드 매칭이 되지 않은 경우 즉, HTTP Method (GET, PSOT, ,PUT, DELETE) 요청이 잘 못 된 경우 .post(http://localhost:8080/elk/getWeather?id=”서울”) | handleHttpRequestMethodNotSupported | @Override 사용 handleHttpRequestMethodNotSupported 를 오버라이딩 HttpStatus.INTERNAL_SERVER_ERROR | 500 | Series.SERVER_ERROR | 서버 에러로 Runtime 에러 등으로 무수히 많은 Exception 이 존재한다. 이를 모두 예측하기 어렵기에 예측 가능했던 즉, 내가 처리한 예외를 제외한 모든 예외는 Exception.Class 로 Handling 한 ExceptionHandler 로 처리한다. (**Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다.) | Exception | 핸들러 사용 @ExceptionHandler(Exception.class) HttpStatus.INTERNAL_SERVER_ERROR | 500 |   | 사용자 정의 예외 이 예외 처리는 온전히 내가 만든 class 임을 알자. |   |  

이렇게, 여러 에러 상황에 대한 HttpStatus 와 발생할 Exception 종류에 대해 알아보았다면

코드를 작성해보자.

cso6005 commented 1 year ago

2. 우리가 직접 만드는 CustomException 사용자 정의 예외 처리

스프링에 제공하는 예외를 넘어, 우리가 세부적으로 예외를 처리하고 싶을 때가 있다.

그때 우리는 사용자 정의 예외 처리가 가능하다.

RuntimeException 을 상속받아 CustomException 클래스를 만든다.

그리고 이 에러들은 당연히 서버 내 예외이므로

HttpStatus 설정은 500 HttpStatus.INTERNAL_SERVER_ERROR 로 진행하면 된다.

이 두 가지로 인지하고 GlobalExceptionHandler에서 CustomExceptionHandler 를 만들면 된다.

1번에서 말한 클라이언트 요청 예외 처리나 사용자 정의 예외 처리나

Exception 의 종류와 HttpStaus 만 다르지, 코드 진행은 거의 유사하다는 것을 알아두자.

코드를 보면 이해하기 쉬울 것이다.

3. 코드 작성 전에 알아야 할 것과 정해야 할 것!

  1. 예외 처리를 딥딥하게 공부를 했다면, @ResponseStatus 를 사용한 예외 처리 방법도 있다는 것을 알 것이다.

    우리가 사용할 방식인 ExceptionHandler는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 그 외에도 여러 불편점이 많기에 잘 사용하지 않는다.

    근데 만약 ResponseEntity에서도 status를 지정하고 @ResponseStatus도 있다면 ResponseEntity가 우선순위를 갖는다. 사실 뭐 코드 작성에 필요한 개념은 아니라 그냥 알아만 두자.

  2. RestControllerAdvice vs ControllerAdvice ??

    @Controller, @RestController 에서 발생하는 예외를 한 곳에서 관리하고 처리할 수있게 하는 어노테이션 이다. 두 개념과 동일하게 advice 또한 결과 반환에 대해 차이가 있다.

    ⇒ 예외 발생 시 json 의 형태로 결과를 반환하기 위해서는 @RestControllerAdvice 를 사용하면 된다.

  3. ControllerAdvice 의 갯수?? 하나?? 여러 개?? 컨트롤러 당??

    여러 ControllerAdvice 가 있다면, @Order 어노테이션으로 순서를 지정해야 한다. 그렇지 않다면 Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있다. 그러므로

    • 직접 구현한 Exception 클래스들은 한 공간에서 관리해야하며, 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
    • 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
  4. Handler 를 글로벌 예외처리 vs Controller 에 예외처리 방식 정하기

    한 곳에 정리하는 것이 좋아 글로벌 예외처리 로 진행하자.

  5. 페이로드 지정. 클라이언트에게 보낼 응답 메시지는 뭘로??

    ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest 등을 얻을 수 있으며 반환 타입으로 ResponseEntity, String, void 등 자유롭게 활용할 수 있다. (더 많은 입력/반환 타입을 위해서는 [공식 문서](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/bind/annotation/ExceptionHandler.html) 를 참고하도록 하자.)

이를 참고하여, 자기가 원하는 페이로드를 정하자.

혹시 내가 처리하지 못한 부분에 있어 기본 페이로드가 나갈 것(405 에러와 같은 경우 …)을 생각하여, ErrorResponse 는 다음과 같이 내보낼 예정이다.

{
    "timestamp": "Tue Jan 31 01:03:44 KST 2023",
    "status": 500,
    "error": "ELK_NOT_CONNECT",
    "message": "Elastic Server 가 꺼져있습니다. Timeout connecting to [/127.0.0.1:9200]",
    "path": "/elk/getWeather"
}
cso6005 commented 1 year ago

4. 코드 작성

1. 에러 코드 Enum 정의 하기

에러 코드 클래스는

애플리케이션의 전반적 Exception 을 잡는 CommonErrorCode 와

사용자 정의 Exception 을 잡는 UserErrorCode 로 나누었다.

사실, 둘을 한 Class 로 묶어서 진행해도 무방하다.

난 나눴기에 CommonErrorCode 와 UserErrorCode의 공통 메소드로 추상화할 인터페이스를 먼저 정의 해준다.

package io.star.exception;

import org.springframework.http.HttpStatus;

public interface ErrorCode {

    String name(); // 내가 지정해줄 에러 이름

    HttpStatus getHttpStatus(); 

    String getMessage();

}

2번 작업에서 알게 된 HttpStatus 를 기반으로 httpStatus와 해당하는 message 가 담긴 CommonErrorCode 를 작성해준다. 해당 message는 클라이언트에게 보내질 메시지이다.

package io.star.exception;

import org.springframework.http.HttpStatus;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum CommonErrorCode implements ErrorCode {

        //400
        INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 파라미터가 존재합니다. 확인 후 다시 시도해주세요."),
        MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "메서드 매개 변수 유형에 필요한 요청 매개 변수 'id'가 없습니다"),

    //401
    INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "권한 정보가 없는 토큰입니다."),

    //404 
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스가 존재하지 않습니다. 확인 후 다시 시도해주세요."),

    //405
    INVALID_METHOD(HttpStatus.METHOD_NOT_ALLOWED ,"메소드 매칭이 되지 않습니다. 확인 후 다시 시도해주세요"),

    //500 (위 설정한 예외 제외한 RuntimeException 을 포함한 모든 Exception Handler 처리)
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러입니다. 서버팀에 문의해주세요."),
    ;

    private final HttpStatus httpStatus;

    private final String message;

}

현재 진행중인 elk 관련 프로젝트 내에서 진행하였기에

elk 관련 메소드 요청에서 elastic Sever가 꺼져 있을 경우에 발생될 예외에 대한 처리로 예시를 들 것이다.

UserErrorCode 또한 CommonErrorCode 와 같이 HttpStatus 와 클라이언트에게 보낼 message 를 작성해준다.

2번의 설명했듯이 사용자 정의 예외의 경우, HttpStatus.INTERNAL_SERVER_ERROR 를 넣어주면 된다.

package io.star.exception;

import org.springframework.http.HttpStatus;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UserErrorCode implements ErrorCode {

    ELK_NOT_CONNECT(HttpStatus.INTERNAL_SERVER_ERROR, "Elastic Server 가 꺼져있습니다. Timeout connecting to [/127.0.0.1:9200]"),

    ;

    private final HttpStatus httpStatus;
    private final String message;

}

2. 에러 응답 클래스 생성

{
    "timestamp": "2023-01-30T13:32:27.969+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/elk/getWeather%E3%85%8E%E3%85%8E%E3%85%8E"
}
package io.star.exception;

import java.util.Date;

import javax.servlet.http.HttpServletRequest;

import org.springframework.http.ResponseEntity;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class ErrorResponse {

    private String timestamp; // 발생 시간

    private int status; // 에러 코드 번호

    private String error; // 에러명

    private String message; // 메시지

    private String path; // 요청 url

    // commonErrorResponse 처리를 위한 
    public static ResponseEntity<Object> toCommonErrorResponse(CommonErrorCode e, String requestURL) {

        return ResponseEntity.status(e.getHttpStatus())
                .body(ErrorResponse.builder()
                        .timestamp(new Date().toString())
                        .status(e.getHttpStatus().value())
                        .error(e.name())
                        .message(e.getMessage())
                        .path(requestURL).build());
    }

    // UserErrorCode 처리를 위한 
    public static ResponseEntity<Object> toUserErrorResponse(UserErrorCode e, String requestURL) {

        return ResponseEntity.status(e.getHttpStatus())
                .body(ErrorResponse.builder()
                        .timestamp(new Date().toString())
                        .status(e.getHttpStatus().value())
                        .error(e.name())
                        .message(e.getMessage())
                        .path(requestURL).build());
    }

}

toCommonErrorResponse() 과 toUserErrorResponse() 는 globalExceptionHandler() 에서 각 핸들러의 리턴 값에 들어갈 메소드이다.

각 핸들러에서 호출하여, CommonErrorCode 또는 UserErrorCode 와 request.getRequestUrl 를 매개변수로 받아, ResponseEntity 를 리턴한다.

그리고 이 리턴값이 클라이언트에게 보내는 응답이다.!!

우리가 상속 받는 ResponseEntityExceptionHandler 의 경우, ResponseEntity 를 반환한다.

사실, ResponseEntity 는 응답으로 내보내고자 했고 사실 상 이 값이 내보내지는 것은 맞지만,

아래에서도 설명하겠지만, 핸들러 사용이 되지 않고 오버라이딩으로 구현해야하는 예외들이 있다. 오버라이딩은 메소드의 선언부가 기존 메소드와 완전히 같아야 한다. 그러므로 통일성을 위해 모든 핸들러의 반환값을 ResponseEntity 로 작성하였다.

3. 사용자 정의 예외 클래스 생성

우리가 발생한 예외를 처리해줄 예외 클래스 customException Class를 추가해준다.

언체크 예외(RuntimeException)를 상속받는 예외 클래스를 다음과 같이 추가해준다.

최근에는 거의 체크 예외가 아닌 언체크 예외를 사용한다.

체크 예외가 아닌 언체크 예외를 상속받도록 한 이유는 크게 두 가지 이다.

  • 일반적인 비지니스 로직들은 따로 catch해서 처리할 것이 없므로 만약 체크 예외로 한다면 불필요하게 throws가 전파될 것이기 때문에
  • 스프링은 내부적으로 발생한 예외를 확인하여, 언체크 예외를 자동으로 롤백시키도록 처리하기 때문에

사용자 정의 에러는 UserErrorCode Enum 에 정의해야 했으므로 이를 변수로 둔다.

package io.star.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class CustomException extends RuntimeException {

    UserErrorCode errorCode;

}

4. @RestControllerAdvice 클래스 생성

이제 제일 중요한 작업이다.

전역적으로 에러를 처리해주는 @RestControllerAdvice 클래스를 생성해야줘야 한다.

package io.star.exception;

import java.nio.file.AccessDeniedException;

import javax.servlet.http.HttpServletRequest;

import org.elasticsearch.ResourceNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
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
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler{

        // 로그백 처리. 로그 기록
    private final Logger LOGGER = LoggerFactory.getLogger(this.getClass());

    // 1) 사용자 정의 예외처리
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> customExceptionHandler(CustomException ex, HttpServletRequest request) {

        UserErrorCode errorCode = ex.getErrorCode();

        return ErrorResponse.toUserErrorResponse(errorCode, request.getRequestURI());

    }

    // 2) 클라이언트 요청 에러 - 핸들러 사용
    // 400 : INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 파라미터가 존재합니다. 확인 후 다시 시도해주세요."),
    @ExceptionHandler(IllegalArgumentException.class) //대개 메소드의 매개변수 유형을 잘 못 사용하는 경우
    public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex, HttpServletRequest request) {

        CommonErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        LOGGER.warn("HandleIllegalArgumentException:CLIENT REQUEST ERROR:INVALID_PARAMETER: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getRequestURI());

    }

        // 3) 클라이언트 요청 에러 - 오버라이딩
        // 400 MISSING_PARAMETER(HttpStatus.BAD_REQUEST, "메서드 매개 변수 유형에 필요한 요청 매개 변수 'id'가 없습니다"),
    @Override    
        protected ResponseEntity<Object> handleMissingServletRequestParameter(MissingServletRequestParameterException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {     

            CommonErrorCode errorCode = CommonErrorCode.MISSING_PARAMETER;
            LOGGER.warn("HandleHttpRequestMethodNotSupportedException:CLIENT REQUEST ERROR:MISSING_PARAMETER: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getDescription(false).substring(4));

    }

    // 2) 클라이언트 요청 에러 - 핸들러 사용
    // 401 : INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "권한 정보가 없는 토큰입니다."),
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<Object> handleAccessDeniedException(AccessDeniedException ex, HttpServletRequest request) {

        CommonErrorCode errorCode = CommonErrorCode.INVALID_AUTH_TOKEN;
        LOGGER.warn("HandleAccessDeniedException:CLIENT REQUEST ERROR:INVALID_AUTH_TOKEN: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getRequestURI());

    }

        // 3) 클라이언트 요청 에러 - 오버라이딩
    // 405 : INVALID_METHOD(HttpStatus.METHOD_NOT_ALLOWED ,"메소드 매칭이 되지 않습니다. 확인 후 다시 시도해주세요"),
    @Override    
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {        

        CommonErrorCode errorCode = CommonErrorCode.INVALID_METHOD;
        LOGGER.warn("HandleHttpRequestMethodNotSupportedException:CLIENT REQUEST ERROR:INVALID_METHOD: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getDescription(false).substring(4));

    }

    // 2) 클라이언트 요청 에러 - 핸들러 사용
    //그 외 서버 에러 모두 
    // 500 : NTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러입니다. 서버팀에 문의해주세요."),
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> exceptionHandler(Exception ex, HttpServletRequest request ) {

        CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
        LOGGER.warn("HandleException: SERVER ERROR :{}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getRequestURI());

    }
  1. ResponseEntityExceptionHandler 를 상속받기

    @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex, HttpServletRequest request) {

    아까 말했듯이 스프링은 예외처리에 대해 ResponseEntityExceptionHandler 를 제공한다.

    이를 @RestControllerAdvice 클래스가 상속 받게 한다.

  2. 핸들러 어노테이션

    @ExceptionHandler(00Exception.class)

    • 예외 처리 상황이 발생하며 해당 Handler 로 처리하겠다고 명시하는 어노테이션

    • 어노테이션 뒤에 괄호를 붙여 어떤 ExceptionClass 를 처리할지 설정할 수 있음

      • @ExceptionHandler(00Exception.class)
    • Exception.class는 최상위 클래스로 하위 세부 예외처리 클래스로 설정한 핸들러가 존재하면, 그 핸들러가 우선처리하게 되며, 처리 되지 못하는 예외처리에 대해 ExceptionClass 에서 핸들링함.

    • 여기서 적어준 @ExceptionHandler(IllegalArgumentException.class) 예외 클래스와

      핸들러의 매개변수 public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex)

      예외 클래스는 동일해야 한다.

    • 그러나, 아래에도 나오지만 Ambiguous 모호성 문제에 의하여 핸들러와 처리하지 못하는 예외가 있다. 이는 오버라이딩으로 처리해야 한다.

  3. 각각 예외에 대한 핸들러 만들어주기

위 핸들러를 세 가지로 구분하고 코드 또한 나눠서 설명하겠다.

1) 사용자 정의 예외처리

    // 1) 사용자 정의 예외처리
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<Object> customExceptionHandler(CustomException ex, HttpServletRequest request) {

        UserErrorCode errorCode = ex.getErrorCode();

        return ErrorResponse.toUserErrorResponse(errorCode, request.getRequestURI());

    }

나중에 아래에서 customException 를 컨트롤러나 서비스에 구현하는 코드를 작성해볼 것인데

지금 설명을 위해 예외를 던지는 부분을 쬐큼 들고 왔다.

throw new CustomException(UserErrorCode.ELK_NOT_CONNECT);

이런 식으로 아까 만든 customException 클래스 객체에

발생한 예외에 맞게 UserErrorCode 를 (지금은 ELK_NOT_CONNECT로 서버가 꺼져있을 때 발생하는 예외 즉, 아까 내가 정의한 예외) 매개변수로 하여 예외를 던진다.

이를 위 customExceptionHandler 가 잡는 것이다.

아까 설명했듯이 핸들러는 HttpServletRequest 등 여러 매개변수를 가지는데

난 응답 메시지로 requestURL이 필요하므로 HttpServletRequest 를 추가하였다.

ex.getErrorCode() 으로 던진 CustomException에 대해 해당하는 UserErrorCode 를 받는다.

그리고 아까 ErrorResponse 클래스의 메소드 toUserErrorResponse() 를 호출하고 그 반환값인

클라이언트 응답 페이로드를 리턴 시킨다.

즉, 이렇게 핸들러에서 각 예외에 맞는 ResponseEntity 를 리턴한다면, 그 리턴값 대로 클라이언트에게 응답으로 보내지는 것이다.

2) 클라이언트 요청 에러 - @ExceptionHandler 어노테이션 사용

사실 1) 사용자 정의 예외 처리와 다른 것은 CommonErrorCode 를 받는다는 것 뿐이다.

이렇게 핸들러만 작성한다면, 잘못된 파라미터가 존재하는 클라이언트 요청 에러에 대해 우리가 정의한 CommonErrorCode.INVALID_PARAMETER 예외 처리가

아래 코드 리턴 값 대로 응답할 것이다.

    // 2) 클라이언트 요청 에러 - 핸들러 사용
    // 400 : INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 파라미터가 존재합니다. 확인 후 다시 시도해주세요."),
    @ExceptionHandler(IllegalArgumentException.class) //대개 메소드의 매개변수 유형을 잘 못 사용하는 경우
    public ResponseEntity<Object> handleIllegalArgumentException(IllegalArgumentException ex, HttpServletRequest request) {

        CommonErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;

        LOGGER.warn("HandleIllegalArgumentException:CLIENT REQUEST ERROR:INVALID_PARAMETER: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getRequestURI());

    }

아래 3) 에서 설명하겠지만, 오버라이딩 해야 하는 예외를 제외하곤 핸들러 어노테이션으로 구현 가능하다.

** 그리고 위 코드는 로그백 처리가 되어있다.

예외 처리에 대해 로그 기록을 남기면 좋지만, 일단 예외 처리와 관련있는 코드는 아니니 빼도 된다.

3) 클라이언트 요청 에러 - 오버라이딩 사용

사실, 이 부분이 제일 애먹었다 ^~^ …

모든 예외가 핸들러 어노테이션으로 통하는 줄 알았는데 아니였다…

아까 위에서 설명한 내용을 다시 설명해보겠다.

ResponseEntityExceptionHandler(org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler)

[spring-framework/ResponseEntityExceptionHandler.java at main · spring-projects/spring-framework](https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseEntityExceptionHandler.java)

@ExceptionHandler({
            HttpRequestMethodNotSupportedException.class,
            HttpMediaTypeNotSupportedException.class,
            HttpMediaTypeNotAcceptableException.class,
            MissingPathVariableException.class,
            MissingServletRequestParameterException.class,
            ServletRequestBindingException.class,
            ConversionNotSupportedException.class,
            TypeMismatchException.class,
            HttpMessageNotReadableException.class,
            HttpMessageNotWritableException.class,
            MethodArgumentNotValidException.class,
            MissingServletRequestPartException.class,
            BindException.class,
            NoHandlerFoundException.class,
            AsyncRequestTimeoutException.class
        })

위 Exception 의 경우에는 ResponseEntityExceptionHandler 에 내장되어 있는 Exception 으로 @ExceptionHandler(처리하고자하는 예외.class) 과 같은 방식으로 진행 하면 안 된다.

이미 구현되어 있는 예외인데, 이를 GlobalExceptionHandler 에서 동일한 예외처리를 하게 되면, Ambiguous 모호성 문제가 발생하게 된다. 즉, 이 예외에 대해 둘 중 어떤 방법으로 처리할 지를 스프링이 판단할 수 없게 되는 것이다.

그렇기에 위 예외의 경우에는 @ExceptionHandler 어노테이션 대신, ResponseEntityExceptionHandler 을 상속받아 오버라이딩하여 진행해야 한다.

이 예외를 핸들러 사용 시 발생하는 에러에 대해서 자세히는 따로 트러블 슈팅에 정리할 것이다.

자, 그럼 다시 코드에 대해 설명해보자.


    // 3) 클라이언트 요청 에러 - 오버라이딩
  // 405 : INVALID_METHOD(HttpStatus.METHOD_NOT_ALLOWED ,"메소드 매칭이 되지 않습니다. 확인 후 다시 시도해주세요"),
  @Override    
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {        

        CommonErrorCode errorCode = CommonErrorCode.INVALID_METHOD;
        LOGGER.warn("HandleHttpRequestMethodNotSupportedException:CLIENT REQUEST ERROR:INVALID_METHOD: {}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getDescription(false).substring(4));

    }
  • 오버라이딩의 경우, 부모클래스와 메소드 선언부가 동일해야 하기에 동일한 핸들러 클래스를 만들어야 한다.

ResponseEntityExceptionHandler 에서 handleHttpRequestMethodNotSupported() 메소드를 찾아서 이 메소드와 동일한 핸들러를 만들어야 준다.

protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {

        pageNotFoundLogger.warn(ex.getMessage());

        Set<HttpMethod> supportedMethods = ex.getSupportedHttpMethods();
        if (!CollectionUtils.isEmpty(supportedMethods)) {
            headers.setAllow(supportedMethods);
        }
        return handleExceptionInternal(ex, null, headers, status, request);
    }

그래서 반환값은 ResponseEntity 이 되어야 한다.

이 오버라이딩 이전에는 ResponseEntity 로 반환값을 작성하였다.

오버라이딩으로 어쩔 수 없이 ResponseEntity 를 리턴값 타입으로 지정해야 하므로 다른 핸들러도 포함하여 모든 핸들러의 리턴값을 ResponseEntity 로 바꾸어 주었다.

  • 또한, 선언부 통일로 인해 HttpServeltReqeust 가 아닌, WebRequest request 를 매개변수로 가지게 된다.

    WebRequest 에서 request.getDescription(false) 는 url 을 반환한다. 이를 requestURL 즉, path 로 넣으면 된다.

    근데, 그대로 내보내면, “url=” 이 앞에 붙여져서 내보내게 된다.

    다른 핸들러 응답과 통일성을 위해 인덱스 0:3 를 잘라준다.

    request.getDescription(false).substring(4)

  • 다른 부분은 1) 2) 핸들러에서의 설명과 동일하다.

4) 사용자 정의 예외를 제외한 모든 서버 에러에 대해 핸들링

// 500 : NTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "내부 서버 에러입니다. 서버팀에 문의해주세요."),
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> exceptionHandler(Exception ex, HttpServletRequest request ) {

        CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
        LOGGER.warn("HandleException: SERVER ERROR :{}", ex.getMessage());

        return ErrorResponse.toCommonErrorResponse(errorCode, request.getRequestURI());

    }

하나 더 설명하자면, 위 핸들러는 사용자 정의 예외를 제외한 모든 서버 에러에 대해 핸들링 한다.

스프링 예외처리의 경우, 세부 exception인 자식 예외를 먼저 처리하고 점점 부모 exception로 올라가며 처리한다. 즉, 에러가 발생했을 때, 그 에러에 대한 처리는 먼저, 세부 예외 처리를 한 핸들링을 살펴보고 없다면 점점 부모로 올라가며 살펴본다.

결국 정의한 세부 예외 처리가 없다면 exception.class 를 핸들링하는 위 핸들러가 받아 처리할 것이다.

그리고 서버 내부 에러를 클라이언트에게 상세히 보여줄 필요는 없다. 오히려 좋지 않을 것.

그래서 해당 CommonErrorCode.INTERNAL_SERVER_ERROR 의 경우 메시지는

"내부 서버 에러입니다. 서버팀에 문의해주세요.”

정도로 작성하면 좋을 것 같다.

자, 이렇게 하면

RestAPI 요청 시, 발생할 수 있는 400 대 ClientRequest(Series.CLIENT_ERROR)에러와 사용자 정의 예외처리를 제외한(정의하지 못한, 예상치 못한) 500 에러(Internal Server Error) 예외 처리

는 끝이다.

사용자 정의 에러는 컨트롤러나 서비스에 구현해줘야 하는데, 일단 CLIENT_ERROR 처리 결과 먼저 보고 진행하고자 한다.