본문 바로가기
Programming/Java, Spring

스프링 프로젝트 API Server Error 처리하기

by Renechoi 2023. 7. 9.

1. 통일된 Error Response를 갖게 하자. 

 

문제점 

통일된 Response를 갖지 않으면 ... 클라이언트 쪽에서 서로 다른 형식의 Error Response를 처리해야 한다.

-> 추가적인 작업과 오류 처리 로직이 필요하게 된다.


 

제안 

 

다음과 같은 형식의 Error Response 형식을 고려해볼 수 있다. 

 

{
  "message": "Invalid Input Value",
  "status: 400,
  // "errors": [], 비어 있을 경우 null이 아닌 빈 배열을 넘긴다. 
  "errors: [
    {
      "field": "name.last",
      "value": "",
      "reason": "must not be empty",
    },
    {
      "field": "name.first",
      "value": "",
      "reason": "must not be empty",
    }
   ],
   "code": "C001"
}

 

자바에서 null 처리를 하는 것이 번거롭듯이 클라이언트 쪽에서도 null 처리가 어려울 수 있다. 따라서 null 보다는 빈 배열을 내리는 게 더 효율적이다. 

 

Error Response 객체는 다음과 같이 작성한다. 

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {

    private String message;
    private int status;
    private List<FieldError> errors;
    private String code;


    private ErrorResponse(final ErrorCode code, final List<FieldError> errors) {
        this.message = code.getMessage();
        this.status = code.getStatus();
        this.errors = errors;
        this.code = code.getCode();
    }

    private ErrorResponse(final ErrorCode code) {
        this.message = code.getMessage();
        this.status = code.getStatus();
        this.code = code.getCode();
        this.errors = new ArrayList<>();
    }
    
    //..

 

간혹 Map 자료형으로 Response 객체를 관리하는 경우가 있는데, 그보다는 객체 형으로 갖는 것이 좋다. 

 

에러 코드는 다음과 같이 정리해서 관리할 수 있다. 

 

import lombok.Getter;

@Getter
public enum ErrorCode {

   // Common
   INVALID_INPUT_VALUE(400, "C001", " Invalid Input Value"),
   METHOD_NOT_ALLOWED(405, "C002", " Invalid Input Value"),
   ENTITY_NOT_FOUND(400, "C003", " Entity Not Found"),
   INTERNAL_SERVER_ERROR(500, "C004", "Server Error"),
   INVALID_TYPE_VALUE(400, "C005", " Invalid Type Value"),
   HANDLE_ACCESS_DENIED(403, "C006", "Access is Denied"),

   // Member
   EMAIL_DUPLICATION(400, "M001", "Email is Duplication"),
   LOGIN_INPUT_INVALID(400, "M002", "Login input is invalid"),

   // Coupon
   COUPON_ALREADY_USE(400, "CO001", "Coupon was already used"),
   COUPON_EXPIRE(400, "CO002", "Coupon was already expired");
   private final String code;
   private final String message;
   private int status;

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

 

 

시스템 자체에서 정의한 에러 코드를 내려줄 수 있도록 작성한다. 예를 들어 "C001", "M001" 같은 것이다.

 

커스텀 에러 코드를 사용하는 장점은 두 가지 측면에서 생각볼 수 있다. 

 

첫 번째는 효율성 측면이다. 코드값이 가진 메시지를 함께 제공함으로써 커뮤니케이션 비용을 아낄 수 있다. 

 

두 번째는 보안성 측면이다. Http 에러 코드가 그대로 노출되는 경우 기술상의 노출이나 내부 구조에 대한 노출로 연결될 수 있다.

 

따라서 정제된 메시지를 보내는 것이 유리하다. 

 

 

 

2.  Controller Advice를 활용한 일관된 핸들링

 

 

필요성 

- 컨트롤러의 중요한 책임 중 하나는 서비스로 보내기 전 요청에 대한 값을 검증해주는 것이다. 

- Spring은 Bean Validation을 통해 검증을 쉽고 일관성 있게 할 수 있도록 도와준다. 

 

 

방법 

- 모든 예외가 @ControllerAdvise에서 처리될 수 있도록 애너테이션을 사용한 별도의 ExceptionHandler 클래스를 정의한다. 

 

 

다음과 같은 예시 클래스를 살펴보자. 

 

@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {



    /**
     * 모든 예외 처리
     */  
    @ExceptionHandler(Exception.class)
    protected ResponseEntity<ErrorResponse> handleException(Exception e) {
        log.error("handleEntityNotFoundException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
    

    /**
     * javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
     * HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할경우 발생
     * 주로 @RequestBody, @RequestPart 어노테이션에서 발생
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("handleMethodArgumentNotValidException", e);
        final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }

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

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

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

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

 

@ControllerAdvise는 base package에서 발생하는 exception을 aop 방식으로 캐치한다. 

 

위의 설명에서 보듯이 설정해주지 않으면 해당 advise가 존재하는 패키지가 default 패키지가 된다. 

 

이때 특정 잡고자하는 특정 예외를 다음과 같이 설정해줄 수 있다. 

@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
    log.error("handleEntityNotFoundException", e);
    final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
    return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
@ExceptionHandler(AccessDeniedException.class)
protected ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
    log.error("handleAccessDeniedException", e);
    final ErrorResponse response = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED);
    return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.HANDLE_ACCESS_DENIED.getStatus()));
}

 

@ExceptionHandler 인자값으로 어떤 exception을 받는지 정해준다. Exception은 최상위 Exception이므로 해당 메서드만 설정할 경우 모든 예외를 잡으며, 디테일 우선순위에 따라 다른 예외들을 정의했다면 보다 구체적인 예외를 별도로 처리할 수 있다. 

 

 

스프링에서 제공하는 @Valid 검증 로직으로 리턴되는 예외를 처리하는 메서드를 살펴보자.

 

private ErrorResponse(final ErrorCode code, final List<FieldError> errors) {
    this.message = code.getMessage();
    this.status = code.getStatus();
    this.errors = errors;
    this.code = code.getCode();
}
/**
 * @ModelAttribute 으로 binding error 발생시 BindException 발생한다.
 */
@ExceptionHandler(BindException.class)
protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {
    log.error("handleBindException", e);
    final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
    return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}

 

handleBindException 메서드는 BindException 예외가 발생했을 때 호출된다. 이때 발생한 바인딩 오류에 대한 정보를 포함한 ErrorResponse 객체가 정적 메서드인 of를 통해 생성되며, 이를 통해 클라이언트에게 HttpStatus.BAD_REQUEST 메시지를 가진 에러 response를 전달한다. 

 

 

또 한가지 포인트는 BusinessException을 최상위 Exception으로 설정해두고 이를 기준으로 적절한 예외를 만들어서 내려주는 것이다. 비즈니스 로직을 수행하는 코드 흐름에서 예외가 발생하는 경우 직접 정의한 적절한 BusinessException 중 하나로 예외를 발생시켜 내려준다.

 

이때 하나의 메서드에서 이를 처리하면서 일관된 방식의 처리가 가능하고 또한 알림과 같은 부가적인 기능도 수행할 수 있다. 필요하다면 특정 exception을 받도록 정의해 더 디테일한 처리도 물론 가능하다. 

 

public class BusinessException extends RuntimeException {

    private ErrorCode errorCode;

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

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

    public ErrorCode getErrorCode() {
        return errorCode;
    }

}

 

 

 

 

3. 효율적인 Validation을 위한 방법 

 

Spring에서 제공하는 @Valid 방식과 유사하게 커스텀 validation 을 정의할 수 있다. 예를 들어 Email Validation을 해야 하는 경우라고 해보자. @EmailUnique 애노테이션을 통해 중복 이메일을 검증할 수 있다면 여러 코드 없이 효율적이고 일관되게 검증 기능을 수행할 수 있을 것이다. 

 

@Documented
@Constraint(validatedBy = EmailDuplicationValidator.class)
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EmailUnique {

    String message() default "Email is Duplication";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

 

@Component
@RequiredArgsConstructor
public class EmailDuplicationValidator implements ConstraintValidator<EmailUnique, String> {

   private final MemberRepository memberRepository;

   @Override
   public void initialize(EmailUnique emailUnique) {
   }

   @Override
   public boolean isValid(String email, ConstraintValidatorContext cxt) {
      boolean isExistEmail = memberRepository.existsByEmail(email);

      if (isExistEmail) {
         cxt.disableDefaultConstraintViolation();
         cxt.buildConstraintViolationWithTemplate(
               MessageFormat.format("Email {0} already exists!", email))
            .addConstraintViolation();
      }
      return !isExistEmail;
   }
}

 

EmailUnique 애노테이션은 EmailDuplicationValidator에서 작성한 검증 로직에 따라 검증 기능을 수행한다. 

 

EmailDuplicationValidator는 ConstraintValidator<>를 구현하는 구현체인데 이때 애노테이션을 인자값으로 넣어준다. 두번째 인자값은 검증할 값의 자료형을 의미한다. 

 

isValid메서드가 핵심이다. 여기서 구현한 검증 로직이 실제 검증에서 사용되는 로직이다. 

 

사용 예시는 다음과 같다. 

 

public class SignupRequest{
	@EmailUnique
    private String email;
    
    // ..

 

 

또다른 커스텀 애노테이션 예시를 보자. 

 

다음과 같은 요구사항이 있다.

- 주문에 있어서 결제 방식이 무통장과 카드 결제가 있는 상황에서 필수 인자가 다르다.

- 무통장 : AccountNumber, bankcode, holder

- 카드: number, brand, csv 

 

이런 경우 각각에 따라 다른 로직을 수행해야 하므로 컨트롤러에서 다음과 같이 작성해야 한다. 

 

@PostMapping
public OrderSheetRequest order(@RequestBody @Valid OrderSheetRequest dto){
	if (dto.getPayment().getPaymentMethod()== PaymentMethod.BANK_TRANSFER)) {
       // 계좌정보가 제대로 넘어 왔는지 검증
    }
    
    if ((dto.getPayment().getPaymentMetho()==PaymentMethod.CARD)) {
      // 카드 정보가 제대로 넘어 왔는지 검증 
    }
    
    return dto; 
}

 

이를 커스텀 애노테이션을 이용하면 보다 일관성있고 효율적으로 검증 기능을 작성할 수 있다. 

 

이를 애노테이션으로 해결해보자. 

 


@Documented
@Constraint(validatedBy = OrderSheetFormValidator.class)
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface OrderSheetForm {

    String message() default "Order sheet form is invalid";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

 

OrderSheetFormValidator의 검증 메서드는 다음과 같이 작성한다. 

 

@Override
public boolean isValid(OrderSheetRequest value, ConstraintValidatorContext context) {
   int invalidCount = 0;

   if (value.getPayment().getAccount() == null && value.getPayment().getCard() == null) {
      addConstraintViolation(context, "카드 정보 혹은 계좌정보는 필수입니다.", "payment");
      invalidCount += 1;
   }

   if (value.getPayment().getPaymentMethod() == PaymentMethod.CARD) {
      final Card card = value.getPayment().getCard();

      if (card == null) {
         addConstraintViolation(context, "카드 필수입니다.", "payment", "card");
         invalidCount += 1;
      } else {
         if (ObjectUtils.isEmpty(card.getBrand())) {
            addConstraintViolation(context, "카드 브렌드는 필수입니다.", "payment", "card", "brand");
            invalidCount += 1;
         }
         if (ObjectUtils.isEmpty(card.getCsv())) {
            addConstraintViolation(context, "CSV 값은 필수 입니다.", "payment", "card", "csv");
            invalidCount += 1;
         }
         if (ObjectUtils.isEmpty(card.getNumber())) {
            addConstraintViolation(context, "카드 번호는 필수 입니다.", "payment", "card", "number");
            invalidCount += 1;
         }
      }
   }

   if (value.getPayment().getPaymentMethod() == PaymentMethod.BANK_TRANSFER) {
      final Account account = value.getPayment().getAccount();

      if (account == null) {
         addConstraintViolation(context, "계좌정보는 필수입니다.", "payment", "account");
         invalidCount += 1;
      } else {
         if (ObjectUtils.isEmpty(account.getBankCode())) {
            addConstraintViolation(context, "은행코드는 필수입니다.", "payment", "account", "bankCode");
            invalidCount += 1;
         }
         if (ObjectUtils.isEmpty(account.getHolder())) {
            addConstraintViolation(context, "계좌주는 값은 필수 입니다.", "payment", "account", "holder");
            invalidCount += 1;
         }
         if (ObjectUtils.isEmpty(account.getNumber())) {
            addConstraintViolation(context, "계좌번호는 필수값입니다.", "payment", "account", "number");
            invalidCount += 1;
         }
      }
   }

   return invalidCount == 0;
}

 

 

이처럼 Spring validation을 활용하여 보다 견고하고 일관적인 API를 만들 수 있다. 

 


 

참고자료

- 패스트 캠퍼스 (한 번에 끝내는 Spring 완.전.판 초격차 패키지 Online - Part9)

 

 

반응형