일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 게시글 목록 api
- 스프링 입문(무료)
- 스프링 고급 - 스프링 aop
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch7
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch4
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch13
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch8
- 자바 기본편 - 다형성
- 2024 정보처리기사 수제비 실기
- 스프링 mvc2 - 로그인 처리
- 자바의 정석 기초편 ch12
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch11
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch2
- 스프링 mvc1 - 서블릿
- @Aspect
- 스프링 db1 - 스프링과 문제 해결
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch5
- 스프링 mvc2 - 검증
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch3
- 스프링 mvc1 - 스프링 mvc
- 코드로 시작하는 자바 첫걸음
- Today
- Total
나구리의 개발공부기록
검증2 - Bean Validation, 소개/시작/프로젝트준비V3, 스프링 적용, 에러 코드, 오브젝트 오류, 수정에 적용, Bean Validation의 한계, groups, Form 전송 객체 분리(프로젝트준비V4/소개/개발), HTTP 메시지 컨버터 본문
검증2 - Bean Validation, 소개/시작/프로젝트준비V3, 스프링 적용, 에러 코드, 오브젝트 오류, 수정에 적용, Bean Validation의 한계, groups, Form 전송 객체 분리(프로젝트준비V4/소개/개발), HTTP 메시지 컨버터
소소한나구리 2024. 9. 4. 20:24 출처 : 인프런 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 소개/시작/프로젝트준비V3
1) Bean Validation 소개
- 검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다(기존의 ItemValidator 클래스 처럼)
- 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화, 표준화 한것이 Bean Validation이며 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있음
(1) Bean Validation 예시
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
//...
}
(2) Bean Validation이란?
- Bean Validation 2.0(JSR-380)이라는 기술 표준 (검증 애노테이션과 여러 인터페이스의 모음)
- 일반적으로 사용하는 구현체는 하이버네이트 Validator임 (이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다고함)
(3) 하이버네이트 Validator 관련링크
- 공식사이트 https://hibernate.org/validator/
- 공식 메뉴얼 https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
- 검증 애노테이션 모음 https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec
2) 스프링과 통합 Bean Validation
(1) 기존에 작업했던 validation 프로젝트의 build.gradle에 Bean Validation 의존관계 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
(2) Jakarta Bean Validation 라이브러리
- jakarta.validation-api : Bean Validation 인터페이스
- hibernate-validator : 구현체
3) 테스트 코드 작성
(1) Item - Bean Validation 애노테이션 적용 (위 예시 코드와 동일함)
- @NotBlank: 빈값 + 공백만 있는 경우를 허용하지 않음
- @NotNull: null을 허용하지 않음
- @Range(min = 1000, max = 1000000): 범위 안의 값이어야 함
- @Max(9999): 최대 9999까지만 허용
- import에서 vajax.validation.~ 으로 시작하면 구현에 관계없이 제공되는 표준 인터페이스
- org.hibernate.validator.~로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능(대부분 실무에서 하이버네이트 validator를 사용하므로 자유롭게 사용해도 됨, 스프링부트도 자동으로 넣어줌)
@Data
public class Item {
private Long id;
@NotBlank
// @NotBlank(message = "공백 X") // 메시지를 직접 입력 할 수도 있음(default 메시지로 적용됨)
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
//...
}
(2) BeanValidationTest
- test하위 validation 패키지에 작성
- 스프링과 통합하면 직접 구현하는 코드는 작성하지 않으니 참고만 할 것
- 출력 결과는 이미 구현되어있는 오류 메시지가 출력된 것이며 변경할 수 있음
public class BeanValidationTest {
@Test
void beanValidation() {
// 불러오기
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
// 세팅
Item item = new Item();
item.setItemName(" ");
item.setPrice(0);
item.setQuantity(10000);
// 검증
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation = " + violation.getMessage());
}
}
}
// 실행 결과
// violation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
// violation = 9999 이하여야 합니다
// violation = ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
// violation = 1000에서 1000000 사이여야 합니다
// violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
// violation = 공백일 수 없습니다
4) 프로젝트 준비 V3
(1) ValidationItemControllerV2 복사 붙혀넣기로 ControllerV3 생성
- URL경로 변경 validation/v2/ -> validation/v3/
(2) ~/validation/v2/ 템플릿 파일을 ~/validation/v3 디렉토리로 복사 후 모든 하위 파일의 URL경로를 변경(위와 동일)
2. 스프링 적용
1) ValidationItemControllerV3의 필요없는 코드 정리
- 주석된 코드들 전부 삭제
- addItemV6() -> addItem()
- private final ItemValidator itemvalidator; 와 @InitBinder init() 코드들 삭제
(1) 스프링 MVC의 Bean Validator를 사용하는 원리
- spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합됨
- 스프링 부트가 자동으로 글로벌 Validator로 LocalValidatorFactoryBean을 등록하고 @NotNull 같은 애노테이션을 보고 검증을 수행함
- 글로벌로 적용되어있기 때문에 @Valid, @Validated만 파라미터에 적용하면 동작함
- 그래서 직접 Validator를 등록하면 스프링 부트가 Bean Validator를 글로벌 Validator로 등록하지 않기 때문에 굳이 등록할 필요는 없어 보임
- 이미 이전 시간에 검증 애노테이션을 등록했고, addItem()에 @Validated를 적용했기 때문에 바로 동작함(global error는 제외되었는데 이부분은 나중에 설명)
2) 검증 순서
(1) @ModelAttribute 각각의 필드에 타입 변환 시도
- 성공하면 다음으로넘어가고 실패하면 typeMismatch로 FieldError 추가
(2) Validator 적용
3) 바인딩에 성공한 필드만 Bean Validation이 적용됨
- 즉, 바인딩에 실패한 필드는 Bean Validation이 적용되지 않음
- 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있기 때문에 바인딩에 성공한 필드여야 Bean Validation 적용이 의미가 있음
- @ModelAttribute -> 각각의 필드 타입 변환 시도 -> 변환에 성공한 필드면 BeanValidation 적용
** 예시
- price에 문자 "A"를 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMismatch FieldError 추가 -> price필드는 beanValidation 적용 x (기존에 error.properties에 등록해놓은 에러 메시지가 출력됨)
3. 에러 코드
1) 에러 코드
- 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶다면?
- 오류 코드가 애노테이션 이름으로 등록되기 때문에 error.properties에 추가해주면 변경됨
- 상세 순서는 기존과 동일함
(1) @NotBlank 오류 코드
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
(2) errors.properties에 오류 메시지 등록
- 등록 후 실행해보면 아래 등록된 메시지가 정상 적용됨
#Bean Validation 추가
#{0}은 필드명, {1}, {2} ... 등은 각 애노테이션 마다 다르니 문서 참고
NotBlank={0} 공백 X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
(3) BeanValidation메시지 찾는 순서
- 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
- 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백! {0}")
- 라이브러리가 제공하는 기본 값 사용 -> 공백일 수 없습니다
(4) 애노테이션의 message 사용 예
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
4. 오브젝트 오류
1) @ScriptAssert() 사용 - 비추천
- message없이 적용하면 상당히 개발자스러운 에러 메시지가 default로 출력되고 message 기능을 넣어서 입력하고자 하는 메시지를 입력 할 수 있음
- 그러나! 실제로 사용해 보면 제약이 많고 복잡하다고함(검증시 해당 객체의 범위를 넘어서는 경우도 있고, DB의 값을 받아오는 경우도 있는데 그런 경우 대응이 어려움)
- 실무에서는 이것보다 훨씬 더 복잡하게 구성되어있기 때문에 자바 코드로 작성하는 것을 권장함
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
// 이렇게 메시지도 적용 가능
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000",
message = "총합이 10000원 넘게 입력해 주세요")
public class Item {
// ...
}
2) ValidationItemControllerV3 - 글로벌 오류 추가
- 해당 추가한 부분을 메서드로 따로 빼서 사용해도 됨
- 신기술도 좋지만 기능에 너무 제약이 많고 단순하지 않으면 기존에 사용하던 방식으로 해결하는 것도 좋은 방법임!
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 다시 글로벌 오류를 검증하는 코드를 컨트롤러에 작성
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// ...
}
5. 상품수정에 적용하기
1) ValidationItemControllerV3 - edit() 변경 (@PostMapping)
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
@Validated @ModelAttribute Item item, BindingResult bindingResult) {
// 글로벌 오류 검증 추가
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
2) edimForm.html 수정
...
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러</p>
</div>
<div>
<label for="id" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>
...
6. Bean Validation의 한계
1) 수정시 검증 요구사항 변경
(1) 수정시 요구사항
- 데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있기에 수정 시 요구사항이 아래와 같다고 가정
- 등록시에는 quantity수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있음
- 등록시에는 id에 값이 없어도 되지만 수정시에는 id 값이 필수
2) 수정사항을 적용 -> 실패
(1) Item - 수정
- 이렇게 등록해 버리면 수정은 잘 작동하지만 등록이 동작하지 않음
- 등록시 id에 값이 없고, 수량 제한 최대값이 9999도 적용이 안됨 -> 에러 발생으로 계속 상품 등록 폼으로 리다이렉트 됨
- 등록과 수정은 같은 BeanValidation을 적용할 수 없음
@Data
public class Item {
@NotNull // 수정에는 id값이 필수 적용
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
// @Max(9999) // 수정은 Max 제외
private Integer quantity;
// ...
}
** 참고
- 현재 구조에서 수정시 item의 id값은 항상 들어있도록 로직이 구성되어있어 검증하지 않아도 된다고 생각할 수 있음
- 그러나 POST MAN 등으로 HTTP 요청은 얼마든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 최종 검증을 진행해야 안전함
7. Bean Validation - groups
1) 동일한 모델 객체를 수정과 등록할 때 각각 다르게 검증하는 방법 2가지
- Bean Validation의 groups 기능을 사용
- Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용
2) Bean Validation groups 기능 사용하기
(1) 체크를 위한 SaveCheck, UpdateCheck 인터페이스를 각각 생성
public interface SaveCheck {
}
public interface UpdateCheck {
}
(2) Item 도메인에 groups 적용
- 각 적용할 애노테이션에 groups = 를 적용
- 하나만 적용할 시 groups = 적용할인터페이스.class
- 여러개 적용할 시 groups = {적용할인터페이스1.class, 적용할인터페이스2.class}
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정시에만 적용
private Long id;
@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;
// ...
}
(3) ValidationItemControllerV3 - addItem과 edit에 각각 groups 적용
- Validdated()에 파라미터 값으로 각 적용시킬 그룹을 입력
- addItem2 : SaveCheck.class
- edit2 : UpdateCheck.class
- @Valid에는 groups를 적용할 수 기능이 없으므로 groups를 사용하려면 @Validated를 사용해야함
// ... 기존 코드 동일
// groups를 적용한 addItem2 - @Validated(SaveCheck.class)
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
}
// ... 기존 코드 동일
// groups를 적용한 edit2 - @Validated(UpdateCheck.class)
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId,
@Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
// ... 기존 코드 동일
}
(4) 적용 후 실행
- 실행하면 모두 잘 작동하는 것을 볼수 있음, 그러나!
- domain/Item 클래스를 보면 굉장히 복잡해진 것을 볼 수 있고, 전반적인 코드들의 복잡도가 올라감
- 실무에서는 groups 기능을 잘 사용되지 않음 -> 실무에서는 등록용 폼 객체와 수정용 폼객체를 분리해서 사용하기 때문
8. Form 전송 객체 분리(프로젝트준비V4/소개/개발) - 최종버전
1) 프로젝트 준비 V4
(1) ValidationItemControllerV3 복사 붙혀넣기로 ControllerV4 생성
- URL경로 변경 validation/v3/ -> validation/v4/
(2) ~/validation/v3/ 템플릿 파일을 ~/validation/v4 디렉토리로 복사 후 모든 하위 파일의 URL경로를 변경(위와 동일)
2) Form 전송 객체 분리
- 실무에서는 groups를 잘 사용하지 않는데, 이유는 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문
- 예제와 같은 간단한 프로젝트에서는 잘 맞지만 실무에서는 Item과 관계없는 수많은 부가 데이터가 회원 등록시 같이 넘어오게 됨
- 그래서 직접 Item을 전달 받는 것이 아닌 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달함
(1) 폼 데이터 전달에 Item 도메인 객체 사용
- HTML Form -> ITem -> Controller -> Item -> Repository
- 장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달 -> 간단함
- 단점: 매우 간단한 경우에만 적용가능, 수정 시 검증이 중복될 수 있음(groups를 이용해야함)
(2) 폼 데이터 전달을 위한 별도의 객체 사용
- HTML Form -> ITemSaveForm(예시) -> Controller -> Item 생성 -> Repository
- 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있음(보통 등록과 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않음)
- 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가됨
(3) 등록과 수정의 차이
- 수정의 경우 등록과 완전히 다른 데이터가 넘어오는데, 보통 회원 가입시 다루는 데이터와 수정시 다루는 데이터는 범위에 차이가 있음
- 로그인 id, 주민번호 등을 등록시에는 받을 수 있지만, 수정시에는 이런 부분이 빠지게 되고 검증 로직도 많이 달라짐
- 심지어 Item을 생성하는데 필요한 추가 데이터를 DB나 다른 곳에서 찾아와야 할 수도 있음
- 그래서 각각 별도의 객체로 데이터를 전달 받는 것이 좋기 때문에 별도의 폼 객체를 나누게 되고 groups를 적용할 일이 드물어짐
(4) 네이밍
- 이름은 의미있게 지으면됨, 중요한것은 일관성임(소속 팀이나 프로젝트 등에 일관성에 맞춰서 작명)
- ItemSave, ItemSaveForm, ItemSaveRequest, ItemSaveDto 등등..
(5) 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋은가.
- 각각의 장단점이 있어 고민하는게 좋지만 어설프게 합치면 수많은 분기문(등록, 수정 등등) 때문에 if문이 많아지면 나중에 유지보수가 매우 어려워짐
- 이런 어설픈 분기문들이 보이기 시작하면 분리해야할 신호라고 봐야함
3) From 전송 객체 분리 개발
(1) domain - Item 원복
- 적용했던 모든 annotation 제거 -> 완전 초기상태로 복귀(Item의 검증은 사용하지 않음)
(2) Item 저장용 폼 - ItemSaveForm 생성
- validation 패키지 하위에 작성
- 저장용 폼에는 id가 없어도 됨
package hello.itemservice.web.validation.form;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
(3) Item 수정용 폼 - ItemUpdateForm 생성
- 수정용 폼에는 수량에 대한 검증을 제외
package hello.itemservice.web.validation.form;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
// 수정에서는 수량은 자유롭게 변경하도록 변경
private Integer quantity;
}
(4) ValidationItemControllerV4 컨트롤러 수정
- Item 대신에 ItemSaveForm, ItemUpdateForm을 전달 받도록 변경
- 뷰 템플릿 변경을 하지 않기 위해 @ModelAttribute("item")으로 적용 (생략하면 규칙에의해 itemSaveForm이라는 이름으로 MVC Model에 담기게 됨
// ... 기존 코드 생략
// @ModelAttribute("item") 이렇게 명시하지않으면(생략시) itemSaveform 이라는 이름으로 넘어가게됨
// addAttribute("itemSaveform", form) 처럼 동작함
// 뷰 템플릿의 item을 모두 바꿔서 적용해도 되지만 지금은 이렇게 작성
@PostMapping("/add")
public String addItem2(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// ... 기존 코드 생략
// 저장할 Item 객체 생성
Item item = new Item();
// 게터세터말고 생성자로 하는것이 더 좋지만 여기선 getter setter를 활용
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
// ... 기존 코드 생략
// addform과 마찬가지
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId,
@Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
// ... 기존 코드 생략
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
}
9. HTTP 메시지 컨버터
1) HTTP 메시지 컨버터
- @Valid, @Validated는 HttpMessageConverter (@RequestBody)에도 적용할 수 있음
** 참고
- @ModelAttribute는 HTTP 요청 파라미터(URL 쿼리스트링, POST Form) 다룰때 사용
- @RequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용 (주로 API JSON 요청을 다룰 때 사용)
(1) ValidationItemApiController 생성
- 실제로 개발할 때는 getAllErrors()로 객체를 그대로 사용하지 않고 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 함
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors(); // bindingResult에 담긴 모든 에러를 반환
}
log.info("성공 로직 실행");
return form; // form 그대로 반환
}
}
2) Postman으로 테스트
(1) API의 경우 3가지 경우를 나누어 생각해야 함
- 성공 요청: 성공
- 실패 요청: JSON을 객체로 생성하는 것 자체가 실패 -> 예외 터짐
- 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했으나, 검증에서 실패
(2) 실패 요청
- {"itemName":"hello", "price":qqq, "quantity":10 }: price의 값에 숫자가 아닌 문자를 전달해서 실패하도록 요청
- [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Invalid UTF-8 start byte 0x85 ....... 의 에러 로그가 뜸
- HttpMessageConverter에서 JSON을 ItemSaveForm 객체로 생성하는데 실패했기 때문에 컨트롤러 자체가 호출 되지 않고 그전에 예외가 발생해버리기 때문에 Validator자체가 실행 되지 않음
(3) 실패요청 결과
(4) 검증 오류 요청
- {"itemName":"hello", "price":1234, "quantity":10000}
- 수량이 10000이면 BeanValidation @Max(9999)에서 걸림
- 로그 및 결과를 보면 검증 오류가 정상 수행 된 것을 확인할 수 있음
(5) @ModelAttribute VS @RequestBody
- HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 동작하므로 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 바인딩이 되어 Validator를 사용한 검증을 적용할 수 있음
- 그러나 HttpMessageConverter는 전체 객체 단위로 적용되어 메시지 컨버터의 작동이 성공해서 객체를 만들어야만 @Valid, @Validated가 적용됨
- 즉, @RequestBody는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이우 단계 자체가 진행되지 않고 예외가 발생하며 컨트롤러도 호출되지 않고 Validator도 적용할 수 없음
** 참고
- HttpMessageConverter 단계에서 실패하면 예외가 발생하는데, 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룰 예정