[Spring] ControllerAdvice와 errors.properties 사용하기

김재연·2023년 4월 27일
0

수숙관

목록 보기
11/17
post-thumbnail

그동안 흐린눈 하면서 지나쳤던 예외처리를 한번 각잡고 싹 정리했다. DB 구조나 기획상 요청이 실패하는 경우를 제외하고, 다뤄야할 예외처리는 총 3개다.

  1. Validation : 요청에 담긴 데이터의 타당성 검증
  2. TypeMismatch : 요청에 담긴 데이터의 자료형 확인
  3. Authorization : 내가 생성한 객체만 수정/삭제 가능하도록 권한 설정

3번 Authorization은 다음 포스팅에 하고, 이번에는 1번 Validation과 2번 TypeMismatch를 다뤄보도록 하겠다.

요청 DTO에 Validation 적용하기

요청 데이터의 타당성을 검증하기 위해서는 요청 DTO를 먼저 살펴봐야 한다.

// DTO
public class ReviewRequestDTO {

    @Getter
    @Setter
    public static class Create {
        @NotNull
        private Long tutoringId;
        @NotBlank
        private String body;
        @NotNull
        private Long tagId;
    }
    ...
}
// Controller
@RestController
public class ReviewController {

    @PostMapping("")
    public ResponseEntity<?> createReview (@Valid @RequestBody ReviewRequestDTO.Create createReview){
        return reviewService.createReview(createReview);
    }
    ...
}

Entity 부분에 @Column(nullable = false) 어노테이션을 쓰긴 했지만, 얘가 요청 Validation을 하는데까지 영향을 끼치진 못했다. Workbench에서 바로 데이터를 insert할때는 원하는대로 null값이 들어가지 않았지만, 내가 만든 API를 통해 데이터를 생성할때는 null값도 잘들어갔었다.

그래서 요청을 보내는 DTO 단에서 null이나 blank를 허용하지 않기 위해 각각의 요청 필드에 @NotNull@NotBlank 어노테이션을 붙였다. (❗Controller 단에서도 @RequestBody 앞에 @Valid를 붙여야 적용된다.) 그랬더니 요청에 맞지 않는 값을 보냈을 때 예외처리가 되면서 널이어서는 안됩니다, 공백일 수 없습니다라는 아주 못쉥긴 기본 메세지가 반환되었다.

message 인자로 메세지 커스텀하기

메세지 커스텀을 위해 어노테이션 안에 message 인자를 주었다.

@NotNull(message = "수업번호는 필수 입력 항목입니다.")
private Long tutoringId;

그럼 디폴트 메세지로 기본 메세지 대신 커스텀 메세지인 수업번호는 필수 입력 항목입니다. 가 반환된다.

반복되는 문구를 변수에 담아 쓰기

그런데 똑같은 어노테이션마다 똑같은 문구를 여러번 반복해서 코딩하는게 마음에 너무 안들어서 아래처럼 수정했다.

BindingResult로 예외처리하기

근데 이것도 별로 마음에 안들어서 다른 방법을 찾다가 스프링에서 예외처리는 BindingResult를 이용해서 한다는 것을 알게 되었다. 이 방법을 최종적으로 쓰진 않았지만 필요한 개념이기에 간단하게 정리만 하겠다.

BindingResult 사용하기

BindingResult는 검증오류를 보관하는 객체로, 핸들러 매개변수에서 자신이 검증할 객체 바로 다음에 위치해야 한다. 에러가 발생했을때 스프링이 자동으로 넣어줄 수도 있고(바인딩 실패의 경우), 개발자가 직접 검증해서 오류를 추가해줄 수도 있다.

// Controller
@PostMapping("")
public String addItem(@ModelAttribute Item item, BindingResult bindingResult)
{
	...
}

<1> FieldError + addError

item의 price 범위 검증 (1) : FieldError를 직접 생성해서 addError()로 직접 추가

  1. public FieldError(String objectName, String field, String defaultMessage);
  2. public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName: 오류가 발생한 객체 이름
  • field: 오류 필드
  • rejectedValue: 사용자가 입력한 값(거절된 값)
  • bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
      bindingResult.addError( // bindingResult에 오류 추가
      		new FieldError( // 필드 에러를 추가할 것임
        		"item", // item 객체에서 오류 발생
        		"price", // price 필드에서 오류 발생
            	item.getPrice(), // 사용자가 입력한 값
             	false, // 검증 실패
          		new String[] {"range.item.price"}, // 메시지 코드
 	          	new Object[]{1000, 1_000_000}, // 메시지 코드에서 사용하는 인자
                null // 기본 오류 메시지 없음
            )
      );
  }

<2> ObjectError + addError

item의 객체 검증 (1) : ObjectError를 직접 생성해서 addError()로 직접 추가

  1. public ObjectError(String objectName, String defaultMessage)
  2. public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
  • objectName: 오류가 발생한 객체 이름
  • codes: 메시지 코드
  • arguments: 메시지에서 사용하는 인자
  • defaultMessage: 기본 오류 메시지
if (item.getPrice() != null && item.getQuantity() != null) {
	int totalPrice = item.getQuantity() * item.getPrice();
	if (totalPrice < 10000) {
		bindingResult.addError( // bindingResult에 오류 추가
        	new ObjectError( // 객체 에러를 추가할 것임
            		"item", // item 객체에서 오류 발생
                    "가격 * 수량의 합은 10,000원 이상이여야 합니다. 현재 값 = " + totalPrice // 기본 오류 메시지
                )
		);
	}
}

❗하지만 위의 방법은 생성자의 매개변수가 너무 많다. 그리고 BindingResult는 자신이 검증해야할 객체 바로 다음에 오기 때문에 자신이 검증할 객체에 대해 이미 알고있어서 굳이 쓸 필요가 없다. 그래서 FieldErrorrejectValue()로, ObjectErrorreject()를 통해 단순화할 수 있다.

<3> rejectValue()

item의 price 범위 검증 (2) : FieldError를 생성하지 않고 rejectValue()로 직접 추가

  1. void rejectValue(@Nullable String field, String errorCode, String defaultMessage);
  2. void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field: 오류 필드명
  • errorCode: 오류 코드
  • errorArgs: 오류 메시지의 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을수 없을 때 사용하는 기본 메시지
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1_000_000) {
	  bindingResult.rejectValue(
    				"price", // price 필드에서 오류 발생
    				"range", // 오류 코드
    				new Object[]{1000, 1_000_000}, // 오류 메시지 인자
    				null // 기본 오류 메시지 없음
      );
}

<4> reject()

item의 객체 검증 (2) : ObjectError를 직접 생성하지 않고 reject()로 직접 추가

  1. void reject(String errorCode, String defaultMessage);
  2. void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • errorCode: 오류 코드
  • errorArgs: 오류 메시지의 {0}을 치환하기 위한 값
  • defaultMessage: 오류 메시지를 찾을수 없을 때 사용하는 기본 메시지
if (item.getPrice() != null && item.getQuantity() != null) {
	int resultPrice = item.getPrice() * item.getQuantity();
	if (resultPrice < 10000) {
          	bindingResult.reject(
          					"totalPriceMin", // 오류코드
          					new Object[]{10000, resultPrice}, // 오류 메시지 인자 
          					null // 기본 오류 메시지 없음
          	);
	}
}

<5> hasErrors()

그리고 마지막으로 다음 코드를 통해 검증에 실패했음을 확인한다.

if (bindingResult.hasErrors()) {
      return "검증 실패";
}

<6> MessageCodesResolver (errors.properties)

BindingResult에 FieldError나 ObjectError 를 추가하면 MessageCodesResolver가 메시지 코드들을 생성하는데, 여기에도 규칙이 있다.

ObjectError

1. code + "." + object name
2. code

FieldError

1. code + "." + object name + "." field
2. code + "." + field
3. code + "." field type
4. code

위의 공식에 맞춰 errors.properties를 작성한다.

range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}

💡application.propertiesspring.messages.basename=errors를 추가해야 한다.

<7> 에러메시지는 어떻게 나올까?

그러면

<1>에서는 new String[] {"range.item.price"}, new Object[]{1000, 1_000_000} 을 보냈으므로 가격은 1000 ~ 1_000_000 까지 허용합니다.가 나올 것이다.

<3>에서는 "range", new Object[]{1000, 1_000_000}를 보냈으므로 range.item.price를 모두 보내진 않았지만 구체적인 것에서 덜 구체적인 것의 순서로 메시지 코드들을 생성하는 규칙에 따라 code인 range에서 걸려 <1>과 마찬가지로 가격은 1000 ~ 1_000_000 까지 허용합니다.가 나올 것이다.

그리고 <4>에서는 "totalPriceMin", new Object[]{10000, resultPrice}를 보냈으므로 code인 totalPriceMin에 걸려 전체 가격은 10000원 이상이어야 합니다. 현재 값 = ?이 나올 것이다.

✋하지뫈,,

그런데 이런식으로 bindingResult를 사용해서 하나하나 예외처리를 하는건 내가 원하는 방식이 아니었고, 나는 한방에 해결하고 싶었다. 더군다나 기껏 @NotNull@NotBlank 어노테이션을 붙였는데 예외처리 조건을 또 코딩하는건 이상하다고 생각했다. 그러다가 @ExceptionHandler@ControllerAdvice를 통해 전역예외처리를 하는 방법을 발견했다.

❗전역예외처리컨트롤러 만들기

@ExceptionHandler

@Controller, @RestController가 적용된 Bean내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해준다. 이 어노테이션 안에 인자로 캐치하고 싶은 예외클래스를 등록하면, 해당 Bean에서 그 예외클래스가 발생했을 때 이 어노테이션이 붙은 메소드가 실행된다.

예를 들어 아래와 같은 코드가 있을 때,

@RestController
public class MyRestController {
    ...
    ...
    @ExceptionHandler(NullPointerException.class)
    public Object nullex(Exception e) {
        System.err.println(e.getClass());
        return "myService";
    }
}

MyRestController의 Bean에서(=>MyRestController 안에 선언된 메소드들에서) NullPointerException이 발생하면, nullex 메소드가 실행된다.

반면에 @ExeptionHandler는 이를 등록한 컨트롤러에만 적용되기 때문에, 다른 컨트롤러에서는 NullPointerException이 발생해도 nullex 메소드가 실행되지 않는다.

2개 이상의 예외클래스를 등록하고 싶을 때는 {}로 묶고 ,로 연결하면 된다.

@ExceptionHandler({ Exception1.class, Exception2.class})

@ControllerAdvice

프로젝트 내에 존재하는 모든 컨트롤러, 즉 전역에서 발생할 수 있는 예외를 잡아 처리하는 어노테이션이다.

새로운 클래스 파일을 만들어서 @ControllerAdvice를 붙여주고, 그 안에 @ExceptionHandler를 붙여서 처리하고 싶은 예외메소드들을 정리해주면 된다.

@RestControllerAdvice
public class MyAdvice {
    @ExceptionHandler(Exception1.class)
    public String exception1() {
        return "hello exception1";
    }
    
    @ExceptionHandler(Exception2.class)
    public String exception2() {
        return "hello exception2";
    }
    ...
}

@RestControllerAdvice@ControllerAdvice와 기능은 똑같지만 @ResponseBody를 통해 객체를 리턴할 수 있다. (@Controller@RestController의 차이와 비슷)

내 ApiControllerAdvice 코드

그래서 나도 전역예외처리컨트롤러를 만들었다.

// src/main/java/springbeam/susukgwan/ApiControllerAdvice.java

@RestControllerAdvice
public class ApiControllerAdvice {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors()
                .forEach(c -> {
                    errors.put(((FieldError) c).getField(), c.getDefaultMessage());
                });
        return ResponseEntity.badRequest().body(errors);
    }
}

@NotNull이나 @NotBlank 등의 어노테이션에 걸려 생기는 예외클래스가 MethodArgumentNotValidException이기 때문에 인자로 MethodArgumentNotValidException.class를 넣었다. 이 예외가 발생하면, handleValidationExceptions가 실행된다.

코드를 요약하자면, 이때 발생한 예외를 ex라고 했을 때, ex에 스프링이 자동으로 넣어준 오류들을 ex.getBindingResult().getAllErrors()로 가지고 와서, 내 입맛대로 에러를 구성한 다음에 그 에러들을 반환해주는 것이다. 여기서 bindingResult를 이해해야 코드가 잘 이해되기 때문에 굳이 위에 직접 쓰지도 않은 방법들인 rejectValue, reject 등등을 정리한 것이다.

이렇게 쓰면 DTO에 검증 어노테이션만 써도 내가 원했던, Validation 전역예외처리가 가능하다. 그런데 여기서 또 문제점이 있다. 바로 c.getDefaultMessage()이다.

ApiControllerAdvice에 errors.properties에서 에러메시지 가져오기

나는 에러메세지를 errors.properties에서 관리하고 싶었지만, c.getDefaultMessage()는 또다시 스프링의 기본메세지만을 가지고 왔다. 어노테이션 안에 message 인자로 내가 만든 메시지를 넣으면, 그 메시지를 가지고 오긴 했다. 그런데 이렇게 대충 해결하면 또 반복되는 코드가 있을 것 같아서, errors.properties에서 에러메시지를 가지고 오는 함수를 따로 작성했다.

// errors.properties
NotNull={0}() 필수 입력 항목입니다.
NotBlank={0}() 필수 입력 항목이며 공백을 제외한 문자를 하나 이상 포함해야 합니다.
// ApiControllerAdvice.java
@RestControllerAdvice
public class ApiControllerAdvice {

    private final MessageSource messageSource;

    public ApiControllerAdvice(MessageSource messageSource) {
        this.messageSource = messageSource;
    }
    
	private String getErrorMessage(ObjectError error) {
		String[] codes = error.getCodes();
		for (String code : codes) {
			try {
				return messageSource.getMessage(code, error.getArguments(), Locale.KOREA);
			} catch (NoSuchMessageException ignored) {}
		}
		return error.getDefaultMessage();
	}
}

그리고 handleValidationExceptions에서 메시지 가져오는 코드를 이렇게 바꾸면,

// ApiControllerAdvice.java
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
	Map<String, String> errors = new HashMap<>();
	ex.getBindingResult().getAllErrors()
                .forEach(c -> {
                    errors.put(((FieldError) c).getField(), getErrorMessage(c));
                });
	return ResponseEntity.badRequest().body(errors);
}

드디어 원하는대로 성공이다.

TypeMismatch 예외처리

똑같은 흐름으로 TypeMismatch도 핸들링한다.

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, String>> handleTypeMismatchExceptions(HttpMessageNotReadableException ex){
        Map<String, String> errors = new HashMap<>();

        Pattern errorFieldPattern = Pattern.compile("\\[[\"](.*?)[\"]\\]");
        Matcher errorFieldMatcher = errorFieldPattern.matcher(ex.getCause().getMessage());
        String errorField = errorFieldMatcher.find() ? errorFieldMatcher.group(1) : "FAIL";

        Pattern rightTypePattern = Pattern.compile("[`](.*?)[`]");
        Matcher rightTypeMatcher = rightTypePattern.matcher(ex.getMessage());
        String rightType = rightTypeMatcher.find() ? rightTypeMatcher.group(1) : "?";

        String errorMessage = messageSource.getMessage("typeMismatch", new Object[] {rightType}, Locale.KOREA);
        errors.put(errorField, errorMessage);

        log.error(ex.toString());

        return ResponseEntity.badRequest().body(errors);
}
// errors.properties
typeMismatch=잘못된 타입입니다. {0} 형으로 입력해주세요.

TypeMismatch가 발생했을 때는 HttpMessageNotReadableException가 발생하기 때문에 이 예외클래스를 받는다. 그리고 기본 에러메시지에서 Pattern과 Matcher를 사용해 에러가 난 필드와 올바른 자료형을 추출해내서 에러메시지를 구성한다.

💡원래 타입이 맞지 않으면 스프링이 typeMismatch라는 오류 코드를 만들어내는데, @RequestBody를 통해 Json을 파싱하는 경우에는 타입이 맞지 않을경우 HttpMessageNotReadableException가 발생해서, 이 경우에는 MessageCodesResolver가 작동하지 않아 에러코드가 생성되지 않는다.

기본메세지에서 문자열 패턴 추출

에러필드와 올바른 자료형을 기본메세지에서 추출해내는 패턴을 쓰는것도 어려웠다. 하필 문자열 패턴을 찾는 키워드인 []하고 "" 안에 있는 값을 가져와야 해서...

에러메시지 : Cannot ~중략~ $Create["tutoringId"])
	=> [""] 안에 있는 tutoringId 추출
    => Pattern.compile("\\[[\"](.*?)[\"]\\]")
    
에러메시지 : JSON parse error: ~중략~ `java.lang.Long` ~중략~
	=> `` 안에 있는 java.lang.Long 추출
    => Pattern.compile("[`](.*?)[`]")

어쨌든 해냄


Reference

@ControllerAdvice, @ExceptionHandler를 이용한 예외처리 분리, 통합하기(Spring에서 예외 관리하는 방법, 실무에서는 어떻게?)
[Spring] 검증(1) - BindingResult, MessageCodesResolver
@Valid 를 이용해 @RequestBody 객체 검증하기
Account_exceptionHandler
[MVC] Bean Validation - 검증

profile
일기장같은 공부기록📝

0개의 댓글