폼 입력으로 들어오는 데이터를 검증하고 만약 오류가 발생했을 때 오류 페이지를 보여주는 것이 아닌 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 고객에게 알려주어야한다.
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지를 검증하는 것이다.
클라이언트 검증은 조작이 가능하다. ==> 보안에 취약
그렇다고 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용해야함.
API 방식을 사용해서 검증 오류를 API 응답 결과에 남겨주는 것도 좋은 방법
1. 검증 직접 처리
서버에서 검증 실패가 발생하면 Form에 입력된 데이터와 검증 오류에 대한 결과를 Model에 담아 다시 상품 등록 Form으로 보내준다.
이런 오류 페이지가 띄워지는 것이 아닌 입력한 데이터는 그대로두고 오류를 사용자에게 알려주는 방식으로 구현
값이 넘어올 때 직접 조건문으로 값을 검사하여 설정한 조건에 부적합하다면 errors라는 Map에 에러종류와 에러 내용을 담아준다. Map이 비어있지 않다면 에러가 존재하는 것이기 때문에 model에 에러의 내용을 담고 다시 addForm으로 보내준다.
또한 해당 컨트롤러에서 @ModelAttribute를 통해 Form 데이터를 Item 객체로 받고 model에 담아주기 때문에 위와 같이 에러만 model에 담아서 넘겨주면 사용자가 입력한 값은 그대로 남아있게 되는 것이다.
이렇게 담긴 오류 내용을 타임리프를 사용하여 처리해주면 검증을 직접 구현할 수 있다.
errors?.과 같이 .앞에 ?을 붙이면 errors가 null일 때 NullPointerException 대신, null을 반환하는 문법이다.
처음 상품 등록을 위해 요청이 들어왔을 때는 errors라는 Map이 생성되어 있지 않기 때문에 null이다. 이 때 발생하는 예외를 막기 위해 사용한 문법 ==> SpringEL이 제공하는 문법이다.
classappend와 No-Operation(_)을 사용해서 구현할 수도 있음.
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control">
하지만 현재 이 방법은 뷰 템플릿의 중복 코드가 많다. 또한 현재 타입 오류에 대한 처리가 완료되지 않았다.
Item의 price는 Integer타입인데 문자를 입력하면 400(Bad Request)가 발생한다. 이러한 오류는 스프링 MVC에서 컨트롤러에 진입하기 이전에 예외가 발생하는 것이기 때문에 컨트롤러가 호출되지가 않으므로 이 방식으로는 처리할 수가 없다.
또한 호출된다고 가정해도 타입이 다르므로 바인딩이 불가능하다.
이제부터는 Spring이 제공하는 검증 기법을 알아보도록 하자.
2. Binding Result
Spring이 제공하는 BindingResult를 사용하면 오류를 여기에 담을 수 있다. 이 때 위치는 반드시 위 코드와 같이
@ModelAttribute 다음에 위치해야한다.
오류를 검증하는 로직은 이전과 같으나 오류가 발생했을 때 bindingResult.addError를 통해 에러를 담아주면 된다.
이 때 각 필드에 대한 오류는 FieldError를 새로 생성한다.
FieldError(@ModelAttribute의 이름(예시에서는 item), 오류가 발생한 필드, 오류 기본 메시지)
필드 오류가 아닌 복합 오류(Global Error)일 경우에는 ObjectError를 생성한다. 이 때 파라미터의 값은 필드만 빠지고
나머지는 FieldError와 동일하다.
BindingResult는 model에 addAttribute를 해주지않아도 자동으로 View로 넘어간다!
이제 BindingResult를 넘겨받은 View에서 이를 어떻게 처리하는지 알아보자.
Thymeleaf는 BindingResult를 활용해서 편리하게 검증 오류를 표현할 수 있다.
#fields : BindingResult가 제공하는 검증 오류에 접근 가능
.globalErrors는 List로 반환하기 때문에 each를 사용하여 하나씩 출력
th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다.(th:if + th:text)
th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.(th:if + th:appendclass)
더 많은 내용은 공식문서 참조!
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다.
BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 정상 호출된다.
==> 원래는 400(Bad Request)오류 페이지
BindingResult에 검증 오류를 적용하는 방법
- @ModelAttribute의 객체에 타입 오류(가격은 Integer인데 문자를 넣는 경우) 등으로 바인딩이 실패하면 스프링이 자동으로 FieldError를 생성하여 BindingResult에 넣어줌.(컨트롤러 정상 호출)
- 개발자가 직접 넣는 방법
- Validator 사용
Errors 인터페이스에 추가적인 기능을 더한 것이 BindingResult!
실제 넘어오는 구현체는 BeanPropertyBindingResult이다.
하지만 BindingResult를 사용하여 Thymeleaf와 통합하였더니 오류가 발생하면 이미 입력된 값들이 모두 날라가버린다.
이를 어떻게 해결할까??
FieldError, ObjectError
사용자가 입력한 값을 검증하여 오류가 발생했을 때 입력한 값이 사라지는 것이 아니라 HTML Form에 남아있어야한다.
FieldError의 생성자의 파라미터 value를 보면 @Nullable Object rejectedValue가 있다. 이것이 바로 사용자가 입력한 값 즉, 거절된 값을 넣어주는 파라미터이다.
각 파라미터들을 살펴보면 아래와 같다.
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
현재는 값은 잘 넘어왔지만(바인딩 성공) 검증 실패이고 사용자가 입력한 값을 넘겨주기 위한 목적이기 때문에 아래와 같이 작성한다.
bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false, null, null, "상품 이름은 필수에요!!"));
ObjectError는 값이 넘어오는 것이 아니기 때문에 바인딩이 실패하는 것이 아니므로 rejectedValue와 bindingFailure을 파라미터로 받지 않는다.
bindingResult.addError(new ObjectError("item",null, null, "가격 * 수량의 합은 10,000원 이상이어야 해요! 현재 값: " + resultPrice));
이제 사용자가 입력한 값에 오류가 발생하여도 Form의 데이터가 날라가지않고 남아있음을 확인할 수 있다.
또한 가격과 같은 Integer 타입에 String을 입력하여도 즉, 타입오류가 발생하여도 마찬가지로 rejectedValue에 저장할 수 있다.(생성자 파라미터를 살펴보면 rejectedValue가 Object 타입이므로 가능)
또한 이것을 Thymeleaf가 th:field를 통해 처리해준다. th:field는 오류가 발생하지 않았을 경우에는 Model 객체의 값을 사용하지만 오류가 발생했을 때는 컨트롤러에서 넘어온 FieldError에서 보관한 값을 사용해서 값을 출력해준다.
따라서 사용자에게 보여지는 화면에 위와 같이 입력한 값이 남아있을 수 있는 것이다.
사용자가 잘못된 값을 입력 -> 스프링이 바인딩 오류, 검증 오류 처리 -> View로 오류를 넘겨줌
-> View로 넘어온 오류를 Thymeleaf가 오류가 존재하는지를 판단하고 랜더링
오류 코드와 메시지 처리
현재 defaultMessage를 통해 각 오류 메시지들이 하드코딩되어 있다. 오류 메시지들 또한 한 곳에서 일관성있게 관리할 수는 없을까?(당연히 있음)
바로 FieldError, ObjectError 생성자들에게 넘기는 codes, arguments들이 그 역할을 담당한다.
errors 메시지 파일은 메시지 기능에 사용했던 messages.properties에 함께 작성해도 되지만 오류 메시지를 별도로 분리하기 위해 errors.properties라는 별도의 파일로 관리하겠다.
application.properties에 스프링 부트가 메시지 파일을 인식할 수 있게 errors를 추가해준다.메시지 기능에서 봤듯이 아무것도 적지 않으면 기본적으로 messages만 사용함.현재는 messages.properties와 errors.properties 두 개를 메시지 파일로 사용
spring.messages.basename=messages,errors
당연한 이야기이지만 errors_en.properties 파일을 생성하여 국제화 처리도 가능함!
에러 메시지를 추가해준다.
이제 에러 메시지를 사용해보자.
bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,new String[]{"range.item.price"},new Object[]{1000, 1000000}, null));
이 때 codes는 String배열, arguments는 Object 배열로 값을 넘겨주어야한다.
codes는 배열로 여러 값을 전달할 수 있는데 순서대로 매칭하여 처음 매칭되는 메시지가 사용되고,arguments는 {0},{1}과 같이 사용된 순서에 맞춰 배열로 넘어온 값들이 순서대로 매칭된다.
메시지 기능 사용시 메시지가 깨지는 문제 해결
setting-Editor-File Encodings에 들어가 Properties Files의 인코딩을 UTF-8로 설정해주어야함.
만약 찾는 해당 메시지가 없으면 오류 페이지가 발생함. 이를 막기위해서는 defaultMessage를 사용해서 기본 오류메시지를 넘기면 됨.
사실 컨트롤러에서 BindingResult는 검증해야할 객체(target)바로 다음에 위치하기 때문에 target을 이미 알고 있다.
그럼에도 우리는 오류를 담아줄 때 생성자를 통해 객체명을 알려주고 있는데 이것을 생략할 수 있지 않을까?
BindingResult에는 .getObjectName과 .getTarget메서드가 존재하는데 로그를 통해 직접 찍어보자.
새로운 상품을 등록해보면 전달한 Form 데이터와 객체명이 이미 bindingResult에 담겨있는 것을 확인할 수 있다.
따라서 하드코딩되어 있는 생성자를 변경할 수 있다.
rejectValue() / reject()
BindingResult가 제공하는 rejectValue()와 reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고 더 깔끔하게 검증 오류를 다룰 수 있다.
이전처럼 생성자를 사용하지 않고 위와 같이 코드를 훨씬 간결하게 줄일 수 있다.
먼저, rejectValue 메서드는 아래와 같다.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage)
- field : 오류 필드명
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 밑에서 설명)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
앞서 BindingResult는 자신이 검증할 대상인 target을 알고 있다고 언급했다.따라서 생성자를 직접 다룰 때와 같이 데이터에 대한 정보를 넘기지 않아도 된다.생성자를 직접 다룰 때에는 range.item.price와 같이 모두 입력했는데 어떻게 rejectValue는 range만 넘겨도 오류메시지를 찾아 출력하는걸까?BindingResult가 ObjectName을 알고 있으므로 그것을 활용하여 "errorCode(파라미터).ObjectName.field(파라미터)"와 같은 규칙으로 메시지를 찾는 것이 아닐까?이 부분을 정확히 알기 위해서는 MessageCodesResolver를 이해해야한다.
MessageCodesResolver
오류 코드를 설계하는 상황을 가정해보자.
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.
required : 필수 값 입니다.
range : 범위 오류 입니다.
위와 같이 자세히 설계할 수도 단순하게 만들 수도 있다. 만약 모든 필드가 필수값이라면 각 필드마다 오류 코드를 만드는 것보다 "required" 하나를 가지고 일반화시켜 사용하는 것이 더 좋은 설계 방식일 것이다.
단순하게 만들면 범용성이 좋아 방금 언급한 예시와 같이 여러 곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하는 것은 어렵다. 반대로 너무 세밀하면 범용성이 떨어진다.
범용성이 있게 사용하다가 세밀한 부분이 필요하면 추가시키는 방식이 가장 좋지 않을까?
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
이와 같이 세밀한 오류코드를 더 높은 우선 순위로 사용하는 것이다. 이렇게 설계를 한다면 범용성 있게 사용하다가 꼭 필요한 곳에서만 세밀한 오류코드를 추가만 하면 된다.
스프링은 이러한 기능을 MessageCodesResolver라는 것으로 지원해준다!
간단한 테스트로 MessageCodesResolver가 어떻게 동작하고 사용되는지 알아보자.
현재 오류코드는 해당 필드가 필수 값임을 알려주는 범용적으로 사용될 수 있는 것과 itemName을 위한 세부적인 오류코드가 존재한다.
MessageCodesResolver는 인터페이스 이므로 기본구현체인 DefaultMessageCodesResolver를 사용하여 테스트를 진행한다.
MessageCodesResolver의 resolveMessageCodes는 errorCode와 objectName을 파라미터로 넘기면 해당 오류 코드들이 담긴 배열을 반환해준다.
즉, 앞서 살펴본 rejectValue(), reject()메서드는 내부적으로 MessageCodesResolver를 사용하여 반환된 배열의 순서대로 FieldError, ObjectError의 생성자를 만들어내는 것이다.(세밀한 오류 코드가 우선순위가 높음 ==> 배열의 앞에 위치)
//example
new FieldError("item", new String[]{"required.item", "required"}
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"
오류 코드 관리 전략
구체적인 것에서 덜 구체적인 것으로
모든 오류 코드에 대해 메시지를 하나하나 정의하면 관리하기가 너무 어려워진다.
따라서 중요하지 않은 메시지는 범용성있게 사용하고, 중요한 메시지만 구체적으로 사용하자!
즉, 핵심은 애플리케이션의 코드를 변경하는 것이 아닌 오류코드(errors.properties)만을 수정하여 관리하는 것이다.
검증오류가 발생하여 이 코드가 실행되면 현재 상태에서는 required 오류코드 중 가장 우선순위가 높은
required.item.itemName이 선택되어 화면에 출력될 것이다. 만약 required.item.itemName이 존재하지 않는다면 앞서 얘기한 메시지 생성 규칙에 따라 다음 오류 코드를 찾는다.(MessageSource에서 메시지를 찾음)
required.item.itemName이 주석처리 되어있다면 "필수 문자입니다."라는 오류 메시지가 화면에 출력됨.
스프링이 직접 만든 오류 메시지 처리
검증 오류 코드는 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 ==> rejectValue()를 직접 호출
- 스프링이 직접 검증 오류에 추가한 경우(ex:타입 정보가 맞지 않는 경우)
Integer 타입인 가격 필드에 String을 넣어보면,
만든적 없는 typeMismatch라는 오류가 발생하여 codes에 담겨 있는 것을 확인할 수 있는데, 이것이 바로 스프링이 만들어준 오류코드들 이다.(즉, 스프링이 rejectValue()를 호출한 것임)
하지만 errors.properties에는 해당 오류 코드가 없기 때문에 기본적으로 설정되어 있는 defaultMessage가 출력됨.
하지만 사용자에게 위와같은 defaultMessage를 보여주는 것은 바람직하지 않다. 따라서 이것또한 errors.properties에
위와 같이 추가해주면 스프링이 내부적으로 rejectValue()를 호출할 때 개발자가 추가한 오류코드가 사용된다.
더 우선 순위가 높은 typeMismatch.java.lang.Integer가 선택되었다!
애플리케이션의 어떤 코드도 변경하지 않고 오류 메시지를 변경할 수 있다.
Validator
컨트롤러에 있는 검증 로직은 별도의 클래스로 만들어 처리하자.(역할과 분리, 재사용)
Spring이 제공하는 Validator 인터페이스를 구현하여 사용해보자.
Validator 인터페이스에는 두가지 메서드가 존재한다.
- boolean supports(Class<?> clazz)
==> 파라미터로 넘어온 타입이 해당 검증기를 지원하는지 여부를 반환한다.(자식 타입도 가능)
- void validate(Object target, Errors errors)
==> 검증 로직이 들어가는 부분
==> Errors는 BindingResult의 부모이기 때문에 앞서 작성한 검증로직을 동일하게 수행할 수 있다.
@Component 애노테이션을 붙혀 컴포넌트 스캔의 대상으로 지정해 스프링 빈으로 등록하여 사용하겠다.
target이 Object 타입이기 때문에 캐스팅
@RequiredArgsConstructor를 통해 생성자 주입으로 스프링 빈 사용.(컨트롤러)
컨트롤러에서는 검증 로직을 위해 위와 같이 간단하게 사용할 수 있다. @ModelAttribute를 통해 받은 Item 객체와 BindingResult만 넘겨준다.
이렇게하면 다른 곳에서도 재사용할 수 있고 컨트롤러는 컨트롤러의 역할에만 집중할 수 있다.
스프링이 왜 Validator 인터페이스를 제공할까?
그 이유는 체계적으로 검증 기능을 도입하기 위해서이다. 위에서는 검증기를 컨트롤러에서 직접 호출하여 사용했지만 Validator 인터페이스를 사용하면 스프링이 추가적으로 제공하는 도움을 받을 수 있다.
WebDataBinder를 통해서 사용하기
WebDataBinder는 스프링의 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함한다.하지만 이것은 스프링MVC가 내부적으로 사용하는 것이기 때문에 이것을 외부로 꺼내어 검증기를 넣어주어야한다.
WebDataBinder에 검증기를 추가해주자. WebDataBinder는 컨트롤러에 요청이 올 때마다 새로 생성이 되는데 이 때 @InitBinder가 있으면 같이 WebDataBinder가 생성될 때 같이 호출된다.
즉, 컨트롤러에 요청이 올때마다 검증기를 WebDataBinder에 추가해주는 것이다.
컨트롤러의 파라미터에 @Validated 애노테이션만 추가하면 직접 검증기를 호출하지 않아도 된다.
이 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아 실행한다. 이 때 만약 여러 검증기가 추가되어 있다면 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports()가 사용되고 true라면 검증기의 validate() 메서드가 호출되어 실행된다.
@InitBinder는 해당 컨트롤러에만 영향을 준다. 글로벌 설정을 할 수도 있음!
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
이렇게하면 모든 컨트롤러에 적용되도록 글로벌로 설정할 수 있다.
@Validated, @Valid 둘다 사용이 가능하다.
이전에 @Valid를 사용해본적이 있는데, 이것을 쓰려면 build.gradle에 의존관계를 추가해줘야함.
@Validated는 스프링 전용 검증 애노테이션, @Valid는 자바 표준 검증 애노테이션
'Spring > Spring MVC' 카테고리의 다른 글
쿠키, 세션 (0) | 2021.07.17 |
---|---|
Bean Validation (0) | 2021.07.14 |
메시지, 국제화 (0) | 2021.07.06 |
Thymeleaf 파헤치기 (0) | 2021.07.01 |
javax에서 제공하는 @Valid, Spring이 제공하는 BindingResult (0) | 2021.05.22 |