new FieldError("item", "price", item.getPrice(), false,
null, null, "가격은 1,000 ~1,000,000 까지 허용합니다.")
여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
스프링의 바인딩 오류 처리
타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.
그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.
따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
오류 코드와 메시지 처리1
오류 메시지를 체계적으로 다루어보자.
ValidationItemControllerV2 - addItemV3() 추가
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[] {9999}, null));
//특정 필드 예외가 아닌 전체 예외
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[] {"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
errors.properties 추가
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
FieldError 예시
//FieldError의 2번째 생성자
public FieldError(
String objectName,
String field,
@Nullable Object rejectedValue,
boolean bindingFailure,
@Nullable String[] codes,
@Nullable Object[] arguments,
@Nullable String defaultMessage
)
//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, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.
rejectValue() , reject()
BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면
FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다
ValidationItemControllerV2 - addItemV4() 추가
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
//특정 필드 예외가 아닌 전체 예외
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/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
필드 오류의 경우 다음 순서로 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 를 통해서 생성된 순서대로 오류 코드를 보관한다.
오류 코드 관리 전략
핵심은 구체적인 것에서! 덜 구체적인 것으로
MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고,
required 처럼 덜 구체적인 것을 가장 나중에 만든다
왜 이렇게 복잡하게 사용하는가?
모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고,
정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
ValidationUtils
스프링이 제공하는 ValidationUtils 클래스를 사용해보자.
ValidationUtils 사용 전
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
/**
* @RequestParam 사용
* - 파라미터 이름으로 바인딩
* @ResponseBody 추가
* - View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력
*/
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
@RequestParam("username") String memberName,
@RequestParam("age") int memberAge) {
log.info("username={}, age={}", memberName, memberAge);
return "ok";
}
@RequestParam
파라미터 이름으로 바인딩한다.
여기서 @RequestParam 을 생략해도 동일하게 동작하지만 생략시 코드 가독성이 떨어질 수가 있다.
@RequestParam 이 있으면 명확하게 요청 파라미터에서 데이터를 읽는다는 것을 알 수 있기 때문
@ResponseBody
View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력
2. HTTP 요청 파라미터 - @ModelAttribute
실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다.
스프링은 이 과정을 완전히 자동화해주는 @ModelAttribute 기능을 제공한다
먼저 요청 파라미터를 바인딩 받을 객체를 만들자.
HelloData
@Data
public class HelloData {
private String username;
private int age;
}
Lombok의 @Data 는 @Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor 를
자동으로 적용해준다.
/**
* @ModelAttribute 사용
* 참고: model.addAttribute(helloData) 코드도 함께 자동 적용됨
*/
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {
log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
return "ok";
}
위의 코드를 작동시켜보면 HelloData 객체가 생성되고, 요청 파라미터의 값도 모두 들어가 있다.
스프링MVC는 @ModelAttribute 가 있으면 다음을 실행한다.
HelloData 객체를 생성한다.
요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾는다.
그리고 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩) 한다. 예) 파라미터 이름이 username 이면 setUsername() 메서드를 찾아서 호출하면서 값을 입력한다.
@ModelAttribute 는 생략할 수 있다.
그런데 @RequestParam 도 생략할 수 있으니 혼란이 발생할 수 있으므로 생략 시 주의가 필요하다
3. HTTP 요청 메시지 - 단순 텍스트
요청 파라미터와 다르게, HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우는
@RequestParam , @ModelAttribute 를 사용할 수 없다
/**
* HttpEntity: HTTP header, body 정보를 편리하게 조회
* - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*
* 응답에서도 HttpEntity 사용 가능
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*/
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {
String messageBody = httpEntity.getBody();
log.info("messageBody={}", messageBody);
return new HttpEntity<>("ok");
}
스프링 MVC는 다음 파라미터를 지원한다.
HttpEntity
HTTP header, body 정보를 편리하게 조회
메시지 바디 정보를 직접 조회
요청 파라미터를 조회하는 기능과 관계 없음 @RequestParam X, @ModelAttribute X
HttpEntity는 응답에도 사용 가능
메시지 바디 정보 직접 반환
헤더 정보 포함 가능
view 조회X
HttpEntity 를 상속받은 다음 객체들도 같은 기능을 제공한다.
RequestEntity
HttpMethod, url 정보 추가
요청에서 사용
ResponseEntity
HTTP 상태 코드 설정 가능
응답에서 사용
return new ResponseEntity("Hello World", responseHeaders, HttpStatus.CREATED)
스프링MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해주는데,
이때 HTTP 메시지 컨버터( HttpMessageConverter )라는 기능을 사용한다.
/**
* @RequestBody
* - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*
* @ResponseBody
* - 메시지 바디 정보 직접 반환(view 조회X)
* - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
*/
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {
log.info("messageBody={}", messageBody);
return "ok";
}
@RequestBody
@RequestBody 를 사용하면 HTTP 메시지 바디 정보를 편리하게 조회할 수 있다.
참고로 헤더 정보가 필요하다면 HttpEntity 를 사용하거나 @RequestHeader 를 사용하면 된다.
이렇게 메시지 바디를 직접 조회하는 기능은
요청 파라미터를 조회하는 @RequestParam , @ModelAttribute 와는 전혀 관계가 없다.
요청 파라미터 vs HTTP 메시지 바디
요청 파라미터를 조회하는 기능 - @RequestParam , @ModelAttribute
HTTP 메시지 바디를 직접 조회하는 기능 - @RequestBody
@ResponseBody
@ResponseBody 를 사용하면 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달할 수 있다.
과거에는 자바 언어에 애노테이션이 없기도 했고, 스프링도 처음부터 이런 유연한 컨트롤러를 제공한 것은 아니다.
@RequestMapping
스프링은 애노테이션을 활용한 매우 유연하고, 실용적인 컨트롤러를 만들었는데
이것이 바로 @RequestMapping 애노테이션을 사용하는 컨트롤러이다.
@Controller
public class SpringMemberFormControllerV1 {
@RequestMapping("/springmvc/v1/members/new-form")
public ModelAndView process() {
return new ModelAndView("new-form");
}
}
스프링 MVC - 컨트롤러 통합
@RequestMapping 은 클래스 단위 뿐 아니라 메서드 단위로도 적용할 수 있다.
따라서 컨트롤러 클래스를 유연하게 하나로 통합할 수 있다.
/**
* 클래스 단위 -> 메서드 단위
* @RequestMapping 클래스 레벨과 메서드 레벨 조합
*/
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mav = new ModelAndView("save-result");
mav.addObject("member", member);
return mav;
}
@RequestMapping
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mav = new ModelAndView("members");
mav.addObject("members", members);
return mav;
}
}
위와 같은 컨트롤러에서는 아래와 같이 조합 된다
클래스 레벨 @RequestMapping("/springmvc/v2/members")
메서드 레벨 @RequestMapping("/new-form") -> /springmvc/v2/members/new-form
메서드 레벨 @RequestMapping("/save") -> /springmvc/v2/members/save
메서드 레벨 @RequestMapping -> /springmvc/v2/members
스프링 MVC - 실용적인 방식
스프링 MVC는 개발자가 편리하게 개발할 수 있도록 수 많은 편의 기능을 제공한다.
실무에서는 지금부터 설명하는 방식을 주로 사용한다.
/**
* v3
* Model 도입
* ViewName 직접 반환
* @RequestParam 사용
* @RequestMapping -> @GetMapping, @PostMapping
*/
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public String newForm() {
return "new-form";
}
@RequestMapping("/save")
public String save(
@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
Model 파라미터
save() , members() 를 보면 Model을 파라미터로 받는 것을 확인할 수 있다.
스프링 MVC도 이런 편의 기능을 제공한다.
ViewName 직접 반환
뷰의 논리 이름을 반환할 수 있다.
@RequestParam 사용
스프링은 HTTP 요청 파라미터를 @RequestParam 으로 받을 수 있다.
@RequestParam("username") 은 request.getParameter("username") 와 거의 같은 코드라 생각하면 된다.
물론 GET 쿼리 파라미터, POST Form 방식을 모두 지원한다.
@RequestMapping -> @GetMapping, @PostMapping
@RequestMapping 은 URL만 매칭하는 것이 아니라, HTTP Method도 함께 구분할 수 있다