Spring: 예외 처리 - 쉽게 관심사 나누기 Global Exception Handler(Controller Advice)

Letsdev·2023년 5월 13일
8
post-thumbnail
post-custom-banner

예외로 응답하기

깃허브(github.com)
기섭닷콤(github.com)

예외처리(catch, throw) 정도는 알고 있다는 전제로 글을 작성합니다.
설명 편의상 오늘은 반모 좀 하겠습니다. (반말모드의 준말 ㅎ)

AOP(Aspect Oriented Programming)

관점 지향 프로그래밍에서 종단 관심사와 횡단 괌심사 표현을 위해서 API 세 개를 각각 파란색 세로 흐름으로, 그중 공통적으로 필요한 처리를 빨간색 점선으로 그려진 가로 네모로 그린 그림

보조적이라 할 만한 기능들을 따로 빼서 처리하게끔 발전해 온 것이 관점지향 프로그래밍이다.
평소 구현할 때는, 우리의 주 관심사인 파란 화살표 위주로만 코드에서 보이게끔 만들면 된다.
이런 걸 '종단 관심사'라고 부르고, 흔히 비즈니스 로직에 집중한다 말할 때와 방향이 맞는다.

빨간 네모 영역은 여러 기능에서 공통으로 필요한 부분이고, 횡단 관심사라고 부른다.
이 부분을 딴 데서 처리하게끔 하면 된다.
기본적으로 스프링에선 스프링 AOP를 받아다가 작성하는 방법이 있다.


근데 꼭 스프링 AOP를 통해서만 하는 것은 아니고,

예를 들어 인가 처리 같은 것들도 사실상 거의 모든 API 요청에서 요구하는 공통 로직에 해당하니까, API Gateway를 통해 인가 처리를 한다면 이것도 관심사를 나눈 셈이다.
인가는 알아서 될 테니까 말이다.


예외 상황에 대한 응답

그중에서 예외에 대한 AOP는 주로 @ControllerAdvice 같은 걸 써서 Global Exception Handler를 구현해서 한다.

요청~응답 흐름 도중에 예외가 발생하면 여기로 점프시킬 수 있다.

그러면 응답 형태도 우리가 원하는 형태로 바꿀 수 있고, 반환할 상태 코드도 쉽게 변경할 수 있다.

구현

(interface) Error Code

interface로 상위에서 묶어 주고, 하위에서 enum으로 나눕니다.

에러 코드는 보통 어지간한 요구사항에선 코드에 종속적이어도 되니 열거형(enum)으로도 많이 쓴다.

이때 에러 코드도 최소한 도메인 단위로는 나누는 게 낫다.

아키텍처와 상관없이 가장 도드라지는 장점은 관리하기 편하다는 것이다. 분류가 가능한 만큼 여러 상황에 맞는 커스텀 예외를 관리하는 것도 굉장히 편하다.
설계상에서 장점을 볼 때는 주로 모듈화한 프로젝트들 구성에서 체감하기 좋은데, 의존성 방향이 합리적이라는 것이다. 하나의 ErrorCode enum 클래스로 묶을 때와 비교할 수 있다.

하나의 ErrorCode enum 클래스에 모든 예외 목록을 작성할 때:

  • 공통 모듈에서 '각 서비스 로직 구현 모듈에서 사용할' 예외 목록을 작성하게 된다.
  • 그리고 각 서비스 로직 구현 모듈이 이 공통 모듈을 사용하게 된다.
  • 각 서비스 구현에 독립적이도록 관리하고 싶은 공통 모듈에, 각 서비스 로직이 요구하는 코드가 추가된 셈이다.

하나의 interface ErrorCode에서 여러 enum CustomErrorCode로 확장:

  • 공통 모듈에는 interface ErrorCode가 존재한다.
  • 각 서비스 구현 모듈 또는 그에 사용되는 구체적인 어느 모듈에 enum ExampleErrorCode 등을 작성한다.
  • 공통 모듈은 각각의 서비스 구현 모듈의 요구에 영향을 받지 않고 독립적으로 관리된다.

이처럼 설계상으로도 의존성 방향에 합리성을 제시할 수 있다. 또한 공통 모듈을 독립적으로 관리할 수 있다.

에러 코드를 여러 enum 클래스로 나눌 때, 확장은 상위에서 interface로 한다.

예시 클래스 다이어그램. 상위에 에러코드 인터페이스를 두고, 하위에 Authentication Failure Error Code 열거형, Accounts Error Code 열거형, Email Verification Error Code 열거형, Payment Error Code 열거형을 둔 모습.

다만 언어적인 한계로 상위에서 묶어 줄 때 약간 양보해야 하는 점도 생긴다.
애노테이션을 만들 때, 애노테이션의 필드(속성)에 열거형 에러코드는 바로 선언할 수 있는데, 인터페이스는 애노테이션의 속성으로 지정할 수 없기 때문에 만약 애노테이션을 활용해야 한다면 아직까지는 열거형마다 따로따로 만들어야 한다. 근데 그런 애노테이션을 만들어 쓸 일이 거의 없다. 만들 일이 생기면 그냥 코드에서 일관성 있게 처리하는 걸로 대신해도 된다.
애노테이션 필드에 관한 이러한 제약이 향후 자바의 어느 버전에서 해결될지 궁금하다. (ex: enum에만 확장할 때 사용하는 특수한 유형의 interface가 추가된다면 해결이 가능할 수도 있다. 그 특수한 유형의 인터페이스도 일반 인터페이스를 extends 또는 implements 할 수 있을 것이다.)

코드

import org.springframework.http.HttpStatus;

public interface ErrorCode {
    String name();
    HttpStatus defaultHttpStatus();
    String defaultMessage();
    RuntimeException defaultException();
    RuntimeException defaultException(Throwable cause);
}
Fig: 어노테이션에 에러 코드를 바로 활용하는 사람들은 이 점이 좀 아쉬울 수 있다. enum이었으면 됐을 텐데. Annotation member에 인터페이스로 된 ErrorCode 타입을 선언할 수 없다고, 즉 어노테이션의 속성에는 인터페이스 같은 건 올 수 없고, 열거형이나 정해진 몇 가지만 올 수 있다는 걸 보여주기 위한 예시 오류 캡처. 오류 메시지는 \ ---

Custom Exception

다른 커스텀 예외들의 상위 타입이 된다. 딱히 추상클래스 같은 게 아니니까, 귀찮으면 에러 코드만 만들고, 에러 코드에서는 얘를 직접 사용해도 된다.

import org.springframework.http.HttpStatus;

public class CustomException extends RuntimeException {

    protected ErrorCode ERROR_CODE;

    private static ErrorCode getDefaultErrorCode() {
        return DefaultErrorCodeHolder.DEFAULT_ERROR_CODE;
    }

    private static class DefaultErrorCodeHolder {
        private static final ErrorCode DEFAULT_ERROR_CODE = new ErrorCode() {
            @Override
            public String name() {
                return "SERVER_ERROR";
            }

            @Override
            public HttpStatus defaultHttpStatus() {
                return HttpStatus.INTERNAL_SERVER_ERROR;
            }

            @Override
            public String defaultMessage() {
                return "서버 오류";
            }

            @Override
            public RuntimeException defaultException() {
                return new CustomException("SERVER_ERROR");
            }

            @Override
            public RuntimeException defaultException(Throwable cause) {
                return new CustomException("SERVER_ERROR", cause);
            }
        };
    }

    public CustomException() {
        this.ERROR_CODE = getDefaultErrorCode();
    }

    public CustomException(String message) {
        super(message);
        this.ERROR_CODE = getDefaultErrorCode();
    }

    public CustomException(String message, Throwable cause) {
        super(message, cause);
        this.ERROR_CODE = getDefaultErrorCode();
    }

    public CustomException(ErrorCode errorCode) {
        super(errorCode.defaultMessage());
        this.ERROR_CODE = errorCode;
    }

    public CustomException(ErrorCode errorCode, Throwable cause) {
        super(errorCode.defaultMessage(), cause);
        this.ERROR_CODE = errorCode;
    }

    public ErrorCode getErrorCode() {
        return ERROR_CODE;
    }
}

코드에서 특징은

  • DEFAULT_ERROR_CODE를 만들 때, 클래스 로드 타임에는 자바가 동시성을 보장해 준다는 점을 이용해서, Thread-safe한 지연로딩을 적용했다. 이러면 getDefaultErrorCode() 메서드를 처음 사용하는 시점에 DEFAULT_ERROR_CODE를 생성하면서도 Thread-safe가 보장된다.
  • ErrorCode를 넣어서 만들 때는 그 에러 코드를 필드로 담지만, 따로 안 넣으면 이 DEFAULT_ERROR_CODE가 담기게 했다.
  • 다른 커스텀 예외들도 이 CustomException 클래스를 상속받아서 사용하면 되고, 이전에 비교 차원에서 이런 상위타입 예외를 두지 않고 사용해 보았을 때보다 당연히 상위 타입을 하나 두고 딱 이곳에서만 ErrorCode를 받아다 관리하게 두는 것이 하위 타입 구현에 훨씬 편리하다.

MemberErrorCode 예시

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@RequiredArgsConstructor
public enum MemberErrorCode implements ErrorCode {
    USERNAME_ALREADY_EXISTS("이미 사용 중인 계정입니다.", HttpStatus.CONFLICT),
    SIGN_UP_FAILED_DEFAULT(
            "회원 가입을 다시 진행해 주십시오. 오류가 지속되는 경우 문의하시기 바랍니다.",
            HttpStatus.INTERNAL_SERVER_ERROR
    ),
    MEMBER_NOT_FOUND("회원을 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
    DEFAULT("회원 취급 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);

    private final String message;
    private final HttpStatus status;

    @Override
    public String defaultMessage() {
        return message;
    }

    @Override
    public HttpStatus defaultHttpStatus() {
        return status;
    }

    // 부모 메서드보다 더 구체적인 타입으로 반환할 수 있다.
    @Override
    public MemberException defaultException() {
        return new MemberException(this);
    }

    @Override
    public MemberException defaultException(Throwable cause) {
        return new MemberException(this, cause);
    }
}

같이 쓰인 MemberException 예시

public class MemberException extends CustomException {
    // 귀찮으면 이런 하위 예외 타입 안 만들고, enum 에러 코드만 새로 생성해서 defaultException 작성 시 바로 new CustomException 써도 됨.
    // 근데 그래도 이거 별로 안 귀찮으니까 그냥 단축키 눌러서 생성자를 자동으로 만드는 건 어떨지. (인텔리제이: Ctrl + O, STS/Eclipse: Alt + Shift + S)
    public MemberException() {
        super();
    }

    public MemberException(String message) {
        super(message);
    }

    public MemberException(String message, Throwable cause) {
        super(message, cause);
    }

    public MemberException(ErrorCode errorCode) {
        super(errorCode);
    }

    public MemberException(ErrorCode errorCode, Throwable cause) {
        super(errorCode, cause);
    }
}

ApiResponseError, ApiSimpleError

이 다음에 Global Exception Handler를 만들 건데, 그때 쓰일 애들이다.
응답 형식을 변형하기 위해서 어느 정도 표준화해 놓은 양식이다.

코드에는 불변 객체 생성에 유용한 자바 record라고 하는 특수한 형태의 자바 클래스 유형을 사용했다. 더 쉽게 쓰자고 나온 클래스라서 너무 의식하지 말고 눈치로 알아보면 된다. 중괄호 내부는 그냥 클래스랑 똑같이 쓰면 되고, 소괄호 부분이 필드들이라 보면 된다.

ApiSimpleError

import lombok.Builder;
import lombok.NonNull;

import java.util.List;

@Builder
public record ApiSimpleError(@NonNull String field, @NonNull String message) {
    public static List<ApiSimpleError> listOfCauseSimpleError(Throwable cause) {
        return List.of(arrayOfCauseSimpleError(cause));
    }

    public static ApiSimpleError[] arrayOfCauseSimpleError(Throwable cause) {
        int depth = 0;
        ApiSimpleError[] subErrors;
        Throwable currentCause = cause;

        while (currentCause != null) {
            currentCause = currentCause.getCause();
            depth++;
        }

        subErrors = new ApiSimpleError[depth];
        currentCause = cause;
        for (int i = 0; i < depth; i++) {
            String errorFullName = currentCause.getClass().getSimpleName();
            String field = errorFullName.substring(errorFullName.lastIndexOf('.') + 1);
            subErrors[i] = ApiSimpleError.builder()
                    .field(field)
                    .message(currentCause.getLocalizedMessage())
                    .build();

            currentCause = currentCause.getCause();
        }

        return subErrors;
    }
}

ApiResponseError.java

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import lombok.Builder;

import java.time.Instant;
import java.util.List;

/**
 *
 * @param code 에러 코드 명
 * @param status 상태 코드 값
 * @param name 오류 이름
 * @param message 오류 메시지
 * @param cause
 * @param timestamp 발생 시각
 */
@Builder
public record ApiResponseError(
        String code,
        Integer status,
        String name,
        String message,
        @JsonInclude(Include.NON_EMPTY) List<ApiSimpleError> cause,
        Instant timestamp
) {
    public static ApiResponseError of(CustomException exception) {
        ErrorCode errorCode = exception.getErrorCode();
        String errorName = exception.getClass().getName();
        errorName = errorName.substring(errorName.lastIndexOf('.') + 1);

        return ApiResponseError.builder()
                .code(errorCode.name())
                .status(errorCode.defaultHttpStatus().value())
                .name(errorName)
                .message(exception.getMessage())
                .cause(ApiSimpleError.listOfCauseSimpleError(exception.getCause()))
                .build();
    }

    public ApiResponseError {
        if (code == null) {
            code = "API_ERROR";
        }
        
        if (status == null) {
            status = 500;
        }
        
        if (name == null) {
            name = "ApiError";
        }
        
        if (message == null || message.isBlank()) {
            message = "API 오류";
        }
        
        if (timestamp == null) {
            timestamp = Instant.now();
        }
    }
}

GlobalExceptionHandler

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.Instant;

@RestControllerAdvice
public final class GlobalExceptionHandler {
    // 따로 더 구체적인 선언이 없다면, CustomException을 상속받은 모든 예외가 이곳으로 온다. 따라서 이 양식을 따르는 한 따로 더 만들 익셉션 핸들러 메서드가 없다.
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ApiResponseError> handleMemberException(CustomException exception) {
        ApiResponseError response = ApiResponseError.of(exception);
        HttpStatus httpStatus = exception
                .getErrorCode()
                .defaultHttpStatus();

        return new ResponseEntity<>(response, httpStatus);
    }

    @ExceptionHandler(NoContentException.class)
    public ResponseEntity<?> handleNoContentException(NoContentException exception) {
        return ResponseEntity.noContent().build();
    }
}

응답 예시

Postman을 통해 편하게 요청을 쏜 화면이다.
윗부분 JSON은 요청의 Body JSON, 아랫부분 JSON은 응답이다.

동일한 회원 정보로 가입 시 409 Conflict 응답과 함께 USERNAME_ALREADY_EXISTS라는 코드, MemberException이라는 이름, "이미 사용 중인 계저입니다."라는 메시지, 발생 시각(서버 기준)을 응답한 예시

보이듯이 timestamp는 그냥 편하게 Offset Date Time으로 했다.
하여간 이제 MemberErrorCode에 종류만 추가하면, 그 예외가 뜰 때 알아서 글로벌 익셉션 핸들러가 반응한다.

사용 예시

throw만 하면 알아서 상태코드, 메시지가 담기며 위와 같은 양식으로 응답한다.

if (...) {
	throw MemberErrorCode.USERNAME_ALREADY_EXISTS.defaultException();
}
try {
	// ...
} catch (Exception e) {
	throw MemberErrorCode.MEMBER_NOT_FOUND.defaultException(e);
}
profile
아 성장판 쑤셔 (블로그 이전) https://letsdev.hashnode.dev
post-custom-banner

1개의 댓글