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

Letsdev·2023년 5월 13일
5
post-thumbnail

예외로 응답하기

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

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

AOP(Aspect Oriented Programming)

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

빨간 네모 영역은 여러 기능에서 공통으로 필요한 부분이고, 횡단 관심사라고 부른다.
이 부분을 딴 데서 처리하게끔 하면 된다.
기본적으로 스프링에선 스프링 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로 한다.

다만 언어적인 한계로 상위에서 묶어 줄 때 약간 양보해야 하는 점도 생긴다.
애노테이션을 만들 때, 애노테이션의 필드(속성)에 열거형 에러코드는 바로 선언할 수 있는데, 인터페이스는 애노테이션의 속성으로 지정할 수 없기 때문에 만약 애노테이션을 활용해야 한다면 아직까지는 열거형마다 따로따로 만들어야 한다. 근데 그런 애노테이션을 만들어 쓸 일이 거의 없다. 만들 일이 생기면 그냥 코드에서 일관성 있게 처리하는 걸로 대신해도 된다.
애노테이션 필드에 관한 이러한 제약이 향후 자바의 어느 버전에서 해결될지 궁금하다. (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이었으면 됐을 텐데. ---

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 HttpStatus defaultHttpStatus() {
        return STATUS;
    }

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

    @Override
    public RuntimeException defaultException() {
        return new MemberException(this);
    }

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

같이 쓰인 MemberException 예시

public class MemberException extends CustomException {
    // 귀찮으면 이런 하위 예외 타입 안 만들고, enum 에러 코드만 새로 생성해서 defaultException 작성 시 바로 new CustomException 써도 됨.
    // 근데 그래도 이거 별로 안 귀찮으니까 그냥 단축키 눌러서 생성자를 자동으로 만드는 건 어떨지.
    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);
    }
}

ApiError

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

자바 record라고 하는, 훨씬 편해진 특수한 형태의 자바 클래스 유형이 쓰였는데, 어차피 더 쉽게 쓰자고 나온 거라서 너무 의식하지 말고 눈치로 알아보면 된다. 중괄호 내부는 그냥 클래스랑 똑같이 쓰면 되고, 소괄호 부분이 필드들이라 보면 된다.

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

import java.time.LocalDateTime;
import java.util.List;

@Builder
public record ApiError(
        // Fields: 이곳에 작성한 것들이 멤버이자, 생성자 순서에 해당.
        String code,
        Integer status,
        String name,
        String message,
        @JsonInclude(Include.NON_EMPTY) List<ApiSubError> subErrors,
        LocalDateTime timestamp
) {
    public ApiError { // <-- 소괄호 없는 생성자는:
        // 생성자가 쓰일 때, 생성자의 파라미터에 대한 검토와 변경을 수행하는 공간.
        if (code == null) code = "API_ERROR";
        if (status == null) status = 500;
        if (name == null) name = "ApiError";
        if (message == null || "".equals(message)) message = "API 사용 중 서버에서 오류가 발생했습니다.";
        if (timestamp == null) timestamp = LocalDateTime.now();
    }

    public record ApiSubError(String field, String message) {}

    public static ApiError of(HttpStatus httpStatus) {
        return ApiError.builder()
                .code(httpStatus.name())
                .status(httpStatus.value())
                .name(httpStatus.getReasonPhrase())
                .message(httpStatus.series().name())
                .build();
    }

    public static ApiError of(HttpStatus httpStatus, ApiSubError... subError) {
        List<ApiSubError> subErrors = List.of(subError);

        return ApiError.builder()
                .code(httpStatus.name())
                .status(httpStatus.value())
                .name(httpStatus.getReasonPhrase())
                .message(httpStatus.series().name())
                .subErrors(subErrors)
                .build();
    }

    public static ApiError of(ErrorCode errorCode) {
        String errorName = errorCode.defaultException().getClass().getName();
        errorName = errorName.substring(errorName.lastIndexOf('.') + 1);

        return ApiError.builder()
                .code(errorCode.name())
                .status(errorCode.defaultHttpStatus().value())
                .name(errorName)
                .message(errorCode.defaultMessage())
                .build();
    }

    public static ApiError of(ErrorCode errorCode, ApiSubError... subError) {
        List<ApiSubError> subErrors = List.of(subError);

        return ApiError.builder()
                .code(errorCode.name())
                .status(errorCode.defaultHttpStatus().value())
                .name(errorCode.defaultException().getClass().getName())
                .message(errorCode.defaultMessage())
                .subErrors(subErrors)
                .build();
    }

    public ApiError appendSubErrors(ApiSubError... subError) {
        return this.appendSubErrors(List.of(subError));
    }

    public ApiError appendSubErrors(List<ApiSubError> subErrors) {
        return ApiError.builder()
                .timestamp(this.timestamp())
                .status(this.status())
                .name(this.name())
                .message(this.message())
                .subErrors(subErrors)
                .build();
    }
}

GlobalExceptionHandler

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

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

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

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

응답 예시

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

보이듯이 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
아 성장판 쑤셔

2개의 댓글

comment-user-thumbnail
2024년 3월 11일

와... 이게 객체지향이군요. 스프링 부트를 막 공부하기 시작했는데 진짜 대박이네요 선생님. 감사합니다.

1개의 답글