지난 포스팅에 이어, 이번 포스팅에서는 9) ~ 14)
까지의 내용을 정리한다.
👉 목차는 다음과 같다.
1) 검증 요구사항
2) 프로젝트 설정 V1
3) 검증 직접 처리 - 소개
4) 검증 직접 처리 - 개발
5) 프로젝트 준비 V2
6) BindingResult1
7) BindingResult2
8) FieldError, ObjectError
9) 오류 코드와 메시지 처리1
10) 오류 코드와 메시지 처리2
11) 오류 코드와 메시지 처리3
12) 오류 코드와 메시지 처리4
13) 오류 코드와 메시지 처리5
14) 오류 코드와 메시지 처리6
15) Validator 분리1
16) Validator 분리2
17) 정리
목표: 오류 메시지를 좀 더 체계적으로 다루어보자.
FieldError 생성자
FieldError
는 두 가지 생성자를 제공한다.objectName
: 오류가 발생한 객체 이름.field
: 오류 필드. (필드명)rejectedValue
: 사용자가 입력한 값 (거절된 값)bindingFailure
: 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값.codes
: 메시지 코드arguments
: 메시지에서 사용하는 인자defaultMessage
: 기본 오류 메시지지금까지는 FieldError
와 ObjectError
를 사용하면서, 오류 메시지를 defaultMessage
를 통해서 직접 작성해서 적용하였다. 그런데 이런 오류 메시지도 한 군데서 일관성있게 관리하는 것이 더 좋다. (예를 들어, "상품 이름은 필수입니다."와 같은 오류 메시지의 경우, 한 군데가 아닌 여러 군데에서 사용될 수 있다. 그런데 이걸 그때마다 다 개별로 작성하면, 어디서는 "상품 이름을 입력해주세요.",... 와 같이 일관되지 않게 관리될 확률이 높다.) 그래서 FieldError
와 ObjectError
는 생성자의 codes , arguments 파라미터를 제공하여 오류 메시지를 일관성있게 관리할 수 있도록 지원한다. (이것을 통해서 이전에 메시지, 국제화에서 학습했던 것 처럼, 오류 메시지를 messages.properties
같은 곳에서 찾아온 다음, 없으면 defaultMessage
를 적용하는게 가능해진다.)
FieldError
, ObjectError
의 생성자는 codes , arguments 를 제공한다.
이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.
errors 메시지 파일 생성
messages.properties
를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties
라는 별도의 파일로 관리해보자.
먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다.
이렇게하면 messages.properties
, errors.properties
두 파일을 모두 인식한다.
(참고. 생략하면 messages.properties
를 기본으로 인식한다.)
errors.properties
파일 추가 ( src/main/resources/errors.properties
)errors_en.properties
파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.
👉 이제 errors 에 등록한 메시지를 사용하도록 코드를 변경해보자.
codes
는 String 배열로 넣어야 한다. 내부에는 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. (배열로 전달된 여러 메시지 코드에서 매칭되는 메시지를 찾지 못하면, defaultMessage를 출력하고, defaultMessage 조차 없다면(null) 오류가 난다.)arguments
는 Object 배열로 넣어야 한다. Object[]{1000, 1000000}
와 같이 사용하여, 오류 메시지에서 {0}
, {1}
을 치환하기 위한 값을 전달한다.defaultMessage
는 예제에서 앞으로 사용하지 않을 것이므로 null
로 적용하였다.MessageSource
를 찾아서 메시지를 조회하는 것을 확인할 수 있다.그런데 바꾸고 보니 약간 중복되어 보이는 것도 많고, 복잡한 느낌도 든다.
다음 내용부터 한 단계씩 개선해보자.
목표
FieldError
, ObjectError
는 다루기 너무 번거롭다. (넣어줘야 하는 파라미터도 너무 많다.)item.itemName
처럼?
🤔 코드를 좀 더 줄이는 방법에 대해서 고민해보자.
컨트롤러에서 BindingResult
는 검증해야 할 객체인 target
바로 다음에 온다.
따라서, BindingResult
는 이미 본인이 검증해야 할 객체인 target
을 알고있다.
(이미 target을 알고있다면, 컨트롤러 로직에서 FieldError
, ObjectError
를 생성할 때, 파라미터로 넣어줬던 objectName 등은 생략할 수도 있지 않을까 ?)
bindingResult
에서 target
에 대한 정보를 가지고있는 것을 알수있다. 그렇다면 코드를 줄일 수 있어보인다.
✔️ rejectValue(), reject()
BindingResult
가 제공하는 rejectValue()
, reject()
를 사용하면 FieldError
, ObjectError
를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
rejectValue() , reject() 를 사용해서 기존 코드를 단순화해보자.
rejectValue()
: 필드 검증 오류 처리를 위해 사용한다. (참고로 첫 번째 파라미터로 바로 field가 나온다. BindingResult
가 이미 target을 알고있기 때문에 objectName은 입력하지 않는다.)reject()
: 글로벌 검증 오류를 처리하기 위해 사용한다. (BindingResult
가 이미 target을 알고있기 때문에 objectName은 입력하지 않는다.)errors.properties
에 있는 코드를 직접 입력하지 않았는데 어떻게 된 것일까? (아래 축약된 오류 코드 참고)
👉 rejectValue()
field
: 오류 필드명errorCode
: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)errorArgs
: 오류 메시지에서 {0}
을 치환하기 위한 값defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지BindingResult
는 어떤 객체를 대상으로 검증하는지 target을 이미 알고있다고 했다. 따라서, target(item)에 대한 정보는 없어도 된다. (오류 필드명은 동일하게 입력했다.)축약된 오류 코드
FieldError
를 직접 다룰 때는 오류 코드를 range.item.price
와 같이 모두 입력했다.rejectValue()
를 사용하고 부터는 오류 코드를 "range"
, "max"
와 같이 간단하게 입력했다. 그래도 오류 메시지를 잘 찾아서 출력한다. 무언가 규칙이 있는 것처럼 보인다. 이 부분을 이해하려면 MessageCodesResolver
를 이해해야 한다. 왜 이런식으로 오류 코드를 구성하는지 바로 다음에 자세히 알아보자.👉 reject()
rejectValue()
와 reject()
를 사용하면서, 전보다는 코드가 많이 줄어들었음을 확인할 수 있다.
그런데 아직 풀리지 않은, 오류 코드를 어떻게 조합해서 만들어 내는지 알아보자.
🤔 오류 코드를 어떤식으로 설계하면 좋을까?
오류 코드를 만들때 다음과 같이 자세히 만들 수도 있고,
required.item.itemName
: 상품 이름은 필수 입니다.range.item.price
: 상품의 가격 범위 오류 입니다.max.item.quantity
: 수량은 최대 {0}
까지 허용합니다.또는 다음과 같이 단순하게 만들 수도 있다.
required
: 필수 값 입니다.range
: 범위 오류 입니다.max
: 최대 {0}
까지 허용 가능합니다.단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
예를 들어서 required
라고 오류 코드를 사용한다고 가정해보자. 다음과 같이 required
라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.
그런데 오류 메시지에 required.item.itemName
와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
물론 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발을 해야겠지만, 범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리할 수 있을 것이다.
☝️ 스프링은 MessageCodesResolver
라는 것으로 이러한 기능을 지원한다.
이번 내용에서는 MessageCodesResolver
에 대해서 알아보자.
( MessageCodesResolver
의 개념을 이해하게 되면, 이전에 errorCode
로 "required"
만 넣었음에도 불구하고, "required.item.itemName"
또한 오류 코드로 만들어진 배경을 이해할 수 있다. )
👉 우선 테스트 코드로 MessageCodesResolver
를 알아보자.
✔️ MessageCodesResolver
MessageCodesResolver
는 인터페이스이고 DefaultMessageCodesResolver
는 기본 구현체이다.ObjectError
, FieldError
✔️ DefaultMessageCodesResolver의 기본 메시지 생성 규칙
required
, object name = item
typeMismatch
, object name = user
, field = age
, field type = int
✔️ 동작 방식
MessageCodesResolver
를 사용한다. 여기에서 메시지 코드들을 생성한다.FieldError
, ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를(배열) 가질 수 있다. MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다.BindingResult
의 로그를 통해서 확인해보자.FieldError
참고 (의도적으로 검증 오류가 발생하도록 요청하고 BindingResult
를 로그로 출력했을 때)codes
가 생성됨을 확인할 수 있다.codes [required.item.itemName, required.itemName, required.java.lang.String, required]
codes [range.item.price, range.price, range.java.lang.Integer, range]
codes [max.item.quantity, max.quantity, max.java.lang.Integer, max]
ObjectError
참고 (의도적으로 검증 오류가 발생하도록 요청하고 bindingResult를 로그로 출력했을 때)codes
가 생성됨을 확인할 수 있다. (codes [totalPriceMin.item, totalPriceMin]
)FieldError
rejectValue("itemName", "required")
required.item.itemName
required.itemName
required.java.lang.String
required
ObjectError
totalPriceMin.item
totalPriceMin
오류 메시지 출력
th:errors
가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다. (디폴트 메시지도 없는 경우 오류가 발생한다.)오류 코드 관리 전략
핵심은 구체적인 것에서! 덜 구체적인 것으로!
MessageCodesResolver
는 required.item.itemName
처럼 구체적인 것을 먼저 만들어주고, required
처럼 덜 구체적인 것을 가장 나중에 만든다. 이렇게 하면 앞서 말한 것처럼 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.🤔 왜 이렇게 복잡하게 사용하는가?
requried
같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
👉 이제 우리 애플리케이션에 이런 오류 코드 전략을 도입해보자.
errors.properties
를 다음과 같이 수정하자.ObjectError
는 두 가지 레벨로 만들었다.FieldError
는 네 가지 레벨로 만들었다. (Lv4는 가장 범용적으로 메시지를 만들어두고, Lv3은 Lv4에서 타입에 대한 부분만 추가되었다. Lv2는 생략하였고, Lv1에는 가장 디테일하게 만들었다. ( 결국, MessageCodesResolver
에 의해서 Lv1 -> Lv2 -> Lv3 -> Lv4 순서로 매칭되도록 해두었다. )errors.properties
를 보면, 크게 객체 오류와 필드 오류를 나누었다. 그리고 각각 범용성에 따라 레벨을 나누어두었다.
itemName
의 경우 required
검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
required.item.itemName
required.itemName
required.java.lang.String
required
그리고 이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource
에서 메시지를 찾는다.
구체적인 것에서 덜 구체적인 순서대로 찾는다. 메시지에 1번(①)이 없으면 2번(②)을 찾고, 2번(②)이 없으면 3번(③)을 찾는다.
이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!
✔️ 실행시 다음과 같이 단계적으로 해보자.
(참고) 테스트 후 주석을 해제하자.
결론적으로 중요한 것은, 애플리케이션 코드를 변경할 필요없이 properties 파일만 수정하면 메시지를 바꿀수 있다는 점이다.
✔️ 참고
Empty
, 공백
같은 단순한 기능만 제공한다. (참고로 내부를 들어가보면 다음과 같이 구현되어 있다.)✔️ 정리
rejectValue()
호출MessageCodesResolver
를 사용해서 검증 오류 코드로 메시지 코드들을 생성new FieldError()
를 생성하면서 메시지 코드들을 보관th:erros
에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
지금까지는 우리가 직접 오류메시지를 만들었다. 그런데 만약 타입 오류등으로 바인딩에 실패한 경우, 스프링이 직접 오류메시지를 만들어줬다. 이 경우는 오류메시지를 어떤식으로 처리하면 좋을지 다음 내용을 통해 학습해보자.
스프링이 직접 만든 오류 메시지 처리
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
rejectValue()
를 직접 호출
👉 지금까지 학습한 메시지 코드 전략의 강점을 지금부터 확인해보자.
BindingResult
에 검증 오류 정보가 들어간다.BindingResult
에 FieldError
가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
위 예시를 보면, 다음과 같이 4가지 메시지 코드가 입력되어 있다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
그렇다. 스프링은 타입 오류가 발생하면 typeMismatch
라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver
를 통하면서 4가지 메시지 코드가 생성된 것이다.
👉 오류 메시지를 확인해보자.
Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "qqq"
errors.properties
에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.errors.properties
에 다음 내용을 추가하자.errors.properties
에 추가한 메시지가 출력됨을 확인할 수 있다.
✔️ 정리
현재 컨트롤러 코드를 보면, 검증 로직이 내부에서 차지하는 비중이 크다.
다음 내용에서는 이를 분리할 수 있는 방법에 대해서 알아보자.
강의를 듣고 정리한 글입니다. 코드와 그림 등의 출처는 김영한 강사님께 있습니다.