Spring 예외 처리를 실무에 맞게 커스텀해보자

봄도둑·2024년 2월 6일
0

Spring 개인 노트

목록 보기
16/17

해당 글은 ExceptionResolver와 예외를 커스텀해서 전역에서 간편한 예외처리를 하는 과정을 설명한 글입니다.

지난 날, 이직을 준비하면서 코드 리뷰 테스트를 본 적이 있었습니다. 그 때 Exception resolver를 통해 controller 이하 레이어에서 발생한 에러를 깔끔하게 처리할 수 있었던 부분에 대해 날카로운 질문을 받은 기억이 있습니다. (물론 광탈했습니다ㅠㅠ)

그 때의 기억을 더듬어 exception resolver와 그에 따라 발생하는 에러를 커스텀해서 프론트에서 안정적으로 처리할 수 없을까에 대한 고민을 실무 코드에 적용해서 풀어보았습니다.

1. Exception Resolver?

Exception Resolver에 대한 설명은 간단하게 진행하겠습니다.

일반적으로 Spring에서 요청은 아래와 같은 흐름으로 컨트롤러에 전달됩니다.

WAS → Dispatcher Servlet → intercepter(preHandle) → handler(controller) → service layer

이러한 흐름에서 handler와 service layer에서 예외가 발생하면 다시 거꾸로 거슬러 WAS까지 돌아와서 발생한 에러를 응답하게 됩니다.

우리는 각 예외에 따른 다른 응답과 오류를 내려주고 싶습니다. 그러나 위와 같은 흐름이라면 WAS까지 에러 처리가 도착하는데 불필요한 요청의 흐름을 보여줍니다. Spring은 이에 대해 HandlerExcepitionResolver를 제공해 예외 발생 시 다시 WAS까지 돌아가지 않고 Execption resolver가 해당 예외를 받아 처리를 시도한 후 그에 따른 결과를 정상적으로 응답한 것처럼 클라이언트에 제공하도록 합니다.

이러한 exception resolver의 장점은 단순히 불필요한 흐름을 제어하는데 있지 않습니다. 백엔드가 처리 불가능한 요청을 클라이언트가 필요로 하는 유용한 정보로 커스텀해서 응답이 가능하다는 것입니다.

백엔드는 비즈니스 로직에 맞는 각 에러의 이름을 명시적으로 지정할 수 있고, 그에 따라 현재 요청을 처리할 수 없는 이유를 클라이언트에게 전달하고 다시 정상적인 요청을 유도할 수 있도록 하는 것이 exception resolver를 사용했을 때 얻을 수 있는 가장 큰 장점이라고 생각합니다.


2. 구조 살펴보기

이제 handler exception resolver를 입맛에 맞게 사용하기 위해 제가 실무에서 정의한 구조를 살펴보도록 하겠습니다.

우리가 주목할 부분은 exception 패키지의 ExceptionBase, ExceptionResolver와 response 패키지의 ErrorResponse입니다.

먼저 각 요청을 알맞게 처리 했는지를 나타내는 응답 코드를 정의했습니다.

public enum ResponseCode {
    // HTTP_CODE 200
    SUCCESS(2000),

    // HTTP_CODE 204
    ACCEPTED(2041),

    // HTTP_CODE 400
    // InvalidParameterException
    MISSING_REQUIRED_PARAMETER(4001),
    INVALID_PARAMETER(4002),

    // HTTP_CODE 401
    // AuthException
    NO_AUTH_TOKEN(4011),
    INVALID_AUTH_TOKEN(4012),
    EXPIRED_AUTH_TOKEN(4013),
    FAILED_LOGIN(4014),
    DUPLICATED_LOGIN(4015),
    INVALID_VERIFICATION_CODE(4016),
    INVALID_DEVICE_TOKEN(4017),

    // HTTP_CODE 403
    // PermissionDeniedException
    NOT_ALLOWED(4031),
    NOT_ADMIN_USER(4032),

    //...

    private final int code;

    ResponseCode(int c) {
        this.code = c;
    }

    public static ResponseCode getName(int code) {
        return Arrays.stream(ResponseCode.values()).filter(c -> c.code == code).findFirst().orElse(null);
    }

    public int getCode() {
        return this.code;
    }

    public String toString() {
        switch (this) {
            case SUCCESS -> {
                return "OK";
            }
            //...
            default -> {
                return "Unhandled error";
            }
        }
    }
}

이 ResponseCode는 요청에 대한 응답에 포함해서 사용합니다. 요청을 정상적으로 처리했다면 ResponseCode는 2000을, 토큰에 문제가 있다면 4011을 내려줍니다. 즉 클라이언트는 백엔드에 요청 시 ResponseCode만 보고도 원인이 무엇인지 확인할 수 있게 됩니다.(이 부분에 대해서는 클라이언트와 각 ResponseCode에 대한 정의가 필요합니다)

다음 우리는 RuntimeException을 상속 받은 커스텀 예외 클래스 ExcepitonBase를 살펴봅시다.

public abstract class ExceptionBase extends RuntimeException {

    public abstract int getStatusCode();
    protected String errorMessage;
    protected ResponseCode errorCode;
    protected Logger logger;
}

ExceptionBase는 앞으로 해당 프로젝트에서 발생하는 예외를 자바에서 정의한 예외를 사용하는 것이 아니라 ExceptionBase를 상속 받은 커스텀 예외들을 사용할 것입니다. 이 예외들은 실제 Service 레이어에서 문제 사항이 발생했을 때 해당 에러를 응답합니다.

여기서 getSatusCode()라는 추상화 함수를 하나 넣었습니다. 이 함수는 ExceptionBase를 상속 받은 커스텀 예외들이 어떤 http 상태 코드를 가지는지 해당 커스텀 예외들이 구현해서 들고 있을 함수라고 보시면 됩니다.

ExceptionBase를 상속받은 AuthException을 한 번 살펴봅시다.

@ResponseStatus(value = HttpStatus.UNAUTHORIZED)
public class AuthException extends ExceptionBase {
    public AuthException(Logger l) {
        errorCode = ResponseCode.INVALID_AUTH_TOKEN;
        logger = l;
    }

    public AuthException(Logger l, ResponseCode _responseCode) {
        logger = l;
        errorCode = _responseCode;
    }

    public AuthException(Logger l, ResponseCode _responseCode, @Nullable String message) {
        logger = l;
        errorCode = _responseCode;
        this.additionalMessage = message;
    }

    @Override
    public int getStatusCode() {
        return HttpStatus.UNAUTHORIZED.value();
    }
}

AuthException이 발생하게 되면 additionalMessage를 통해 발생한 에러에 대한 상세 정보를 담아서 내려줄 수 있습니다. 여러 생성자가 있기 때문에 반드시 message를 필요로 하지는 않습니다.

보신 것 처럼 AuthException에서 발생한 getSatusCode() 를 보면 HttpStatus.UNAUTHORIZED 에 해당하는 상태 코드를 응답하도록 Override해서 구현했습니다.

다음은 에러가 발생했을 때 클라이언트에게 응답할 Response 객체입니다.

public class ErrorResponse extends HashMap<String, Object> {
    public ErrorResponse(ExceptionBase exception) {
        super();
        this.put("error", true);
        this.put("http_status_code", exception.getStatusCode());
        this.put("error_code", exception.getErrorCode());
        this.put("error_message", exception.getErrorMessage());
    }

    public ErrorResponse() {
        super();
        this.put("error", true);
        this.put("http_status_code", HttpStatus.SERVICE_UNAVAILABLE.value());
        this.put("error_code", ResponseCode.INTERNAL_SERVER_ERROR);
        this.put("error_message", ResponseCode.INTERNAL_SERVER_ERROR.toString());
    }
}

이 Response 객체는 ExceptionBase를 상속 받아 만든 커스텀 예외를 인자로 받는 생성자와 기본 생성자를 만들었습니다. 기본 생성자로 ErrorResponse를 생성하게 되면 500 에러, 즉 사전에 정의하지 않은 서버 에러가 발생했다고 판별할 수 있도록 처리했습니다.


3. ExceptionResolver 살펴보기

여기까지 보면 왜 이렇게 만들었는지 이해할 수 없는 코드 덩어리처럼 보입니다. 해당 코드들이 본격적으로 사용되는 ExceptionReolver를 살펴봅시다.

@ControllerAdvice
public class ExceptionResolver {
    Logger logger = LogManager.getLogger(this.getClass());

    @ExceptionHandler({
        AuthException.class,
        InvalidDeviceTokenException.class
    })
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    @ResponseBody
    public ErrorResponse authExceptionHandler(HttpServletRequest request, Exception exception) {
        return exception instanceof ExceptionBase ? 
						new ErrorResponse((ExceptionBase) exception) : 
						new ErrorResponse();
    }

		//...
}

@ControllerAdvice 어노테이션은 해당 클래스를 컨트롤러에서 발생하는 예외를 관리하는 Bean으로 등록하겠다라는 것을 의미합니다. 즉, 이제 에러에 대한 처리를 전역에서 사용하겠다는 것을 의미합니다. HandlerExceptionResolver에서 반드시 사용해야 할 어노테이션입니다.

@ExceptionHandler 는 일종의 포인트컷 역할을 수행합니다. 해당 어노테이션에 인자로 넘긴 예외 클래스들은 포인트컷의 조건이 되어 선언한 메소드를 실행하도록 합니다. 이 코드에서는 authExceptionHandler에 대한 포인트컷으로 앞서 커스텀 예외 클래스로 만든 AuthException과 InvalidDeviceTokenException을 지정했습니다.

@ResponseStatus 는 해당 예외 처리 시 사용할 Http 응답 코드를 정의합니다. 인증에 대한 에러를 처리하기 때문에 HttpStatus.UNAUTHORIZED 를 사용했습니다.

@ResponseBody 는 메소드의 결과 객체를 HttpResponse의 body에 매핑합니다. 그래서 예외 발생 시 사전에 정의한 ErrorResponse가 클라이언트에게 전달되는 것입니다. 그렇기 때문에 우리가 생성한 ErrorResponse만으로 클라이언트는 서버에서 발생한 문제는 무엇이고 정상 응답을 처리하기 위해 수행할 다음 동작을 준비할 수 있게 되는 것입니다.

authExceptionHandler은 2가지의 파라미터를 받습니다. HttpServletRequest와 Exception 객체를 받습니다. 위의 코드를 살펴보면 HttpServletRequest에 해당하는 request 는 사용하고 있지 않습니다. 그럼에도 HttpServletRequest를 받는 이유는 우리가 들어온 요청에 대한 정보를 로그로 남겨서 사용할 수 있기 때문에 받도록 처리했습니다.

Exception 파라미터는 현재 발생한 에러가 ExceptionBase로부터 상속 여부를 판별하고 만약 상속 받았다면 발생한 Exception을 기반으로 ErrorResponse를 만듭니다. 그렇지 않다면 500에러로 간주하고 기본 생성자로 ErrorResponse를 리턴합니다.

여기서 ErrorResponse의 생성자를 기본 생성자와 ExceptionBase를 인자로 받는 생성자 2개를 만든 것을 확인할 수 있습니다.

여기까지 완료하면 우리는 authExceptionHandler를 전역에서 사용할 준비가 끝난 것입니다.

그렇다면 실제 예외를 발생 시켜 우리가 의도한 ErrorResponse가 잘 응답하고 있는지 살펴봅시다.


4. 예외 발생

우리는 예외 처리를 jwt 토큰을 검증하는 로직에서 발생 시켜보겠습니다.

private Token validateToken(String tokenString) {
    Token covertedToken = buildToken(tokenString);

    if (covertedToken== null) {
        log.error("!!!!!! AuthService.validateToken() - covertedTokenis null");
        throw new AuthException(logger, ResponseCode.INVALID_AUTH_TOKEN, "Invalid access token");
    }

    if (covertedToken.isExpired()) {
        log.error("!!!!!! AuthService.validateToken() - covertedTokenis expired - expired : {}", covertedToken.getExpirationTime());
        throw new AuthException(logger, ResponseCode.EXPIRED_AUTH_TOKEN, "Expired access token");
    }

    validateRedisToken(covertedToken);

    return covertedToken;
}

위의 로직을 간단히 설명 드리자면 tokenString을 받아서 jwt 파싱을 통해 Token 객체인 covertedToken으로 변환합니다.

변환된 convertedToken이 null이라면 INVALID_AUTH_TOKEN ResponseCode를 가진 에러를 응답하고, 토큰이 만료되었다면 EXPIRED_AUTH_TOKEN ResponseCode를 가진 에러를 응답합니다.

정상적인 토큰이라면 redis에 저장된 토큰과 비교 후(validateRedisToken) covertedToken 객체를 리턴하는 간단한 토큰 검증 로직입니다.

이 토큰 검증 로직은 interceptor이기 때문에 API를 호출하면 반드시 먼저 호출되는 함수이기 때문에 토큰을 유효하지 않은 토큰을 넣어서 보내보겠습니다.

이처럼 우리가 정의한 ErrorResponse의 모양에 따라 에러를 처리할 수 있음을 보여줄 수 있습니다.

다른 에러 처리는 정상적으로 되는지 확인하기 위해 만료된 토큰으로 백엔드 서버에 요청할 경우 아래와 같은 ErrorResponse도 확인할 수 있습니다.


5. 마치며

개인적으로 HandlerExceptionResolver를 사용하면서 느낀 최고의 경험은 더 이상 백엔드에서 에러 처리를 할 때 각 에러에 대응하는 response를 만들지 않아도 된다는 점이었습니다. 예시로 보여드린 것은 authExceptionHandler 하나지만 여러 개의 handler를 사용하면서 모든 에러를 전역에서 처리할 수 있으니 백엔드는 에러 처리 응답은 신경쓰지 않고 적합한 에러만 선언해주기만 하면 되는 것이었습니다.

또한, 사용자의 경험에도 큰 향상을 가져온다고 봅니다. 일반적인 400에러를 응답하는 것이 아니라 사용자가 잘못 요청한 항목을 백엔드에서 세분화해서 ErrorResponse에 담아 보내줌으로써 서버의 처리 실패에 대한 명확한 원인을 전달할 수 있었습니다. 사용자는 명확한 실패 사유를 받고 성공적으로 요청을 처리할 수 있도록 재시도를 하도록 유도할 수 있다는 점에서 Handler를 사용하는 점에 있어 사용성을 크게 올렸다고 봅니다.

반면, 지나치게 세분화된 에러 코드는 클라이언트(사용자)와 정의가 꼭 필요합니다. 아무리 백엔드가 4013이라는 에러 코드를 내려주어도 클라이언트가 이를 이해하지 못하면 세분화하고 응답을 내려주는 의미가 없습니다.

백엔드의 에러 응답은 단순해야 한다고 이야기 하는 분들이 있습니다. 실패한 http 코드를 보고 현재 화면에 맞는 에러 메세지를 사용자에게 띄우면 되는데 지나치게 에러를 세분화 하는 것은 불필요한 개발이라고 보시는 분들이 있습니다. 그분들의 의견도 존중하는 한편 그에 따른 제 생각은 백엔드의 실패 사유는 상세할수록 좋다는 것입니다. 에러는 화면에 종속되어서는 안됩니다. 백엔드의 API들이 화면에 종속되어서 설계되지 않고 클라이언트가 필요로 하는 정보를 잘 주고 받을 수 있도록 설계 하듯이 클라이언트가 백엔드에서 내려주는 상세한 실패 사유를 보고 재시도를 할지, 어떤 요청을 수정해야 할지를 명확하게 지정해주어야 클라이언트가 서버의 규약때로 요청을 유도할 수 있는 부분에서 반드시 필요한 부분이라고 생각합니다.

실제 실무에서 사용하는 코드를 간소화했습니다. 해당 코드는 계속 개선을 해 나가고 싶습니다. 혹시라도 exception resolver를 사용하는데 좋은 아이디어가 있다면 댓글과 이메일, 많은 피드백 부탁드립니다!


Reference

profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글