[ 김영한 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 #4 ] 검증1 - Validation (3)

김수호·2023년 9월 15일
0
post-thumbnail

지난 포스팅에 이어, 이번 포스팅에서는 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) 정리


9) 오류 코드와 메시지 처리1

목표: 오류 메시지를 좀 더 체계적으로 다루어보자.

FieldError 생성자

  • FieldError 는 두 가지 생성자를 제공한다.
    • 파라미터 목록
      • objectName : 오류가 발생한 객체 이름.
      • field : 오류 필드. (필드명)
      • rejectedValue : 사용자가 입력한 값 (거절된 값)
      • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값.
      • codes : 메시지 코드
      • arguments : 메시지에서 사용하는 인자
      • defaultMessage : 기본 오류 메시지

지금까지는 FieldErrorObjectError 를 사용하면서, 오류 메시지를 defaultMessage 를 통해서 직접 작성해서 적용하였다. 그런데 이런 오류 메시지도 한 군데서 일관성있게 관리하는 것이 더 좋다. (예를 들어, "상품 이름은 필수입니다."와 같은 오류 메시지의 경우, 한 군데가 아닌 여러 군데에서 사용될 수 있다. 그런데 이걸 그때마다 다 개별로 작성하면, 어디서는 "상품 이름을 입력해주세요.",... 와 같이 일관되지 않게 관리될 확률이 높다.) 그래서 FieldErrorObjectError 는 생성자의 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 에 등록한 메시지를 사용하도록 코드를 변경해보자.

  • ValidationItemControllerV2 - addItemV3: 기존 addItemV2 메서드를 복사해서 addItemV3를 만들고 아래와 같이 수정하자.
    (참고. 기존 addItemV2 메서드의 @PostMapping은 주석처리 하자.)
    • codes 는 String 배열로 넣어야 한다. 내부에는 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다. (배열로 전달된 여러 메시지 코드에서 매칭되는 메시지를 찾지 못하면, defaultMessage를 출력하고, defaultMessage 조차 없다면(null) 오류가 난다.)
    • arguments 는 Object 배열로 넣어야 한다. Object[]{1000, 1000000} 와 같이 사용하여, 오류 메시지에서 {0} , {1} 을 치환하기 위한 값을 전달한다.
    • (참고) defaultMessage 는 예제에서 앞으로 사용하지 않을 것이므로 null 로 적용하였다.
  • 실행해보자.
    • 실행해보면 메시지, 국제화에서 학습한 MessageSource 를 찾아서 메시지를 조회하는 것을 확인할 수 있다.

그런데 바꾸고 보니 약간 중복되어 보이는 것도 많고, 복잡한 느낌도 든다.
다음 내용부터 한 단계씩 개선해보자.


10) 오류 코드와 메시지 처리2

목표

  • FieldError , ObjectError 는 다루기 너무 번거롭다. (넣어줘야 하는 파라미터도 너무 많다.)
  • 오류 코드도 좀 더 자동화 할 수 있지 않을까? 예) item.itemName 처럼?

 

🤔 코드를 좀 더 줄이는 방법에 대해서 고민해보자.

컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다.
따라서, BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고있다.
(이미 target을 알고있다면, 컨트롤러 로직에서 FieldError, ObjectError 를 생성할 때, 파라미터로 넣어줬던 objectName 등은 생략할 수도 있지 않을까 ?)

  • 컨트롤러에 다음 코드를 추가해보자.
  • 실행해보자.
  • 로그를 확인해보자.
    • 결론적으로 bindingResult 에서 target에 대한 정보를 가지고있는 것을 알수있다. 그렇다면 코드를 줄일 수 있어보인다.

 

✔️ rejectValue(), reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.

rejectValue() , reject() 를 사용해서 기존 코드를 단순화해보자.

  • ValidationItemControllerV2 - addItemV4: 기존 addItemV3 메서드를 복사해서 addItemV4를 만들고 아래와 같이 수정하자.
    (참고. 기존 addItemV3 메서드의 @PostMapping은 주석처리 하자.)
    • 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()를 사용하면서, 전보다는 코드가 많이 줄어들었음을 확인할 수 있다.
그런데 아직 풀리지 않은, 오류 코드를 어떻게 조합해서 만들어 내는지 알아보자.


11) 오류 코드와 메시지 처리3

🤔 오류 코드를 어떤식으로 설계하면 좋을까?

오류 코드를 만들때 다음과 같이 자세히 만들 수도 있고,

  • required.item.itemName : 상품 이름은 필수 입니다.
  • range.item.price : 상품의 가격 범위 오류 입니다.
  • max.item.quantity : 수량은 최대 {0} 까지 허용합니다.

또는 다음과 같이 단순하게 만들 수도 있다.

  • required : 필수 값 입니다.
  • range : 범위 오류 입니다.
  • max : 최대 {0}까지 허용 가능합니다.

단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.

예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자. 다음과 같이 required 라는 메시지만 있으면 이 메시지를 선택해서 사용하는 것이다.

  • 참고)

그런데 오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.

  • 참고)

물론 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발을 해야겠지만, 범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리할 수 있을 것이다.

☝️ 스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원한다.


12) 오류 코드와 메시지 처리4

이번 내용에서는 MessageCodesResolver 에 대해서 알아보자.
( MessageCodesResolver 의 개념을 이해하게 되면, 이전에 errorCode"required" 만 넣었음에도 불구하고, "required.item.itemName" 또한 오류 코드로 만들어진 배경을 이해할 수 있다. )

👉 우선 테스트 코드로 MessageCodesResolver 를 알아보자.

  • MessageCodesResolverTest 생성 : test > java > hello > itemservice > validation 패키지를 생성하고, 내부에 MessageCodesResolverTest 클래스를 생성하자.
  • 실행해보자.
    • 정상적으로 실행됨을 확인할 수 있다.

 

✔️ MessageCodesResolver

  • 검증 오류 코드(errorCode)로 메시지 코드들을 생성한다.
  • MessageCodesResolver 는 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
  • 주로 다음과 함께 사용 ObjectError , FieldError

✔️ DefaultMessageCodesResolver의 기본 메시지 생성 규칙

  • 객체 오류
    • 객체 오류의 경우 다음 순서로 2가지 생성
      • 1) code + "." + object name
      • 2) code
      • (디테일한 것에서부터 범용적인 순서로)
    • 예) 오류 코드 = required, object name = item
      • 1) required.item
      • 2) required
  • 필드 오류
    • 필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
      • 1) code + "." + object name + "." + field
      • 2) code + "." + field
      • 3) code + "." + field type
      • 4) code
      • (디테일한 것에서부터 범용적인 순서로)
    • 예) 오류 코드 = typeMismatch, object name = user, field = age, field type = int
      • 1) typeMismatch.user.age
      • 2) typeMismatch.age
      • 3) typeMismatch.int
      • 4) typeMismatch

✔️ 동작 방식

  • rejectValue() , reject() 는 내부에서 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")
  • 다음 4가지 오류 코드를 자동으로 생성
    • required.item.itemName
    • required.itemName
    • required.java.lang.String
    • required

ObjectError

  • 예시) reject("totalPriceMin")
  • 다음 2가지 오류 코드를 자동으로 생성
    • totalPriceMin.item
    • totalPriceMin

오류 메시지 출력

  • 타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다. (디폴트 메시지도 없는 경우 오류가 발생한다.)

13) 오류 코드와 메시지 처리5

오류 코드 관리 전략

핵심은 구체적인 것에서! 덜 구체적인 것으로!

  • MessageCodesResolverrequired.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번(③)을 찾는다.

이렇게 되면 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!

 

✔️ 실행시 다음과 같이 단계적으로 해보자.

  • 최초에는 그냥 실행해보자.
    • Level1이 전부 매칭된 것을 확인할 수 있다.
  • Level1 전부 주석해보자.
    • Level3이 매칭되는 것을 확인할 수 있다.
  • Level2,3 전부 주석해보자.
    • Level4가 매칭되는 것을 확인할 수 있다. (Level1 ~ Level3에 매칭되는 것은 없으니)
  • Level4 전부 주석해보자.
    • Level1 ~ Level4에서 전부 못찾으면 코드에 작성한 디폴트 메시지를 사용한다. (현재는 디폴트 메시지가 null이기에 오류가 발생한다.)
  • (참고) Object 오류도 Level1, Level2로 재활용 가능하다.
  • (참고) 테스트 후 주석을 해제하자.

  • 결론적으로 중요한 것은, 애플리케이션 코드를 변경할 필요없이 properties 파일만 수정하면 메시지를 바꿀수 있다는 점이다.

 

✔️ 참고

  • ValidationUtils
    • ValidationUtils 사용 전
    • ValidationUtils 사용 후
      • 다음과 같이 한줄로 표현 가능하다.
      • 제공하는 기능은 Empty , 공백 같은 단순한 기능만 제공한다. (참고로 내부를 들어가보면 다음과 같이 구현되어 있다.)

✔️ 정리

  • 1) rejectValue() 호출
  • 2) MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
  • 3) new FieldError() 를 생성하면서 메시지 코드들을 보관
  • 4) th:erros 에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출

 

지금까지는 우리가 직접 오류메시지를 만들었다. 그런데 만약 타입 오류등으로 바인딩에 실패한 경우, 스프링이 직접 오류메시지를 만들어줬다. 이 경우는 오류메시지를 어떤식으로 처리하면 좋을지 다음 내용을 통해 학습해보자.


14) 오류 코드와 메시지 처리6

스프링이 직접 만든 오류 메시지 처리

검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.

  • 개발자가 직접 설정한 오류 코드 -> rejectValue() 를 직접 호출
  • 스프링이 직접 검증 오류에 추가한 경우 (주로 타입 정보가 맞지 않은 경우)

 

👉 지금까지 학습한 메시지 코드 전략의 강점을 지금부터 확인해보자.

  • 가격에 "qqq" 라고 입력 후 저장해보자.
    • 가격에 "qqq"를 입력 후 저장하면, Item의 price는 Integer이기 때문에 "qqq"를 받을 수 없다. 따라서 BindingResult 에 검증 오류 정보가 들어간다.
    • 로그를 확인해보면 BindingResultFieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.
      • codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
        • 스프링이 rejectValue()를 호출하면서 오류 코드로 typeMismatch 라고 넣어준 것이다.

위 예시를 보면, 다음과 같이 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 에 다음 내용을 추가하자.
    • 강의에서는 두가지만 추가하였다. (Level3, Level4)
  • 다시 실행 후 이전과 똑같이 재현해보자.
    • 스프링이 생성한 기본 메시지가 아닌, errors.properties에 추가한 메시지가 출력됨을 확인할 수 있다.
    • 결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정할 수 있다. (스프링이 제공하는 오류 코드에 대해서도 처리가 가능하다.)
    • (참고) 위 실행된 화면을 보면, 메시지가 2개가 출력된다. 이는 타입 오류로 인한 오류와, 컨트롤러 내부 비즈니스 검증 오류에 대한 메시지이다. 필요하다면 추가 조건등을 넣어서, 둘 중 하나만 나오게 할수도 있다. 이는 개발자의 선택이다.
      (아래는 컨트롤러 로직 수행 전, 이미 BindingResult에 오류가 있다면 리턴하는 코드이니 참고하자.)

 

✔️ 정리

  • 메시지 코드 생성 전략은 그냥 만들어진 것이 아니다. 조금 뒤에서 Bean Validation 을 학습하면 그 진가를 더 확인할 수 있다.

 

현재 컨트롤러 코드를 보면, 검증 로직이 내부에서 차지하는 비중이 크다.
다음 내용에서는 이를 분리할 수 있는 방법에 대해서 알아보자.


강의를 듣고 정리한 글입니다. 코드와 그림 등의 출처는 김영한 강사님께 있습니다.

profile
현실에서 한 발자국

0개의 댓글