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)
'Programming > Java, Spring' 카테고리의 다른 글
스프링 웹 환경에서 요청 응답 플로우 (request, filter, interceptor, controller, exceptionHandler) (0) | 2023.10.16 |
---|---|
Spring에서 argumentResolver를 사용하여 인증 책임 분리하기 (0) | 2023.07.23 |
StringBuilder와 String 클래스의 문자열 만드는 효율 차이 (0) | 2023.06.21 |
자바 함수형 프로그래밍과 디자인 패턴 (0) | 2023.06.21 |
자바 함수형 프로그래밍 Scope, Closure&Curry, Lazy Evaluation, Function Composition (0) | 2023.06.20 |