MVC2 4th Step

최보현·2022년 8월 12일
0

MVC

목록 보기
11/18
post-thumbnail

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - sec04
출처 : 스프링 MVC 2편

검증

우리가 숫자만 들어갈 수 있는 곳에 문자를 치면 이걸 어떻게 바꾸라고 알려주는 오류 메시지와 다음 과정으로 넘어가지 않고 입력 데이터를 유지하는 것이 중요함

컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것

클라이언트 검증(자바스크립트) VS 서버 검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족
    둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
    API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

여태까지 내가 만든 사이트의 처리 방식은 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect했다.
그리고 오류가 발생하면 바로 400오류를 띄어주는 화면으로 전환됨

하지만, 우리가 검증을 도입하고 우리가 어떤 오류를 만들었는지를 보여주기 위해서는 이러한 순서체계가 잡혀야 한다.

검증 V1

검증 오류 보관
Map<String, String> errors = new HashMap<>();
만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둠
내용 유무의 검증 로직

if (!StringUtils.hasText(item.getItemName())) {
	errors.put("itemName", "상품 이름은 필수입니다.");
}

검증시 오류가 발생하면 errors 에 담아둔다. 이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용하고 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력함
특정 필드 범위를 넘는지 검증 로직(글로벌 오류 메시지)

if (item.getPrice() != null && item.getQuantity() != null) {
  int resultPrice = item.getPrice() * item.getQuantity();
  if (resultPrice < 10000) {
    errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
  }
}

이때는 특정 필드 이름을 넣을 수 없기 때문에 globalError이라는 key를 사용함
검증 실패 시

if (!errors.isEmpty()) {
	model.addAttribute("errors", errors);
	return "validation/v1/addForm";
}

만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 model 에 errors 를 담고, 입력 폼이 있는 뷰 템플릿으로 보냄

오류 처리

오류 발생시 상자도 빨갛게!

파일의 상단부에 css를 추가해주면 됨

.field-error {
  border-color: #dc3545;
  color: #dc3545;
}

필드 오류 처리

<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control">

classappend 를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조, 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않음
필드 오류 처리 - 메시지

<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="$ {errors['itemName']}">
상품명 오류
</div>

글로벌 오류 메시지

<div th:if="${errors?.containsKey('globalError')}">
	<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>

글로벌 오류의 경우 발생할 때만 보여주고 싶기 때문에 th:if를 사용해서 조건을 만족할 때만 나올 수 있도록 만들어 줌

문제점

  1. 검증 오류가 발생해도 고객이 입력한 데이터가 유지됨 => 모델에 담겨있는 애들을 html에서 고대로 받아서 쓰니깐
  2. 뷰 템플릿에서 중복 처리가 많음
  3. 타입 오류 처리가 안됨 => 타입이 Integer인 곳에 문자 타입으로 설정면 오류가 발생하는데 이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워줌
    3-1. 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어려움
  4. 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 함

검증 V2

BindingResult

상단에 있던 Map<> Errors의 역할을 해줌
스프링이 제공하는 검증 오류를 보관하는 객체로 검증 오류가 발생하면 여기에 보관됨
@ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출됨!
=> 타입 오류가 발생해도 400오류가 아니라 오류 정보를 담아 컨트롤러를 정상 호출함
얘는 파라미터로 주는데 @ModelAttribute 다음에 와야함 꼭!
=> 왜냐 이 아이가 item 객체의 바인딩 결과를 가져오기 때문에 연관성이 높음
그리고 이 아이는 Model에 자동으로 포함되어 뷰에 넘어감
bindingresult가 있을때는 우선 controller가 호출됨 그리고 뭐가 문제가 있는지 담기게 됨
bindingResult 대신 Errors(binding이 상속하는 인터페이스)를 써도 되지만 기능이 좀 없음 ex) .addError 같은 것들

오류

필드 오류

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}

fieldError는 객체명(@ModelAttribute의 이름), (오류가 발생한) 필드명, 기본 메시지 순서대로 작성해서 생성함
fieldError는 ObjectError의 자식임
글로벌 오류

bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

글로벌 오류의 경우 특정 필드를 넘어 오류가 발생하여, ObjectError 객체를 생한다. 얘는 객체명, 기본 메시지 순서대로 작성해서 생성함

타임리프의 검증 오류 통합 기능

  1. #fields : #fields 로 BindingResult 가 제공하는 검증 오류에 접근 가능
  2. th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력 => th:if 의 편의 버전이다.
  3. th:errorclass : th:field 에서 지정한 필드에 오류가 있으면 class 정보를 추가 => th:field에 th:field로 된 이름의 오류가 발생하면 css에다가 필드 에러 클래스를 추가해줌

필드에러 - 오브젝트에러

필드에러 생성자

public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object
rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable
Object[] arguments, @Nullable String defaultMessage)

첫 번째는 위에서 설명했으니 생략하겠윰
두 번째 파라미터를 순서대로 말하자면, 객체명, 필드명, 사용자가 입력한 오류 값, 타입 오류 같은 바인딩 실패인지 검증 실패인지 구분, 메시지 코드, 메시지 파라미터, 기본 오류 메시지

오류 발생시 사용자 입력 값 유지

new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공함
타임리프에서는 오류가 발생하면 FieldError에서 보관한 값을 사용해서 출력함

스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어두고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출

오류 코드와 메시지 처리1

필드에러도, 오브젝트에러도 생성자에게 코드와 매개변수를 제공한다 => 오류 발생시 오류코드로 메시지를 찾기 위해 사용됨
오류 메시지도 errors.properties같이 별도의 파일을 만들어 메시지 관리가 가능
=> 스프링 부트가 에러 파일도 인식할 수 있도록 application.properties에다가 basename에 errors도 추가해줘야함
이렇게 되면 우리가 필드에러를 생성할 때 null값을 줬던 코드와 매개변수 부분에 값을 넣어주면 된다

new FieldError("item", "price", item.getPrice(), false, new String[]
{"range.item.price"}, new Object[]{1000, 1000000}

코드 : required.item.itemName 를 사용해서 메시지 코드를 지정, 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용됨
매개값 : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달

오류 코드와 메시지 처리2

우리가 지금 작성하는 오류 코드들을 좀 더 자동화하고 간편하게 만들어보자
우선, BindingResult는 검증해야할 객체인 target바로 뒤에 오기 때문에 본인이 검증해야할 아이를 알고 있음

rejectValue(), reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면 FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수있음
rejectvalue를 사용하면 처음 매개체는 필드명, 그리고 두번째 매개체는 코드의 맨 처음 글자만 써주면 됨!
=> 이게 가능한 이유는 코드 이름이 첫 글자 + .객체명 + .필드명으로 되어 있기 때문에 가능하다 => 즉 알아서 조합해줌

오류 코드와 메시지 처리3

지금은 굉장히 자세하게 오류 메시지를 만들었지만 required : 필수 값 입니다.,
range : 범위 오류 입니다. 같이 폭넓게 만들 수 있음
단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다.
그래서 세밀한 메시지가 필요한 곳은 세밀하게 아닌 곳은 범용적으로 약간 유도리 있게 돌아갈 수 있도록 해줘야함
우선 우리는 오류 메시지를 모아 두는 곳에 넓은 범위와 좁은 범위를 둘다 써두게 되면 좁고 구체적인 것이 우선순위가 더 높아서 먼저 사용됨

오류 코드와 메시지 처리4

MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성
  • 인터페이스이며 DefaultMessageCodesResolver라는 기본 구현체를 주로 사용
  • ObjectError , FieldError와 함께 사용됨
  • 안에 들어가보면 배열로 값을 반환함 => 여러개의 값을 가져올 수 있음

메시지 생성 규칙

객체 오류

객체 오류의 경우 다음 순서로 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"

동작 방식

  1. rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용하고 메시지 코드들을 생성함
  2. 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있고 MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관함

오류 메시지 출력

타임리프 화면을 렌더링 할 때 th:errors 가 실행됨
만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾고 없으면 디폴트 메시지를 출력함

오류 코드와 메시지 처리5

구체적인 것에서 덜 구체적인 것으로!

ValidationUtils

사용 전

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

사용 후

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName",
"required");

공백 같은 단순한 기능만 제공하기는 하지만 위에보다 훨씬 간편함

오류 코드와 메시지 처리6

개발자가 직접 설정한 오류 메시지를 출력하려면 rejectValue()를 호출하고
스프링이 직접 검증 오류에 추가한 경우(타입 오류)일 경우에는 typeMismatch라는 오류 코드를 사용함

Validator 분리

컨트롤러에서 비즈니스 로직보다 검증 로직이 차지하는 부분이 매우 크기 때문에 이를 따로 분리해내서 컨트롤러의 역할을 덜어줌
Validator 인터페이스

public interface Validator {
  boolean supports(Class<?> clazz);
  //해당 검증기를 지원하는 여부 확인
  void validate(Object target, Errors errors);
  //검증 대상 객체와 BindingResult
}

Validator 분리2

WebDataBinder

item객체에 파라미터를 바인딩해주고 검증기 가지고 검증도해주고 스프링 mvc가 내부에서 사용하는 기능을 꺼내서 검증기를 넣어주는 역할을 해줌
컨트롤러가 호출될 때마다 만들어져서 검증기를 만들어줌
컨트롤러에 추가해줘야함

@InitBinder //해당 컨트롤러에만 영향을 줌
public void init(WebDataBinder dataBinder) {
  log.info("init binder {}", dataBinder);
  dataBinder.addValidators(itemValidator);
}

@ModelAttribute 앞에 @Validated를 넣어주면 자동으로 객체에 검증기가 실행됨
=> 여러 검증기가 있어도 validator의 supports()가 알아서 걸러줌

profile
Novice Developer's Blog

0개의 댓글