본문 바로가기
공부/Spring

[Spring][인프런 스프링 MVC] @ModelAttribute와 검증 (Validation)

by 웅대 2023. 3. 7.
728x90
반응형

본 포스팅은 김영한 강사님의 인프런 강의 "스프링 MVC 1편"을 정리한 포스팅입니다.

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., -

www.inflearn.com

 

클라이언트가 어떠한 정보를 서버로 넘길 때 검증 과정이 필요하다.

 

예를 들어 길이를 제한할 수도 있고 빈 값을 보낼 수 없게 설정할 수도 있다.

 

템플릿 엔진인 타임리프(Thymeleaf)를 사용하여 HTML form 형식으로 넘어온 데이터를 검증하는 과정을 포스팅해보려 한다.

 

@ModelAttribute

우선 ModelAttribute의 역할부터 알아야한다.

 

@ModelAttribute 어노테이션은 클라이언트로 넘어오는 파라미터 형식을 객체로 받을 수 있도록 해준다.

https://growth-coder.tistory.com/122

<@ModelAttribute 포스팅>

 

예를 들어 클라이언트가 url?name=chulsoo&address=seoul 이러한 형식으로 값을 보낸다고 하면 하나씩 String으로 받아도 되지만 @ModelAttribute를 사용하면 다음과 같이 편하게 사용할 수 있다.

 

우선 클래스를 생성한다.

 

@Getter @Setter
public class Person {
    private String name;
    private Integer age;
}

 

그리고 아래와 같이 어노테이션을 사용한다.

@PostMapping("/url")
public String addItem(@ModelAttribute Person person, Model model) {

	.
    .
    .
}
@ModelAttribute를 사용하면 Model에 자동으로 해당 객체를 넣어준다.

 

즉 굳이 model에 넣어줄 필요없이 view에서 바로 쓰면 된다.

 

참고로 담길 때 key 값은 객체 이름을 camel case로 바꾼 값이 된다.

 

예를 들어 @ModelAttribute Person user 이라고 한다면 key 값은 user이 아니라 person이 된다.

 

만약 user로 쓰고 싶다면 @ModelAttribute("user") Person user라고 쓰면 된다. 

 

@ModelAttribute("key")
public String[] model() {
    return new String[]{"hello", "hi"};
}

참고로 위와 같이 메소드의 파라미터에 붙이는 것이 아니라 메소드 자체에 붙이게 되면 반환 값을 자동으로 model에 넣어준다.

 

@BindingResult

BindingResult는 쉽게 말해서 검증 과정에서 발생한 오류를 담아두는 공간이다.

 

@ModelAttribute이 붙어있는 객체에 담긴 값이 부적절한 값이라면 해당 오류를 담아두기 때문에

BindingResult는 반드시 @ModelAttribute 옆에 작성해야 하고 자동으로 모델에 담긴다.
@PostMapping("/url")
public String addItem(@ModelAttribute Person person, BindingResult bindingResult, Model model) {

	.
    .
    .
}

그리고 @ModelAttribute 옆에 작성하기 때문에 bindingResult는 항상 자신이 가지고 있는 오류의 대상이 누군지 알고 있다.

필드 오류와 글로벌 오류

HTML form 형식으로 넘어온 데이터를 @ModelAttribute로 받아와서 검증을 실행할 때 오류는 크게 필드 오류와 글로벌 오류로 나뉜다.

 

필드 오류는 말 그대로 필드, input에 관련된 오류이다.

 

예를 들어서 name에 대한 필드가 빈 값이어서는 안되고 10글자를 넘어서는 안된다고 가정하면 이 가정에 벗어나는 오류는 name에 대한 필드 오류가 되는 것이다.

 

글로벌 오류는 어떤 특정 필드에만 해당하는 것이 아닌 경우이다.

 

예를 들어 가격 * 수량이 최소 10000원 이상이어야한다고 가정하면 이 가정에 벗어나는 오류는 글로벌 오류가 되는 것이다.

 

참고로 객체 안의 프로퍼티의 타입과 클라이언트가 보낸 값의 타입이 다른 경우는 자동으로 필드 오류를 생성해서 bindingResult에 넣어준다.

 

직접 검증을 진행하고 FieldError와 ObjectError 인스턴스를 생성해서 bindingResult에 넣어줘도 되지만 스프링에서는 이를 간편하게 구현할 수 있도록 도와준다.

 

바로 bindingResult의 reject 혹은 rejectValue를 사용하는 것이다.

 

void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
  1. field : 필드명
  2. errorCode : 에러 코드
  3. errorArgs : 에러 메시지 파라미터
  4. defaultMessage : 에러 메시지를 찾지 못할 때 사용

메시지 관리용 파일 생성

예를 들어 "상품명"이라는 이름을 여러 필드의 label에서 사용하고 있는데 "상품 이름"으로 바꿔야 한다면 상당히 번거로울 것이다. 

 

그런데 별도의 파일을 만들어서 "상품명"이라는 이름을 관리하고 있었고 여기서 가져다가 사용하고 있었다면 이 파일의 "상품명"을 "상품 이름"으로 바꾸면 전체적으로 적용이 될 것이다.

 

이 별도의 파일을 메시지 관리용 파일이라고 부른다.

 

그런데 오류 메시지도 메시지 관리용 파일을 만들어서 관리한다면 스프링의 여러 편한 기능을 사용할 수 있게 된다.

 

resource 아래에 errors.properties 파일을 생성한다.

이 파일을 사용하기 위해서는 application.properties에 다음과 같은 코드를 적어준다.

 

<application.properties>

spring.messages.basename=messages, errors

현재 messages.properties는 존재하지 않지만 아무것도 적지 않으면 디폴트값으로 존재하므로 그냥 넣어두었다.

 

그리고 다음과 같이 오류 코드를 만든다.

 

<errors.properties>

#level1
required.person.name=이름을 입력하세요
range.person.age=나이는 {0}부터 {1} 사이만 입력하세요

#level2
required=입력하세요
range={0}부터 {1} 사이만 입력하세요

typeMismatch=올바른 타입을 입력하세요

오류 코드는 자세할수록 우선순위가 높다.

오류 코드를 만드는 방법은 여러가지가 있는데 위에서는 코드만 사용한 방식과 코드 , 객체 이름, 필드 이름을 조합한 방식을 사용했다.

 

위에서 @ModelAttribute 옆에 작성하기 때문에 bindingResult는 항상 자신이 가지고 있는 오류의 대상이 누군지 알고있다고 했다.

 

그리고 bindingResult의 rejectValue를 사용하면 FieldError와 ObjectError를 직접 생성할 필요가 없다고 했다.

그리고 rejectValue는 파라미터로 코드를 받기 때문에 rejectValue안의 MessageCodesResolver 자동으로 코드, 객체 이름, 필드 이름을 조합한 오류 코드를 생성해낸다.

 

이러한 오류 코드에 해당하는 메시지를 메시지 관리용 파일에 적어두면 클라이언트에게 오류 메시지를 보여줄 수 있는 것이다.

 

물론 무조건 코드, 객체 이름, 필드 이름을 조합한 오류 코드만 생성해내는 것은 아니다.

 

여러 오류 코드를 생성해내고 우선 순위가 높은 오류 코드가 메시지 관리용 파일에 존재하면 그 메시지를 클라이언트에게 보여주는 것이다.

  1. required.person.name
  2. required.name
  3. required.java.lang.String
  4. required

예를 들어 위와 같은 오류 코드를 생성했다면 메시지 관리용 파일에 1번 오류 코드가 존재하므로 그에 해당하는 메시지를 클라이언트에게 보여준다.

 

참고로 @ModelAttribute 객체의 타입이 불일치하면 typeMismatch 코드를 사용하여 오류 코드들을 생성해낸다.

 

Thymeleaf

이렇게 오류 코드를 생성해내면 뷰에서 해당하는 오류 메시지를 출력해야한다.

 

우선 다음과 같은 form을 만들 예정이다.

 

  1. 이름과 나이를 입력받는다.
  2. 이름과 나이는 각각 반드시 입력해야하고 입력하지 않았다면 필드 오류 메시지를 input 태그 아래에 출력한다.
  3. 만약 둘 다 입력하지 않았다면 글로벌 오류 메시지를 출력한다.
  4. 서버에서 @ModelAttribute를 이용하여 이름과 나이를 갖는 Person 객체로 받아온다.
  5. 검증을 진행 후 bindingResult에 발생한 오류를 담는다.

<Controller>

@Controller
@RequiredArgsConstructor
public class Controller {
    @GetMapping("/person")
    public String addForm(Model model) {
        model.addAttribute("person", new Person());
        return "person";
    }
    @PostMapping("/person")
    public String person(@ModelAttribute Person person, BindingResult bindingResult, Model model) {
        if (person.getName().equals("")) {
            bindingResult.rejectValue("name", "required"); 
            //field와 code
        }
        if (person.getAge()==null) {
            bindingResult.rejectValue("age", "range",new Object[]{1000, 10000}, null);
			//field, code와 인자
        }
        if (person.getName().equals("") && person.getAge() == null) {
            bindingResult.reject("global", null);
            //code만 존재. field가 없으므로 글로벌 오류로 인식.
        }
        return "person";
    }
}

localhost:8080/person으로 GET 요청이 들어오면 model에 빈 객체를 담고 template 폴더 아래의 person.html 파일을 보여준다.

 

같은 url로 POST 요청이 들어오면 오류를 검증하여 오류가 있으면 bindingResult에 오류를 담는다.

reject와 rejectValue는 생성자가 여러 개 있으므로 필요한 파라미터를 파악한 후 적절한 것을 고른다.

참고로 default message라는 파라미터에 메시지를 담으면 메시지 관리용 파일이 없어도 해당 메시지를 출력한다.

 

POST 요청 처리가 끝났을 때에는 우선 자동으로 person 객체가 모델에 담기고 bindingResult에 오류가 담긴다.

 

이 상태에서 동일한 html 파일을 클라이언트에게 보여준다.

 

필드 및 코드에 맞게끔 오류 메시지를 만든다.

 

<errors.properties>

#level1
required.person.name=이름은 필수입니다.
range.person.age=나이 범위는 {0}~{1}입니다. 

#level2
required=필수입니다.
range=범위는 {0}~{1}입니다.

typeMismatch=타입 오류
global=둘 다 잘못 입력하셨습니다.

 

Thymeleaf 문법

 

다음은 html 파일인데 이를 이해하려면 Thymeleaf 문법에 대해 알아야 한다.

 

<person.html>

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<form action="/person" th:object="${person}" th:action method="post">
    <!-- 글로벌 오류 -->
    <div th:if="${#fields.hasGlobalErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
    </div>
    <!-- 필드 오류 -->
    <input th:field="*{name}" placeholder="name">
    <div th:errors="*{name}"></div>
    <br>
    <input th:field="*{age}" placeholder="age">
    <div th:errors="*{age}"></div>
    <button>입력</button>
</form>

</body>
</html>

타임리프 문법인 #fields를 사용하면 bindingResult의 글로벌 오류에 접근할 수 있다.

    <!-- 글로벌 오류 -->
    <div th:if="${#fields.hasGlobalErrors()}">
        <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
    </div>

타임리프 문법을 사용하여 글로벌 에러가 존재한다면 반복문을 돌려 글로벌 오류 메시지를 출력한다.

 

다음은 필드에 관한 부분이다.

 

th:object

우선 form 태그에 보면 th:object=${person} 를 볼 수 있는데 이는 object를 담아두는 것이다.

 

@ModelAttribute를 사용하면 해당 객체가 자동으로 model에 담기므로 입력 값으로 들어온 객체를 사용할 수 있게 해준다.

 

이 방식의 장점은 th:object 문법을 사용한 태그 내부에서는 *{ name }과 같은 문법을 사용할 수 있다.

 

참고로 위에서 처음 /person으로 GET 요청이 들어오면 빈 Person 객체를 모델에 담아주었는데 이는 ${person}으로 객체를 불러올 때 null 오류를 방지하기 위해서이다.

 

처음 GET 요청이 들어왔을 때는 모델에 담긴 값이 없기 때문이다.

*{ 필드 이름 }

이는 th:object를 사용한 태그 내부에서 사용할 수 있으며 person 객체가 담겨 있으므로

 

*{ 필드 이름 }은 ${ person.필드 이름 }와 동일하다.

 

즉 th:field = *{name}은 th:text = ${person.name}과 동일하다.

th:field

<input th:field="*{age}" placeholder="age">

th:field는 많은 역할을 한다. 우선 id, name, value를 자동으로 만들어준다.

 

예를 들어 나이를 입력하는 필드의 경우 th:field = *{age}로 되어있는데 나이에 123을 입력하고 이를 렌더링한 결과를 보면

다음과 같다.

<input placeholder="age" id="age" name="age" value="123">

 

 

id와 name은 age로 만들어주고 value는 *{age}로 만들어준다.

 

th:field의 장점은 발생하면 입력 form을 다시 렌더링하도록 했는데 value를 자동으로 만들어주니까 렌더링 할 때 input에 자신이 잘못 입력한 값을 보여준다는 점이다.

 

잘못 입력할 때마다 입력했던 값을 초기화해버리면 번거로울 것이다.

 

 

th:errors

<div th:errors="*{name}"></div>

 

th:errors는 th:if의 기능을 하는데 필드에 오류가 발생하면 해당 필드의 오류 메시지를 출력하는 기능을 한다.

 

위 코드에서 th:errors에 th:object에 담겨있는 person 객체의 name 필드를 넣어주었으므로 이 필드에 오류가 발생하면 자동으로 인식하여 오류 메시지를 출력해준다.

 

테스트 해 본 결과는 다음과 같다.

아무것도 입력하지 않고 입력을 누르면 컨트롤러에 따라서 name, age에 대한 필드 오류와 글로벌 오류가 발생한다.

만약 name만 입력했다면 글로벌 오류는 발생하지 않고 age에 대한 필드 오류만 발생할 것이다.

 

 

html form 형식의 오류의 발생 과정을 정리해보면 다음과 같다.

 

  1. 클라이언트에서 보낸 데이터를 @ModelAttribute 어노테이션을 사용하여 객체로 받는다.
  2. 검증 과정을 진행하고 잘못되었다면 rejectValue를 통해 필드 오류나 글로벌 오류를 생성한다.
  3. rejectValue를 사용할 때 필드, 코드, 파라미터 등등의 정보를 필요한 만큼 담는다.
  4. MessageCodesResolver가 rejectValue의 정보를 사용하여 여러 개의 오류 코드를 생성한다.
  5. 여러 개의 오류 코드 중에서 메시지 관리용 파일에 존재하는 오류 코드 중 가장 우선순위가 높은 메시지를 가져온다.
  6. 타임리프의 th:field 문법을 사용하여 해당 메시지를 클라이언트에게 보여준다.

이번 포스팅에서 직접 필드 값들을 비교하면서 직접 rejectValue를 통해 bindingResult에 오류를 담아주었는데 Bean Validation 방식을 사용하면 그럴 필요가 없다.

 

필드에 어노테이션만 달아주면 검증의 과정을 알아서 처리해주기 때문이다.

728x90
반응형

댓글