티스토리 뷰

Backend

일관적인 에러응답을 달라!

지마켓 김현진 2023. 6. 21. 08:45

안녕하세요. Seller & SD Engineering 팀에서 판매자 가입, 계정 관리 업무를 맡고 있는 주니어 개발자 김현진입니다.

개발을 빠르게 빠르게 진행하다 보면 놓치게 되는 디테일, 에러 응답 처리 방법에 대한 주제로 글을 써보려 합니다.

 

에러 응답을 처리하는 방법

Java에서 에러를 처리하는 코드의 기본 구조는 아래와 같습니다.

try {
    ...
} catch(예외1) {
    ...
} catch(예외2) {
    ...
}

예외가 발생할 가능성이 있는 코드를 try로 감싸고, 잡아내고 싶은 예외를 catch에 명시해 주는 방식입니다.

try로 감싼 코드에서 예외가 발생하지 않았다면 catch 부분의 코드는 그대로 통과됩니다. 하지만 예외가 발생했다면 발생한 예외가 속한 catch 문을 찾아 해당 코드를 실행합니다.

 

바로 예외를 잡아낼 수도 있고 예외가 발생할 가능성이 있는 코드를 불러다 쓰는 caller에게 예외 처리를 전가하는 방식도 있습니다.

public float divide(int a, int b) {
    if (b == 0) throw new ArithmeticException();
    int result = a/b;
    System.out.println(result);
}
try {
    divide(1,0);
} catch(ArithmeticException ex) {
    ex.printStackTrace();
}

b의 값을 0으로 전달하면 실제로 예외가 발생한 부분은 divide 메서드 내부이지만 예외 처리는 divide 메서드를 호출하는 caller에게 전가됩니다. 이렇게 에러를 처리하는 로직을 divide 메서드 밖으로 빼냄으로써 divide 메서드는 나눗셈을 하는 역할에 집중할 수 있게 되었습니다.

spring에서는 한 걸음 더 나아가 @ExceptionHandler라는 어노테이션을 제공하고 있습니다.
ExceptionHandler는 메서드에 적용하는 어노테이션으로, 속성으로는 Exception 객체의 이름을 받습니다.
ExceptionHandler의 속성으로 전달한 Exception이 발생하면 @ExceptionHandler가 붙은 메서드가 이를 catch 하여 
실행하는 방식으로 작동합니다.

@ExceptionHandler의 속성값으로 넘겨준 Exception 객체는 하위 객체일수록 높은 우선순위를 갖습니다.

@ExceptionHandler(Exception.class)
public void doWhenExceptionThrown() {
    System.out.println("doWhenExceptionThrown executed!!");
}
@ExceptionHandler(ArithmeticException.class)
public void doWhenArithmeticExceptionThrown() {
    System.out.println("doWhenArithmeticExceptionThrown executed!!");
}

 

위의 코드와 같이  doWhenExceptionThrowndoWhenArithmeticExceptionThrown가 존재하는 상황에서
ArithmeticException이 발생하였을 때 doWhenArithmeticExceptionThrown 메서드가 실행됩니다.
ArithmeticException 객체는 Exception 객체를 상속받은 하위 객체이기 때문입니다.

또 다른 유의할 점은 @ExceptionHandler의 적용 범위입니다. @ExceptionHandler는 기본적으로 @Controller 어노테이션이 붙은 범위 내에서 적용이 가능합니다. 그렇다고 모든 컨트롤러마다 동일한 @ExceptionHandler를 구현하는 것은 코드의 중복을 발생시켜 유지보수를 어렵게 합니다. 이는 객체지향적이지도 않습니다(OCP). 이러한 단점을 보완하기 위해 보통 @ExceptionHandler를 @RestControllerAdvice와 함께 사용합니다. 저희 팀 내의 에러처리 코드들도 하단의 코드와 비슷하게 구성되어 있는 것을 확인할 수 있었습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public void dosomething() {}
}

 

@RestControllerAdvice + @ExceptionHandler 에러 처리 시 유의할 점

GlobalExceptionHandler에서는 custom하게 정의한 ExceptionResult 객체를 일괄 리턴하도록 코드를 작성하였습니다.
client 측에서 잘못된 요청을 보냈을 때 이를 판단할 수 있는 오류코드와 오류 메시지만을 리턴하려는 의도가 담겨 있습니다.

public class ExceptionResult {
    private String code;
    private String message;
    public ExceptionResult(String code, String message) {
        this.code = code;
        this.message = message;
    }
}
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(ArithmeticException.class)
    public ExcepitionResult doWhenArithmeticExceptionThrown() {
        return new ExceptionResult("EX001", "ArithmeticException occurred");
    }
    @ExceptionHandler(NullPointerException.class)
    public void doWhenNullPointerExceptionThrown() {
        return new ExceptionResult("EX002", "NullPointerException occurred");
    }
}


이때, client측에서 HTTP GET 요청을 POST로 잘못 지정하여 요청을 보냈더니 HttpRequestMethodNotSupportedException가 발생하며 custom 하게 정의한 ExceptionResult 객체를 응답으로 받을 수 없었습니다. ExceptionResult 객체 대신 spring으로 개발하는 개발자라면 한 번쯤 접해보았을 아래의 default 에러 객체를 리턴하는 것을 확인할 수 있었습니다.

{
    "timestamp": "2023-04-26T11:43:06.095+00:00",
    "status": 405,
    "error": "Method Not Allowed",
    "trace": "..."
}

이러한 응답을 내려보내게 될 경우, API의 응답은 일관성을 잃게 됩니다. 어떨 때에는 code와 message 파라미터를 내려보내는데 어떤 경우에는 timestamp, status, error, trace 파라미터를 내려보내기 때문입니다. client는 백엔드 개발자에게 어느 응답 파라미터를 기준으로 잡아 응답 처리를 진행해야 하는지에 대한 추가적인 의사소통을 요구할 수도 있습니다.

 

ResponseEntityExceptionHandler 상속으로 해결하기

Spring에서는 에러 핸들링에 대한 base class로 ResponseEntityExceptionHandler라는 추상 클래스를 정의하고 있습니다. 아래는 Spring 내부 코드인 ResponseEntityExceptionHandler 추상 클래스의 일부입니다.

 

ResponseEntityExceptionHandler.class

앞선 예제 코드에서 구현한 GlobalExceptionHandler.class에 HttpRequestMethodNotSupportedException 혹은 HttpMediaTypeNotSupportedException와 같은 에러의 처리 로직을 명시하지 않았다면 Spring 내부에서 ResponseEntityExceptionHandler의 @ExceptionHandler가 이를 잡아내어 handleException 메서드를 실행시키는 방식으로 작동하게 됩니다.

 

handleException 메서드에서는 개발자가 custom하게 정의한 에러 객체인 ExceptionResult가 아닌 spring에서 자체 정의하고 있는 에러 객체를 리턴하고 있습니다. 그래서 HttpRequestMethodNotSupportedException가 발생했을 때 code, message가 아닌 timestamp, status와 같은 파라미터를 응답 값으로 얻게 된 것입니다.


이를 해결하기 위해 ResponseEntityExceptionHandler가 에러를 잡아채더라도 개발자가 custom 하게 정한 로직대로 에러를 처리하게 하고 싶었습니다. 따라서 앞서 구현한 GlobalExceptionHandler.class가 ResponseEntityExceptionHandler를 상속받게 했고 handleException 메서드에서 호출하고 있는 handleHttpRequestMethodNotSupported 메서드를 override 하였습니다.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
    @Override
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(
            HttpRequestMethodNotSupportedException ex 
            , HttpHeaders headers
            , HttpStatus status
            , WebRequest request
    ) {
        return new ExceptionResult("EX003", "HttpRequestMethodNotSupportedException occurred");
    }
}

이렇게 구현할 경우 HttpRequestMethodNotSupportedException이 발생하면 ResponseEntityExceptionHandler를 상속받은 GlobalExceptionHandler.class의 @ExceptionHandler가 해당 에러를 잡아내고 override 한 handleHttpRequestMethodNotSupported 메서드를 통해 에러 객체를 처리할 수 있게 됩니다.

원래대로라면 ResponseEntityExceptionHandler의 handleHttpRequestMethodNotSupported 메서드가 실행되었겠지만 GlobalExceptionHandler에게 ResponseEntityExceptionHandler를 상속받게 하고 메서드를 오버라이드 함으로써 스프링 자체적인 에러 객체가 아닌 개발자가 커스텀하게 구현한 에러 객체를 리턴시킬 수 있었습니다.

 

Handler를 찾지 못했을 경우 해결하기

ResponseEntityExceptionHandler를 상속받게 구현하였음에도 불구하고 스프링 자체적인 에러를 내뱉는 경우가 더 존재했습니다. uri를 질못 지정했을 때 NotFoundException이 발생하는 경우입니다. DispatcherServlet.class의 doDispatch() 메서드에서 요청을 처리할 핸들러를 찾지 못했을 때 에러를 throw 하지 않는 것이 기본 값으로 설정되어 있었기 때문입니다.

 

DispatcherServlet.class

위의 코드에서 요청을 처리할 핸들러를 찾지 못해 null이 리턴되었을 경우 noHandlerFound 메서드가 실행되는데 이 메서드의 내부에서는 throwExceptionIfNoHandlerFound의 기본값이 false로 설정되어 있습니다. 예외를 터트리지 않기 때문에 GlobalExceptionHandler.class의 @ExceptionHandler로직을 타지 않습니다.

 

noHandlerFound method

this.throwExceptionIfHandlerFound 값이 false로 설정되어 있어 response.sendError(HttpServletResponse.SC_NOT_FOUND);가 실행되고 스프링이 자체 정의한 에러 객체를 리턴하게 되는 것입니다.

따라서 spring의 application.properties 파일에 아래와 같은 설정을 추가해주어야 합니다.


spring.mvc.throw-exception-if-no-handler-found=true

정리

Java의 try-catch부터 spring의 에러 처리 방식까지 정리해 보며 Spring이 어떻게 에러 처리 로직을 한 곳으로 모아 비즈니스 로직에 집중하게 해 주었는지 알아볼 수 있었습니다. 그 외에도 에러가 발생했을 때 일관적인 응답 객체를 리턴하는 데 있어서 해결해야 할 포인트들(ResponseEntityExceptionHandler 상속받기, throw-exception-if-no-handler-found 옵션 추가)도 함께 확인해 보았습니다.

DispatcherServlet 자체로도 설명할 내용이 많아 최대한 에러 응답 리턴에 초점을 맞춰 간략히 적은 글입니다.
spring 내부 코드를 확인해 보시면 이해가 더 빠를 수 있습니다. 이참에 spring 내부 코드를 한 번 확인해 보는 것은 어떨지요? :)
감사합니다.

 

 

 

참고
- DispatcherServlet.class code
- ResponseEntityExceptionHandler.class code

댓글