예외란 무엇이며, 스프링부트에서 예외를 간단하게 처리하는 필살기 (@ControllerAdvice, @ExceptionHandler, 트리구조의 예외 상속, @Valid)

초록·2023년 11월 22일
0
post-thumbnail

예외.. 어떻게 쓰는거지?

저는 기존엔 특정 메서드에서 유효하지 않은 인자라던가 문제 있는 상황에 대해 오류 리턴값(0 혹은 -1, null)을 반환함으로써 호출한 쪽에서 오류를 처리하도록 했습니다. 그런데 계속해서 오류값을 전달하는 코드가 증가하고 핵심 로직이 잘 보이지 않게 되었습니다. try-catch로 예외를 처리할 수 있단 건 알고는 있는데 이걸 어떻게 써먹어야할지 감이 잘 안 왔습니다.

예외를 처리하는 방법에 대해 찾아보다가 이 글을 발견하고 담배200 프로젝트에 적용하면서, 예외란 무엇인지, 어떻게 처리해야할지 잘 알게되었습니다. 그 느낌을 표현하자면 필살기를 전수받은 느낌이었습니다ㅎㅎ

예외.. 그거 뭐하는건데

예외를 처리하는 방법에 대해 설명하기 전에, 먼저 예외가 무엇인지 짚어보겠습니다.

예외는 던지는거야~

문제가 발생하면 throw Exception를 하게 되고, catch해서 Exception을 처리한다는 말을 알고 계실 겁니다. 실제로 예외는 공을 던지듯 던져지고, 호출 내역을 따라서 계속 던져지다가 어딘가에서 캐치되어 처리됩니다.

예외는 말 그대로 예외상황에 대한 처리야!

던져지고 캐치된다는 건 알겠는데, 그래서 예외가 뭘까요? 예시를 들어 설명해드리겠습니다.

  1. 사용자가 회원가입하기 위해 이메일과 비밀번호를 보냈는데, 골뱅이@가 포함되어있지 않는 등 이메일 형식에 안 맞습니다. 그럼 회원가입 절차를 중단하고 이메일을 다시 작성하라는 메시지를 작성해서 사용자에게 보낼 수가 있습니다.
  2. 사용자가 어떤 쿠폰을 사용하고자 요청을 보냈는데, 체크해보니 쿠폰의 만료기한이 다 되었습니다. 그럼 쿠폰 적용 절차를 중단하고 사용자에게 해당 쿠폰이 만료되어 사용불가하다는 메시지를 작성해서 사용자에게 보낼 수 있습니다.

이렇게 기계적 결함이나 리소스 문제가 아니라 무언가를 처리하는 데 있어 문제가 되거나 예외적으로 처리해주기 위해 throw하는 것이 예외입니다.

위 처럼 사용자에게 예외 내용을 알려주게 되는 예외도 있지만 그렇지 않은 예외도 있습니다. 예를 들면, 앱 내부에 메시지큐를 만들어 작동시켰는데, 메시지가 제대로 처리되지 않았을 때 예외를 던져, 예외를 잡은 쪽에서 다시 메시지를 메시지큐에 넣도록 하는 등, 코드 동작의 예외적인 상황을 명시해 처리할 수 있습니다.

결국, 어떤 처리를 하고자하는데 해당 처리를 진행하기 어려운 상황이라 판단되면 예외를 던져, 지금까지 진행중이던 내용들을 중단 및 Rollback(복구)하고 다른 처리(위 예시에선 사용자에게 재시도 메시지 전달)를 진행할 수 있도록 하는 게 예외입니다.

그럼 모든 예외에 대해 try-catch문을 작성 해야하는거야?

결론부터 말씀드리자면 '아닙니다'. 자바에는 CheckedException과 UncheckedException(==RuntimeException)이 있습니다.

CheckedException은 해당 예외가 던져지면 반드시 try-catch 문으로 묶어서 처리하거나 메서드 선언부 옆에 throws XXException을 적어줌으로써 그 예외를 반드시 처리하게끔 하는 예외입니다.

UncheckedException 혹은 RuntimeException은 예외를 던져도 아무런 처리를 강제하지 않는 예외입니다. 처리를 해주지 않으면 호출스택을 따라 계속 예외를 던지게 되죠.

CheckedException은 코드가 복잡해지기 때문에 꼭 필요한 경우가 아니면 CheckedException 기반의 예외를 정의하는 것은 권장드리지 않습니다. 다른 언어들은 CheckedException을 지원하지도 않습니다. 다시 말해 CheckedException이 없어도 견고한 프로그래밍이 가능하다는 말입니다.

그래서 UncheckedException(RuntimeException)을 사용해 try-catch문을 훨씬 적게 작성해 코드를 훨씬 간결하게 작성할 수 있습니다.

스프링부트에서 예외를 어떻게 간편하게 처리할까?

이제 예외가 뭔지 알았으니, 스프링부트에서 예외를 간편하게 처리하는 방법을 알아보겠습니다.

@ControllerAdvice로 예외를 일관되게 처리!

스프링의 @ControllerAdvice라는 걸 쓰면 모든 컨트롤러(에서 호출한 어떤 메서드든)에 대해 글로벌한 설정을 할 수 있게됩니다. 그리고 특정 컨트롤러 내에 @ExceptionHandler라는 걸 사용하면 특정 컨트롤러의 예외처리하는 로직을 메서드 형태로 명시할 수 있습니다.

그래서, GlobalExceptionHandler라는 이름으로 클래스를 만들어서, @ControllerAdvice를 붙인 뒤, 그 내부에 @ExceptionHandler를 붙인 예외처리 메서드들을 작성하면, 모든 컨트롤러 내의 요청이 이 곳에서 일괄적으로 처리됩니다.

그러면 대부분의 코드에서 try-catch로 예외를 처리할 코드를 작성하지 않고 그냥 예외를 던지기만 하면 되며, 예외 종류별로 일관되게 예외 처리 방식을 관리할 수 있게 됩니다.

주의하셔야하는 점은, ExceptionHandler는 컨트롤러 레이어 내부에서 발생한 예외와 인터셉터의 preHandle, postHandle 메서드에서 발생한 예외만 처리하기 때문에, 인터셉터의 afterCompletion 메서드나 필터에서 발생한 예외는 별도로 처리해주어야 합니다. 원래 preHandle, postHandle, 컨트롤러 내부에서 예외나면 바로 afterCompletion으로 뛰어버리는데, ExeceptionHandler가 afterCompletion 전에 끼게 되는 것입니다.

아래는 Yun님의 글을 보고 아주 약간의 커스터마이징을 거친 GlobalExceptionHandler 소스코드입니다. 예외 종류별로 사용자에게 보낼 예외 메시지를 다르게 생성해서 return합니다. 이는 클라이언트에게로 보내지게 되며, 클라이언트는 명시된 예외코드를 기반으로 적절한 행동을 취할 수 있게 됩니다.

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler implements GlobalExceptionHandlerInterface{
    /**
     *  javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
     *  HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할경우 발생
     *  주로 @RequestBody, @RequestPart 어노테이션에서 발생
     * @return
     */
    @Override
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("handleMethodArgumentNotValidException", e);
        final ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        final StandardResponse response = StandardResponse.builder().errorResponse(errorResponse).status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).build();
        return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.INVALID_INPUT_VALUE.getStatus()));
    }

    /**
     * @ModelAttribute 으로 binding error 발생시 BindException 발생한다.
     * ref https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#mvc-ann-modelattrib-method-args
     * @return
     */
    @Override
    @ExceptionHandler(BindException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleBindException(BindException e) {
        log.error("handleBindException", e);
        final ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        return StandardResponse.of(errorResponse);
    }

    /**
     * enum type 일치하지 않아 binding 못할 경우 발생
     * 주로 @RequestParam enum으로 binding 못했을 경우 발생
     * @return
     */
    @Override
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
        log.error("handleMethodArgumentTypeMismatchException", e);
        final ErrorResponse errorResponse = ErrorResponse.of(e);
        return StandardResponse.of(errorResponse);
    }

    /**
     * 지원하지 않은 HTTP method 호출 할 경우 발생
     * @return
     */
    @Override
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
        log.error("handleHttpRequestMethodNotSupportedException", e);
        final ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
        return StandardResponse.of(errorResponse);
    }

    /**
     * Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생합
     */
    @Override
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleAccessDeniedException(AccessDeniedException e) {
        log.error("handleAccessDeniedException", e);
        final ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED);
        return StandardResponse.of(errorResponse);
    }

    // business 로직에 의해 처리되어야할 예외 ex) 중복된 아이디, 쿠폰 기한 만료
    @Override
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleBusinessException(final BusinessException e) {
        log.error("handleBusinessException", e);
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse errorResponse = ErrorResponse.of(errorCode, e.getMessage());
        return StandardResponse.of(errorResponse);
    }

    // 그 외 모든 예외
    @Override
    @ExceptionHandler(Exception.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleException(Exception e) {
        log.error("Exception", e);
        final ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
        return StandardResponse.of(errorResponse);
    }

}

발생한 예외에 대한 정보를 담아놓는 DTO, ErrorResponse

위 코드(GlobalExceptionHandler)를 보면 예외 정보를 ErrorResponse라는 객체에 담는 모습을 볼 수 있는데 이 클래스는 아래와 같이 작성되어있습니다. 상태코드와 예외코드, 타입, 메시지들을 담습니다. List<FieldError> errors 는 @Nonnull과 같은 Validation 애너테이션에서 validation에 실패한 내용들이 모이게 됩니다.

ErrorCode, 에러 메시지 등의 인자를 받아 of라는 메서드로 이 객체를 생성하게 된다. ErrorCode가 뭔지는 아래에서 알려드리겠습니다.

@Getter
public class ErrorResponse{
    private int status;
    private String errorCode;
    private String errorType;
    private String message;
    private List<FieldError> errors = new ArrayList<>();

    private ErrorResponse(ErrorCode errorCode, List<ErrorResponse.FieldError> errors, String message){
        this.status = errorCode.getStatus();
        this.errorCode = errorCode.getCode();
        this.message = message;
        this.errors = errors;
        this.errorType = errorCode.getType();
    }

    public static ErrorResponse of(final ErrorCode code) {
        return new ErrorResponse(code, null, null);
    }

    public static ErrorResponse of(ErrorCode errorCode, List<ErrorResponse.FieldError> errors){
        return new ErrorResponse(errorCode, errors, null);
    }

    public static ErrorResponse of(final ErrorCode code, final String message) {
        return new ErrorResponse(code, null, message);
    }

    public static ErrorResponse of(ErrorCode errorCode, BindingResult bindingResult){
        return ErrorResponse.of(errorCode, FieldError.of(bindingResult));
    }

    public static ErrorResponse of(MethodArgumentTypeMismatchException e) {
        final String value = e.getValue() == null ? "" : e.getValue().toString();
        final List<ErrorResponse.FieldError> errors = ErrorResponse.FieldError.of(e.getName(), value, e.getErrorCode());
        return ErrorResponse.of(ErrorCode.INVALID_TYPE_VALUE, errors);
    }

    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class FieldError {
        private String field;
        private String value;
        private String reason;

        public static List<ErrorResponse.FieldError> of(String name, String value, String errorCode){
            List<ErrorResponse.FieldError> feildErrors = new ArrayList<>();
            feildErrors.add(new FieldError(name, value, errorCode));
            return feildErrors;
        }

        public static List<ErrorResponse.FieldError> of(final BindingResult result){
            final List<org.springframework.validation.FieldError> fieldErrors = result.getFieldErrors();
            return fieldErrors.stream()
                    .map(error -> new FieldError(error.getField()
                            , error.getRejectedValue() == null ? "" : error.getRejectedValue().toString()
                            , error.getDefaultMessage())).collect(Collectors.toList());
        }

    }

}

예외에 대한 정보를 관리하는 ErrorCode enum

ErrorCode라는 enum에서 에러종류별로 [에러 코드, 에러 메시지, HTTP 상태코드]를 관리하게 됩니다.
아래는 담배200에서 관리하는 에러 종류들입니다. 이런 것들을 각 예외 클래스에서 관리하게 되면 다 흩어지게 되기 때문에 일관성이 떨어지거나 중복되거나 원하는 내용을 찾기 힘든 문제가 있습니다.

한 번 훑어보시면 아 이런 내용들을 예외로 만들 수가 있구나 하고 알 수 있으실 겁니다.
Entity에 중복이 있거나, 특정 조건이 맞지 않거나, 요청된 내용을 찾을 수 없는 경우가 많습니다.

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {

    // Common
    INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST.value(), "C001", " Invalid Input Value"),
    METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED.value(), "C002", " Method not allowed"),
    ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "C003", "Entity Not Found"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "C004", "Server Error"),
    INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST.value(), "C005", " Invalid Type Value"),
    HANDLE_ACCESS_DENIED(HttpStatus.FORBIDDEN.value(), "C006", "Access is Denied"),
    DUPLICATED_ENTITY(HttpStatus.BAD_REQUEST.value(), "C007", "Duplicated Entity"),
    RLOCK_TIMEOUT(HttpStatus.INTERNAL_SERVER_ERROR.value(), "C008", "Redis Lock Timeout"),

    // User
    NOT_LOGGED_IN(HttpStatus.BAD_REQUEST.value(), "U001", "Not Logged in"),
    LOGIN_INFO_NOT_MATCHED(HttpStatus.BAD_REQUEST.value(), "U002", "Login info is not matched"),
    EMAIL_DUPLICATION(HttpStatus.BAD_REQUEST.value(), "U003", "Email is Duplication"),
    NICKNAME_DUPLICATION(HttpStatus.BAD_REQUEST.value(), "U004", "Nickname is Duplication"),
    EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "U005", "The email doesn't exist"),

    // Session
    ACCESSED_EXPIRED_SESSION_TOKEN(HttpStatus.UNAUTHORIZED.value(), "S001", "만료된 세션 정보에 접근했습니다."),
    SESSION_INFO_NOT_EXISTS(HttpStatus.UNAUTHORIZED.value(), "S002", "해당 토큰에 해당하는 세션 정보가 존재하지 않습니다."),

    // Store
    INVALID_STORE_BRAND_CODE(HttpStatus.BAD_REQUEST.value(), "ST001", "Invalid Store Brand Code"),
    DUPLICATE_STORE(HttpStatus.BAD_REQUEST.value(), "ST002", "Duplicate Store Registered"),

    // Access
    INVALID_ACCESS_TYPE_CODE(HttpStatus.INTERNAL_SERVER_ERROR.value(), "A001", "Invalid Access Type Code"),
    CANNOT_FIND_ACCESS_NOTIFCATION_TYPE(HttpStatus.INTERNAL_SERVER_ERROR.value(), "A002", "Cannot Find Access Notification Type"),
    CANNOT_FIND_ACCESS_SITUATION_TYPE(HttpStatus.INTERNAL_SERVER_ERROR.value(), "A003", "Cannot Find Access Situation Type"),
    DUPLICATED_ACCESS_APPLY(HttpStatus.BAD_REQUEST.value(), "A004", "Duplicate Access Registered"),
    ACCESS_NOT_ALLOWED(HttpStatus.UNAUTHORIZED.value(), "A005", "Access is not allowed to the store"),

    // Cigarette
    OFFICIAL_DUPLICATION(HttpStatus.INTERNAL_SERVER_ERROR.value(), "CG001", "Duplicated Official Cigarette Name. Try other name"),

    // CigaretteOnList
    DUPLICATE_CIGARETTE_ON_LIST(HttpStatus.BAD_REQUEST.value(), "CGL001", "Duplicated Cigarette is already on the List"),
    INVALID_ORDER_TYPE_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "CGL002", "Invalid Order Type Exception" ),

    // Socket
    INVALID_SUBSCRIBE_SOCKET_CHANNEL(HttpStatus.BAD_REQUEST.value(), "SCK01", "Invalid Subscribe Socket Channel"),


    ;

    private int status;
    private final String code;
    private final String type;

    ErrorCode(final int status, final String code, final String message) {
        this.code = code;
        this.status = status;
        this.type = message;
    }

    public String getType() {
        return this.type;
    }
    public int getStatus() { return this.status; }
    public String getCode() { return this.code; }
}

이렇게 만들어진 ErrorCode enum이 적극적으로 활용되는 게 바로 비지니스예외입니다. 아래는 GlobalExceptionHandler의 비지니스예외 처리 메서드입니다. 잘 보면 예외에서 ErrorCode와 Message를 꺼내 ErrorResponse를 만드는 것을 볼 수 있습니다.

    // business 로직에 의해 처리되어야할 예외 ex) 중복된 아이디, 쿠폰 기한 만료
    @Override
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<StandardResponse<ErrorResponse>> handleBusinessException(final BusinessException e) {
        log.error("handleBusinessException", e);
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse errorResponse = ErrorResponse.of(errorCode, e.getMessage());
        return StandardResponse.of(errorResponse);
    }

ErrorCode를 이용해 커스텀예외 만들기

일단 RuntimeException을 상속하는 BusninessException이라는 커스텀 예외 클래스를 만듭니다. 그리고 ErrorCode 필드를 정의해줍니다. 이제 ErrorCode를 잘 입력하면 알맞은 예외메시지를 클라이언트에게 보낼 수 있습니다.

비지니스 예외란?

쿠폰 만료, 이메일 형식 안 맞음 등과 같이, 서버나 처리의 문제가 아닌 비지니스 로직의 이유로 발생시키는 예외입니다. 위 ErrorCode Enum에서 Common 영역을 제외한 모든 예외가 비지니스 예외라고 보시면 됩니다.

@Getter
public class BusinessException extends RuntimeException {

    private ErrorCode errorCode;

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

    public BusinessException(ErrorCode errorCode, String msg){
        super(msg);
        this.errorCode = errorCode;
    }

}

트리구조의 런타임 예외 정의

하지만 ErrorCode를 입력하는 것만으로 예외를 던지게 되면, 어떤 예외가 던져졌는지 클래스이름만으로 확인할 수 없고, catch문 작성도 더 복잡해질 것입니다. 매번 BusinessException을 캐치하고 getErrorCode해서 내가 찾는 예외코드가 맞는지 확인해야 할 것이기 때문입니다.

그래서 예외를 상속하는 것은 예외를 관리하는 좋은 방법입니다. 클래스 이름으로 catch도 훨씬 쉽게 되며, 자연스럽게 트리구조가 형성이 되며, 각 예외상황들을 카테고라이징하는 효과와 메시지 템플릿도 공유할 수 있게 됩니다.

아래는 BusinessException을 상속한 EntityNotFoundException이며, UserEntityNotFoundException, StoreEntityNotFoundException 등 더 뻗어나갈 수도 있을 것입니다. 생성자를 잘 활용하다보면 끝 단에선 생성자 인자로 ErrorCode나 메시지 등을 넘길 필요 없이 new XXException()으로 쉽게 예외를 던질 수도 있을 것입니다.

public class EntityNotFoundException extends BusinessException {

    public EntityNotFoundException(String entity, String id){
        super(ErrorCode.ENTITY_NOT_FOUND, String.format("id %s에 해당하는 %s entity를 찾을 수 없습니다.", id, entity));
    }

    public EntityNotFoundException(String msg){
        super(ErrorCode.ENTITY_NOT_FOUND, msg);
    }

    protected EntityNotFoundException(ErrorCode errorCode, String msg){
        super(errorCode, msg);
    }

    protected EntityNotFoundException(ErrorCode errorCode){
        super(errorCode);
    }
}

아래는 담배200에서 실제로 사용된 예외 목록입니다.

클라이언트에서 처리하기 쉬운 일관된 구조

응답은 JSON으로 내려지는데, 성공 메시지와 실패 메시지 모두 StandardResponse라는 동일한 DTO에 담음으로써, 성공응답이든 실패응답이든 프론트에서 일관되게 처리할 수 있도록 했습니다. 오류가 발생했을 때는 에러코드를 기반으로 간결하게 대응할 수 있습니다.

아래는 예외가 발생했을 때 응답 모습입니다.

{
  "status": 400,
  "data": null, // 성공했을 때 여기에 응답 내용이 담김
  "errorResponse": {
  	"status" : 400,
    "errorCode" : "C001",
    "errorType" : "Invalid Input Value",
    "message" : null,
    "errors" : [
      {
        "field" : "email",
        "value" : "email3example.com",
        "reason" : "잘못된 이메일 형식입니다."
      }
    ]
  }
}

한눈에 보기

이 예외처리 흐름을 한 눈에 정리하자면 아래와 같아집니다.

스크린샷 2023-02-15 오후 1 09 54

마무리

Yun님의 이 글 을 처음 접했을 때는 예외에 대해 거의 잘 몰랐었는데, 이 방법을 시작으로 이젠 예외를 너무 잘 사용하고 예외 덕분에 코딩이 편해지기도 합니다. 이 글을 보시는 분들도 예외에 대해 더 잘 알게되고 친숙하게 느껴졌으면 좋겠습니다.

이 글이 제 첫 블로그 글인데, 정보를 전달하는 글이 생각보다 더 어렵고 정성이 필요하다는 걸 느끼게 되었습니다. 하지만 다른 사람의 성장을 위해 컨텐츠를 만드는 일 자체가 보람있고, 누군가가 보게될 것이란 생각에 설레고, 시각자료 만드는 일도 재밌었습니다. 앞으로도 간간히 블로그에 글 작성하도록 하겠습니다.

참조

profile
몰입하고 성장하는 삶을 동경합니다

0개의 댓글