[강의] 김영한님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 정리 - Validation

크리링·2023년 2월 22일
0
post-thumbnail

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




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

참고 : 클라이언트 검증, 서버 검증

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






V1

검증 과정

상품 저장 성공

GET/add -> POST/add -> Redirect/items/{id} -> GET/items/{id}

사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.

상품 저장 검증 실패

GET/add -> POST/add -> 상품 저장 (검증 실패) -> Model(검증 오류 결과 포함) 상품 등록 폼에 전달

고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.



직접 개발

검증 오류 보관

Map<String, String> errors = new HashMap<>();
만약 검증시 오류가 발생하면 어떤 검증에성 오류가 발생했는지 정보를 담아둔다.
이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다. 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.

특정 필드의 범위를 넘어서는 검증 로직

특정 필드를 넘어서는 오류를 처리해야 할 수도 있다. 이때는 필드 이름을 넣을 수 없으므로 globalError라는 key를 사용

검증에 실패하면 다시 입력 폼으로

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



정리

  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.



남은 문제점

  • 뷰 템플릿에서 중복 처리가 많다. 뭔가 비슷
  • 타입 오류 처리가 안된다. Itemprice, quantity 같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능하다. 숫자 타입에 문자가 들어오면 오류가 발생한다. 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
  • Itemprice에 문자를 입력하는 것처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.






V2

BindingResult

주의
BindingResult bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야한다.



필드 오류 - FieldError

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

FieldError 생성자 요약

public FieldError(String objectName, String field, String defaultMessage){}

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.

  • objectName : `@ModelAttribute 이름
  • field: 오류가 발생한 필드 이름
  • defaultMessage: 오류 기본 메시지



글로벌 오류 - ObjectError

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

ObjectError 생성자 요약

public ObjectError(String objectName, String defaultMessage){}

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute의 이름
  • defaultMessage : 오류 기본 메시지



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

타임리프는 스프링의 bindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.

  • #fields : #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.
  • th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다. `th:if 편의 버전이다.
  • th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.






BindingResult2

BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.

예) @ModelAttribute에 바인딩 시 타입 오류가 발생하면?

  • BindingResult가 없으면 -> 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
  • BindingResult가 있으면 -> 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 3가지 방법

  • @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생생해서 BindingResult에 넣어준다.
  • 개발자가 직접 넣어준다.
  • Validator 사용 -> 후에 설명

주의

  • BindingResult는 검증할 대상 바로 다음에 와야한다.
  • BindingResult는 Model에 자동으로 포함된다.

BindingResult와 Errors
BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다. Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다. BindingResult는 여기에 더해서 추가적인 기능들을 제공한다.

예제


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

//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
	int resultPrice = item.getPrice() * item.getQuantity();
	if (resultPrice < 10000) {
		bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
	}
}

FieldError 생성자

FieldError는 두가지 생성자를 제공한다.

파라미터 목록

  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailur : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

ObjectError도 유사하게 두가지 생성자를 제공한다.

스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.



오류 코드와 메시지 처리

errors 메시지 파일 생성

messages.properties를 사용해도 되지만, 오류 메시지를 구분하기 쉽게 errors.properties라는 별도의 파일로 관리해보자.

예제

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
	new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}
  • codes: required.item.itemName를 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object [] {1000, 100000}를 사용해서 코드의 {0}, {1}로 치환할 값을 전달한다.



오류 코드와 메시지 처리 2

rejectValue()를 사용해서 오류 errors.properties에 있는 코드를 직접 입력하지 않아도 실행

rejectValue

void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
  • field : 오류 필드명
  • errorCode: 오류 코드 (메시지에 등록된 코드 아님)
  • errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

예제

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

BindingResult는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price



오류 코드와 메시지 처리 3

오류 코드를 만들 때 세밀하게 작성하면 범용성이 떨어진다.
메시지 코드가 있으면 우선순위로 사용한다.

예제

#Level1
required.item.itemName: 상품 이름은 필수 입니다.

#Level2
required: 필수 값 입니다.



오류 코드와 메시지 처리 4

MessageCodesResolver

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

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류

예) 오류 코드: required, object name: item
1.: required.item
2.: required

필드 오류

예) 오류 코드: 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를 통해서 생성된 순서대로 오류 코드를 보관한다.



오류 코드와 메시지 처리 5

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

ValidationUtils

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



오류 코드와 메시지 처리 6

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

errors.properties에 다음 내용 추가

#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

결과적으로 소스코드를 하나도 건들지 않고, 원하는 메시지를 단계별로 설정









Bean Validation V3

Bean Validation 소개

특정한 구현체가 아니라 검증 애노테이션과 여러 인터페이스의 모음이다.

Bean Validation 시작

검증 애노테이션

@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null을 허용하지 않는다.
@Range(min = 1000, max = 100000) : 범위 안의 값이어야 한다.
@Max(9999) : 최대 9999까지만 허용한다.



스프링 부트 글로벌 Validator

spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다. 스프링 부트는 자동으로 글로벌 Validator로 등록한다. 이 Validator는 @NotNull같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다.
검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

@Validated, @Valid 둘다 사용 가능
@Validated는 내부에 groups라는 기능을 포함

순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1.1 성공하면 다음으로
    1.2 실패하면 typeMismatchFieldError 추가
  2. Validator 적용

바인딩에 성공한 필드만 Bean Validation 적용

@ModelAttribute-> 각각의 필드 타입 변환 시도 -> 변환에 성공한 필드만 BeanValidation 적용



Bean Validatioin - 에러 코드

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보자.
오류 코드가 애노테이션 이름으로 등록된다. 마치 typMismatch와 유사

예제

@NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank

@Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range



BeanValidation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 메시지 찾기
  2. 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다.



Bean Validation - 오브젝트 오류

Bean Validation에서 특정 필드 (FieldError)가 아닌 해당 오브젝트 관련 오류 (ObjectError)는 어떻게 처리? -> @ScriptAssert() 사용

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item {
 //...
}

메시지 코드

  • ScriptAssert.item
  • ScriptAssert



Bean Validation - 한계

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

  • 등록 문제 X -> 수정 문제
  • 등록 문제 -> 수정 문제 X



Bean Validation - groups

위의 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.

예제

저장용 groups

public interface SaveCheck {
}

수정용 groups

public interface UpdateCheck {
}

Item - groups 적용

@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;

@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class,
UpdateCheck.class})
private Integer price;

@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
private Integer quantity;

Controller 적용

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//...
}

groups는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문에 잘 사용하지 않는다









Form 전송 객체 분리 - V4

Form 전송 객체 분리 - 소개

실무에서 groups를 잘 사용하지 않는 이유는 바로 등록시 폼 전달 데이터가 Item 도메인 객체와 맞지 않기 때문이다. (약관 정보 등이 추가로 넘어옴)
그래서 Item을 직접 전달 받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.

HTML Form -> Item -> Controller -> Item -> Repository

  • 장점 : Item 도메인 객체를 컨트롤러, 리파지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단
  • 단점 : 간단한 경우에만 적용 가능

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

  • 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복 안됨
  • 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정 필요

이름은 의미있게 지으면 된다. ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest , ItemSaveDto 등으로 사용



예제

ItemSaveForm

...

@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;

@NotNull
@Max(value = 9999)
private Integer quantity;
 ...

ItemUpdateForm

...
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;

//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
 ...

Controller


@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
 //...
}

주의
@ModelAttribute("item")item 이름을 넣어준 부분을 주의하자. 이것을 닿지 않으면 itemSaveForm이름으로 MVC Model에 담기게 된다.



Bean Validation - HTTP 메시지 컨버터

@Valid, @ValidatedHttpMessageConverter (@RequestBody)에도 적용할 수 있다.

참고

  • @ModelAttribute : HTTP 요청 파라미터 (URL 쿼리 스트링, POST Form)를 다룰 때 사용한다. 각각의 필드 단위로 세밀하게 적용된다.

  • @RequestBody : HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다. HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 진행되지 않고 예외 발생한다.






0개의 댓글