타임리프와 메세지, 국제화, 검증

Created
2024/11/06 05:03
상태
Done
태그
spring
web service

Thymeleaf

타임리프 특징

서버 사이드 HTML 랜더링(SSR, Server Side Rendering)
타임리프는 서버 사이드 랜더링 방식으로, 백엔드 서버에서 HTML을 동적으로 랜더링한다.
네츄럴 템플릿
타임리프는 순수 HTML을 최대한 유지하여, 웹 브라우저에서 파일을 직접 열어도 확인이 가능하고 서버를 통해 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인할 수 있다.
타임리프와 달리 JSP 파일을 웹 브라우저에서 열면 정상적으로 확인할 수 없는데, 이러한 특징을 네츄럴 템플릿(natural templates)이라 한다.
스프링 통합 지원
타임리프는 스프링과 자연스럽게 통합되고, 스프링의 다양한 기능을 편리하게 사용할 수 있도록 지원한다.

타임리프 기본 표현

타임리프 사용 선언
<html xmlns:th="http://www.thymeleaf.org">
기본 표현식
변수 표현식 : ${...}
선택 변수 표현식 : *{...}
메세지 표현식 : #{...}
링크 URL 표현식 : @{...}
조각 표현식 : ~{...}
리터럴 표현식
텍스트 : 'one text', 'Another one!'
숫자 : 0, 34, 3.0, 12.3
불린 : true, false
널 : null
리터럴 토큰 : one, sometext, main
문자 연산
문자 합치기 : +
리터럴 대체 : |The name is ${name}|
산술 연산
이항 연산 : +, -, *, /, %
음수 부호 : -
논리 연산
이항 연산 : and, or
부정 연산 : !, not
비교와 동등
비교 연산 : <, >, <=, >= (gt, lt, ge, le)
동등 연산 : ==, != (eq, ne)
조건 연산
if-then : (if) ? (then)
if-then-else : (if) ? (then) : (else)
Default : (value) ?: (default value)
특별한 토큰
No-Operation : _

텍스트 출력

HTML의 콘텐츠(content)에 데이터를 출력할 때는, 아래와 같이 th:text를 사용한다.
<span th:text=${data}">
HTML의 태그 속성이 아니라 콘텐츠 영역 안에서 직접 데이터를 출력하고 싶으면, [[...]]를 사용하면 된다.
<li>컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>
HTML 문서는 태그에 사용되는 <>와 같은 특수 문자를 기반으로 정의된다. 타임리프의 th:text[[...]]는 기본적으로 여는 괄호와 닫는 괄호에 대해 치환해 출력하는 이스케이프(escape)를 제공한다.
< : &lt;
> : &gt;
만약 이런 이스케이프 기능 없이 <b></b>와 같은 태그를 받으면 굵은 글씨로 보이도록 적용하고 싶다면, 콘텐츠를 출력할 때 다음 두 가지를 사용하면 된다.
th:utext
[(...)]
타임리프에서 변수를 사용할 때는 ${...}와 같은 변수 표현식을 사용하는데, SpringEL 표현식이라는 스프링이 제공하는 표현식도 사용이 가능하다.
User userA = new User("userA", 10); User userB = new User("userB", 20); List<User> list = new ArrayList<>(); list.add(userA); list.add(userB); Map<String, User> map = new HashMap<>(); map.put("userA", userA); map.put("userB", userB); model.addAttribute("user", userA); model.addAttribute("users", list); model.addAttribute("userMap", map);
Java
복사
이와 같이 모델에 데이터를 넣어두면 아래의 표현식들을 통해 해당 변수에 접근할 수 있다.
${user.username}
${user['username']}
${user.getUsername()}
${users[0].username}
${userMap['userA'].username}
타임리프에서 지역 변수를 선언하여 사용하려면, th:with를 통해 사용할 수 있다.
<div th:with="first=${user[0]}"> <p>처음 사람의 이름은 <span th:text="${first.username}"></span></p> </div>
HTML
복사

타임리프 객체

타임리프는 다음과 같이 모델에 넣어둔 객체들 중 몇 가지를 기본 객체들과 편의 객체들을 제공한다.
@Component("helloBean") static class HelloBean { public String hello(String data) { return "Hello " + data; } }
Java
복사
session.setAttribute("sessionData", "Hello Session"); model.addAttribute("request", request); model.addAttribute("response", response); model.addAttribute("servletContext", request.getServletContext()); return "basic/basic-objects";
Java
복사
request : ${request}
response : ${response}
session : ${session}
servletContext : ${servletContext}
locale : ${#locale}
Request Parameter : ${param.paramData}
spring bean : ${@helloBean.hello('Spring!')}
타임리프는 문자, 숫자, 날짜, URI 등을 편리하게 다룰 수 있는 다양한 유틸리티 객체들도 제공한다.
#message : 메세지, 국제화 처리 기능 지원
#uris : URI 이스케이프 지원
#dates : java.util.Date 서식 지원
#calendars : java.util.Calendar 서식 지원
#temporals : Java8 날짜 서식 지원
#numbers : 숫자 서식 지원
#strings : 문자 관련 편의 기능 제공
#objects : 객체 관련 기능 제공
#bools : boolean 관련 기능 제공
#arrays : 배열 관련 기능 제공
#lists, #sets, #maps : 컬렉션 관련 기능 제공
#ids : 아이디 처리 관련 기능 제공

타임리프 URI 링크 처리

단순 URL
@{/hello}/hello
쿼리 파라미터
@{/hello(param1=${param1}, param2=${param2})}/hello?param1=data1&param2=data2
(...) 안에 있는 부분은 쿼리 파라미터로 처리된다.
경로 변수
@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}/hello/data1/data2
URL 경로상에 변수가 있다면 (...) 안에 있는 부분은 경로 변수로 처리된다.
경로 변수 + 쿼리 파라미터 복합 사용
@{/hello/{param1}(param1=${param1}, param2=${parram2})}/hello/data1?param2=data2
이와 같이 경로 변수와 쿼리 파라미터를 함께 사용할 수 있다.
상대 경로와 절대 경로
/hello : 절대 경로
hello : 상대 경로

리터럴

타임리프에는 여러 리터럴을 지원한다.
문자 : 'hello'
숫자 : 10
불린 : true, false
null : null
문자 리터럴은 항상 (작은 따옴표)로 감싸야 한다
<span th:text="'hello'">
A-Z, a-z, 0-9, [], ., -, _의 문자로만 이루어진 문자열은 하나의 의미있는 토큰으로 인지하여 작은 따옴표를 생략할 수 있다.
<span th:text="hello world"> → 오류 발생
<span th:text="'hello ' + ${data}"> → 가능
리터럴 대체 문법을 사용하면 템플릿을 사용하는 것처럼 편리하게 적용할 수 있다.
<span th:text="|hello ${data}|">

연산자와 조건식

타임리프에서는 산술 연산과 비교 연산을 지원한다.
<span th:text="10 + 2">
<span th:text="1 &gt; 10">
<span th:text="1 gt 10">
<span th:text="1 > 10">
조건식을 통해 값을 출력할 수도 있다.
<span th:text="(10 % 2 == 0)? '짝수':'홀수'">
데이터가 있는지 여부에 따른 조건인 Elvis 연산자도 가능하다
<span th:text="${data}?: '데이터가 없습니다.'">
_ 연산자인 No-Operation을 활용하는 방법도 있다. 이 경우에는 조건에 맞으면 타임리프가 없는 것처럼 동작하여 해당 태그의 HTML 기본값이 출력된다.
<span th:text="${data}?: _">데이터가 없습니다.</span>

속성 값 설정

th:* 속성을 지정하면, 타임리프가 해당 이름과 동일한 기존의 속성을 지정한 값으로 대체하고 기존 속성이 없다면 새로 추가한다.
<input type=”text” name="mock" th:name="userA" /><input type="text" name="userA" />
th:attrappend, th:attrprepend, th:classappend 속성을 통해 기존의 속성 앞과 뒤에 새로운 속성 값을 추가할 수 있다.
<input type="text" class="text" th:attrappend="class='large '" /><input type="text" class="text large" />
<input type="text" class="text" th:attrprepend="class=' large'" /><input type="text" class="large text" />
<input type="text" class="text" th:classappend="large" /><input type="text" class="text large" />
HTML에서 checked 속성은 그 값과는 상관 없이 속성이 존재하기만 하면 체크박스에 표시가 된다. 이를 타임리프에서 th:checked 속성을 사용하면 false인 경우 checked 속성 자체를 삭제하여 표시하지 않도록 만들어준다.
<input type="checkbox" name="active" checked="false" /> → 체크 표시 O
<input type="checkbox" name="active" th:checked="true" /> → 체크 표시 O
<input type="checkbox" name="active" th:checked="false" /> → 체크 표시 X

반복

타임리프에서는 th:each 속성을 사용하여 반복을 구현할 수 있다. 추가적으로 반복 내에서 사용할 수 있는 여러 상태 값을 지원한다.
List<User> list = new ArrayList<>(); list.add(new User("userA", 10)); list.add(new User("userB", 20)); list.add(new User("userC", 30)); model.addAttribute("users", list);
Java
복사
<tr th:each="user : ${users}"> <td th:text="${user.username}">username</td> <td th:text="${user.age}">0</td> </tr>
HTML
복사
위와 같이 변수 내부를 순회하여 반복을 구현할 수 있다. 오른쪽의 ${users} 컬렉션에서 하나씩 꺼내어, user 변수에 담아 태그를 반복 실행한다.
th:each는 List 뿐만아니라 java.util.Iterable, java.util.Enumeration을 구현한 모든 객체에 사용할 수 있다. Map도 사용 가능하고, Map.Entry 형태로 담기게 된다.
<tr th:each="user, userStat : ${users}"> <td th:text="${userStat.count}">username</td> <td th:text="${user.username}">username</td> <td th:text="${user.age}">0</td> <td> index = <span th:text="${userStat.index}"></span> count = <span th:text="${userStat.count}"></span> size = <span th:text="${userStat.size}"></span> even? = <span th:text="${userStat.even}"></span> odd? = <span th:text="${userStat.odd}"></span> first? = <span th:text="${userStat.first}"></span> last? = <span th:text="${userStat.last}"></span> current = <span th:text="${userStat.current}"></span> </td> </tr>
HTML
복사
또한 이처럼 index, count, even, odd, first, last 등 반복 내부에서 사용할 수 있는 상태값을 지원한다. 이 userStat 상태값은 th:each 속성 내에서 생략하더라도 사용하는게 가능하다. 생략하게 되면 변수명 + Stat의 이름으로 호출할 수 있다.

조건부 평가

타임리프에는 ifunless를 통해 조건에 맞지 않으면 태그 자체를 렌더링하지 않도록 만들 수 있다.
<span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
<span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
다른 조건부 속성으로는 switch가 있다.
<td th:switch="${user.age}"> <span th:case="10">10살</span> <span th:case="20">20살</span> <span th:case="*">기타</span> </td>
HTML
복사
*의 경우에는 만족하는 조건이 없을 때 사용하는 디폴트이다.

주석

표준 HTML 주석 처리
<!-- ... -->
HTML 주석은 타임리프가 렌더링하지 않고 그대로 남겨둔다.
타임리프 파서 주석
<!--/* ... */-->
타임리프 파서 주석은 타임리프 자체에서 주석으로 인삭하기 때문에, 렌더링에서 주석 부분 자체를 제거한다.
타임리프 프로토타입 주석
<!--/*/ ... /*/-->
<!--/*--> + <!--*/-->
타임리프 프로토타입 주석은 HTML 파일을 열어보면 웹 브라우저가 렌더링 하지 않을 뿐 주석 자체는 들어있다. 하지만 이를 타임리프 렌더링을 거치면 정상적으로 렌더링 되어 보이게 되는 주석이다.

블록

타임리프에는 HTML 태그가 아닌 유일한 자체 태그인 <th:block> 태그가 있다.
<th:block th:each="user : ${users}"> <div> 사용자 이름1 <span th:text="${user.username}"></span> 사용자 나이1 <span th:text="${user.age}"></span> </div> <div> 요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span> </div> </th:block>
HTML
복사
위의 div 태그 2개를 맞춰서 반복을 돌고 싶을 때와 같이, HTML 태그 안에 속성으로 기능을 정의해서 사용하기 애매한 경우에 사용된다.
<th:block> 태그는 렌더링 시 제거된다.

자바스크립트 인라인

타임리프에는 자바스크립트를 편리하게 사용할 수 있도록 자바스크립트 인라인 기능을 제공한다.
<!-- 자바스크립트 인라인 사용 전 --> <script> var username = [[${user.username}]]; var age = [[${user.age}]]; // 자바스크립트 내추럴 템플릿 var username2 = /*[[${user.username}]]*/ "test username"; // 객체 var user = [[${user}]]; </script> <!-- 결과 --> <script> var username = userA; var age = 10; // 자바스크립트 내추럴 템플릿 var username2 = /*userA*/ "test username"; // 객체 var user = BasicController.User(username=userA, age=10); </script>
HTML
복사
자바스크립트를 렌더링하면 userA라는 변수 이름이 문자열이 아닌 그대로 처리되어 오류가 발생한다. 반면 age의 경우 문자열이 아닌 상수 10으로 처리되어 정상 렌더링 된다.
자바스크립트에는 내추럴 템플릿을 지원하지 않기 때문에, 주석으로 인식하지 못하고 그대로 해석하게 된다.
자바스크립트에서 객체를 넣으면 toString()이 호출된 결과가 들어간다.
<!-- 자바스크립트 인라인 사용 후 --> <script th:inlime="javascript"> var username = [[${use.usename}]]; var age = [[${user.age}]]; // 자바스크립트 내추럴 템플릿 var username2 = /*[[${user.username}]]*/ "test username"; // 객체 var user = [[${user}]]; </script> <!-- 결과 --> <script> var username = "userA"; var age = 10; // 자바스크립트 내추럴 템플릿 var username2 = "userA"; // 객체 var user = {"username":"userA", "age":10}; </script>
HTML
복사
자바스크립트 인라인을 사용하면, 문자 타입인 경우 을 포함해주고 이스케이프(\”)까지 자동으로 처리해준다.
자바스크립트 인라인은 내추럴 템플릿 기능을 지원하여, 주석 부분이 제거되고 userA로 대체되어 적용된 것을 볼 수 있다.
자바스크립트 인라인에서는 JSON으로 변환하여 저장해준다.
자바스크립트 인라인에는 each 기능을 지원한다.
<script th:inline="javascript"> [# th:each="user, stat : ${users}"] var user[[${stat.count}]] = [[${user}]]; [/] </script> <!-- 결과 --> <script> var user1 = {"username":"userA", "age":10}; var user2 = {"username":"userB", "age":20}; var user3 = {"username":"userC", "age":30}; </script>
HTML
복사

템플릿 조각

웹 페이지 개발 시 공통된 영역을 나타내기 위해 반복적으로 코드가 사용되는 것을 해결하기 위해 템플릿 조각 기능을 지원한다.
<footer th:fragment="copy"> 푸터 자리 입니다 </footer>
HTML
복사
공통된 영역에 들어갈 html 코드를 이와 같이 th:fragment와 같이 선언해두면,
<div th:insert="~{template/fragment/footer :: copy}"></div> <div th:replace="~{template/fragment/footer :: copy}"></div>
HTML
복사
이와 같이 경로를 입력하여 해당 템플릿 조각을 불러와 렌더링할 수 있다.
insert의 경우 해당 태그 내부에 footer에 선언한 태그를 삽입한다.
replace의 경우 해당 태그를 대체하여 footer에 선언한 태그로 치환한다.
경로를 표현하는 ~{...} 부분은 코드가 단순하다면 생략 가능하다.
<footer th:fragment="copyParam (param1, param2)"> <p>파라미터 자리 입니다.</p> <p th:text="${param1}"></p> <p th:text="${param2}"></p> </footer>
HTML
복사
이와 같이 파라미터를 받아 사용할 수 있고,
<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></ div>
HTML
복사
파라미터는 위와 같이 전달한다.

템플릿 레이아웃

템플릿 조각이 일부 코드 조각을 가지고 와서 대체하거나 삽입하여 렌더링 하는 것이라면, 템플릿 레이아웃은 코드 조각을 레이아웃에 넘겨 렌더링 하는 방식이다.
<!DOCTYPE html> <html th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org"> <head> <title th:replace="${title}">레이아웃 타이틀</title> </head> <body> <h1>레이아웃 H1</h1> <div th:replace="${content}"> <p>레이아웃 컨텐츠</p> </div> <footer> 레이아웃 푸터 </footer> </body> </html>
HTML
복사
<!DOCTYPE html> <html th:replace="~{template/layoutExtend/layoutFile :: layout(~{::title},~{::section})}" xmlns:th="http://www.thymeleaf.org"> <head> <title>메인 페이지 타이틀</title> </head> <body> <section> <p>메인 페이지 컨텐츠</p> <div>메인 페이지 포함 내용</div> </section> </body> </html>
HTML
복사
위와 같이 전체 레이아웃을 구성해두고, 해당 레이아웃에 일부 코드 조각을 넘겨 부분적으로 교체하여 렌더링한다.

타임리프와 스프링 통합

타임리프는 스프링이 없어도 동작하지만, 스프링과의 통합을 위해 다음과 같은 다양한 편의 기능을 제공한다.
스프링의 SpringEL 문법 통합
${@myBean.doSomething()}처럼 스프링 빈 호출 지원
th:objectth:field, th:errors, th:errorclass와 같은 편리한 폼 관리 속성 제공
checkbox, radio button, List 등을 편리하게 쓸 수 있는 폼 컴포넌트 기능 제공
스프링의 메세지, 국제화 기능 통합
스프링의 검증, 오류 처리 통합
스프링의 변환 서비스 통합(ConversionService)

폼 관리 속성

@GetMapping("/add") public String addForm(Model model) { model.addAttribute("item", new Item()); return "form/addForm"; }
Java
복사
이처럼 Model에 객체를 넣어둔다면,
<form action="item.html" th:action th:object="${item}" method="post"> <div> <label for="itemName">상품명</label> <input type="text" th:field="*{itemName}" class="form- control" placeholder="이름을 입력하세요"> </div> <div> <label for="price">가격</label> <input type="text" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요"> </div> <div> <label for="quantity">수량</label> <input type="text" th:field="*{quantity}" class="form- control" placeholder="수량을 입력하세요">``` </div> </form>
HTML
복사
이와 같이 form 태그 내부에서 th:object를 통해 해당 객체를 편하게 사용할 수 있다.
${item.itemName}과 같은 변수는 th:object로 선택한 객체 내부의 변수를 호출하는 *{itemName}과 같은 선택 변수식을 적용할 수 있다.
th:field 속성을 사용하면 id, value, name 속성을 전부 객체의 필드명과 값을 꺼내어 자동으로 생성해준다.

체크 박스

<!-- single checkbox --> <div>판매 여부</div> <div> <div class="form-check"> <input type="checkbox" id="open" name="open" class="form-check-input"> <label for="open" class="form-check-label">판매 오픈</label> </div> </div>
HTML
복사
이와 같이 단순히 HTML로 체크 박스를 구현하여 데이터를 받아보면, 체크 박스를 선택하는 경우에는 item.open=true로 잘 전달되지만 선택하지 않는 경우에 item.open=null로 전달된다. 이는 HTML에서 체크 박스를 선택하지 않고 보내면 전송 시에 open이라는 필드 자체를 빼고 전송하기 때문에 발생한다.
이를 해결하기 위해 Spring의 MVC에서는 히든 필드 기능을 지원한다.
<!-- single checkbox --> <div>판매 여부</div> <div> <div class="form-check"> <input type="checkbox" id="open" name="open" class="form-check-input"> <input type="hidden" name="_open" value="on"/> <!-- 히든 필드 추가 --> <label for="open" class="form-check-label">판매 오픈</label> </div> </div>
HTML
복사
이와 같이 _open처럼 언더바를 붙인 히든 필드를 선언하고 요청을 보내면, 체크 박스 선택 시에는 open=on&_open=on 형태로 보내고 선택하지 않았을 때는 _open=on으로 보내게 된다.
이러한 히든 필드를 Spring MVC에서 인식하여 open=trueopen=false 형태로 데이터를 바꾸어 준다.
타임리프를 사용한다면 이러한 히든 필드 부분을 자동으로 처리해준다.
<!-- single checkbox --> <div>판매 여부</div> <div> <div class="form-check"> <input type="checkbox" id="open" th:field="*{open}" class="form-check-input"> <label for="open" class="form-check-label">판매 오픈</label> </div> </div>
HTML
복사
이와 같이 체크 박스에 th:field만 추가하면, 히든 필드를 자동으로 추가하여 우리가 받을 때는 true와 false 형태로 받을 수 있도록 만들어준다.
추가적으로 해당 값을 받아 렌더링 결과를 보면, 체크 박스가 체크되어 있다면 checked="checked" 속성이 들어가 있고 체크 되어 있지 않다면 checked 속성 자체가 없는 것을 확인할 수 있다. 이 역시 타임리프가 체크 박스 속성이 true인 경우 자동으로 checked 속성을 추가하도록 처리해준다.
다중 체크박스를 구현하기 위해 먼저 model에 map을 추가하자
@ModelAttribute("regions") public Map<String, String> regions() { Map<String, String> regions = new LinkedHashMap<>(); regions.put("SEOUL", "서울"); regions.put("BUSAN", "부산"); regions.put("JEJU", "제주"); return regions; }
Java
복사
필요한 모든 Controller에 Map을 생성하여 model에 넣는 작업을 해주는 것은 번거롭고 비효율적이니, 위와 같이 @ModelAttribute 애노테이션을 사용하면 Controller에서 요청을 반환할 때 자동으로 model에 위 map을 담아서 보내게 된다.
다중 체크박스의 경우에는 th:each 반복을 통해 구현할 수 있다.
<!-- multi checkbox --> <div> <div>등록 지역</div> <div th:each="region : ${regions}" class="form-check form-check-inline"> <input type="checkbox" th:field="*{regions}" th:value="${region.key}" class="form-check-input"> <label th:for="${#ids.prev('regions')}" th:text="${region.value}" class="form-check-label">서울</label> </div> </div>
HTML
복사
코드에서 ${regions}는 위에서 model 담았던 map을 순회하는 반복이고, th:field*{region}는 상위 태그의 th:object에서 받아온 아이템의 region이다.(${item.region}과 동일)
th:value${region.key}를 넣었기 때문에, 타임리프는 item.region에서 해당 value와 동일한 값을 찾고 존재한다면 checked 속성을 추가하여 렌더링한다.
추가적으로 label 태그를 통해 글자를 누르더라도 체크 박스를 선택할 수 있게 만드려면, th:for 속성에 체크 박스의 id를 넣어주어야 한다. 하지만 th:each를 통해 반복을 돌고 있기 때문에 이를 처리하기가 쉽지 않은데, 타임리프에서는 id 뒤에 임의로 숫자를 붙여주어 ${#ids.prev('...')}${#ids.next('...')}와 같이 호출하여 id를 쉽게 처리할 수 있도록 도와준다.
<!-- 렌더링 결과 --> <input type="checkbox" value="SEOUL" class="form-check-input" id="regions1" name="regions"> <input type="checkbox" value="BUSAN" class="form-check-input" id="regions2" name="regions"> <input type="checkbox" value="JEJU" class="form-check-input" id="regions3" name="regions">
HTML
복사
수정이나 저장과 같이 객체를 서버로 전달 받는 경우에는 item.regions=[SEOUL, BUSAN]처럼 컬렉션에 넣어 전달되고, 체크된 항목이 없다면 hidden 태그를 자동으로 생성하기 때문에 null이 아닌 빈 배열로 전달된다.

라디오 버튼

@ModelAttribute("itemTypes") public ItemType[] itemTypes() { return ItemType.values(); }
Java
복사
라디오 버튼을 구현하기위해 위와 같이 model에 Enum의 값들을 배열로 담아주고,
<!-- radio button --> <div> <div>상품 종류</div> <div th:each="type : ${itemTypes}" class="form-check form-check-inline"> <input type="radio" th:field="*{itemType}" th:value="${type.name()}" class="form-check-input"> <label th:for="${#ids.prev('itemType')}" th:text="${type.description}" class="form-check-label"> BOOK </label> </div> </div>
HTML
복사
체크 박스와 마찬가지로 th:each로 반복하도록 작성하고, th:value에 Enum의 name을 넣어두면 타임리프에서 해당 값과 비교하여 일치하는 값에 표시한다.
체크 박스와 유사하지만 차이점은 히든 태그가 없어 아무것도 선택하지 않으면 null이 전달된다.
<div th:each="type : ${T(hello.itemservice.domain.item.ItemType).values()}">
HTML
복사
추가적으로 model에 넣는 것이 아니라 위와 같이 Enum의 패키지를 넣어 직접 사용하는 SpringEL 문법도 지원하지만, 패키지 위치나 이름이 변경될 때마다 확인하는 작업에서 실수하기 쉽기 때문에 권장되는 방법은 아니다.

셀렉트 박스

@ModelAttribute("deliveryCodes") public List<DeliveryCode> deliveryCodes() { List<DeliveryCode> deliveryCodes = new ArrayList<>(); deliveryCodes.add(new DeliveryCode("FAST", "빠른 배송")); deliveryCodes.add(new DeliveryCode("NORMAL", "일반 배송")); deliveryCodes.add(new DeliveryCode("SLOW", "느린 배송")); return deliveryCodes; }
Java
복사
셀렉트 박스를 추가하기 위해 이전과 동일하게 model에 컬렉션을 추가해주고,
<!-- SELECT --> <div> <div>배송 방식</div> <select th:field="*{deliveryCode}" class="form-select"> <option value="">==배송 방식 선택==</option> <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}" th:text="${deliveryCode.displayName}">FAST</option> </select> </div>
HTML
복사
이와 같이 작성하면 셀렉트 박스를 구성할 수 있다.

메세지와 국제화

label 태그에 들어가는 상품명이나 가격, 수량과 같은 항목들을 변경하려면, 각 html 파일들을 찾아다니며 변경해야한다. 스프링과 타임리프에서는 이러한 다양한 메세지를 한 곳에서 관리하도록 메세지 기능과 국제화 기능을 제공한다.
메세지 기능을 사용하기 위해서는 스프링 메세지 소스를 설정해주어야 하는데,
@Bean public MessageSource messageSource() { ResourceBundlerMessageSource messageSource = new ResourceBundlerMessageSource(); messageSource.setBasenames("messages", "errors"); messageSource.setDefaultEncoding("utf-8"); return messageSource; }
Java
복사
스프링으로 등록하는 방법은 이와 같이 빈으로 등록하여 설정한다. basename을 통해 설정 파일의 이름을 지정하면, messages.propertieserrors.properties의 파일을 읽어서 사용한다.
국제화 파일은 messages_en.properties와 같이 언어정보를 파일명 마지막에 추가해주면 된다.
스프링 부트에서는 이를 자동화하여, 우리가 굳이 MessageSource를 만들어 스프링 빈으로 등록하지 않더라도 스프링 부트에서 자동으로 등록한다. 때문에messages.propertiesmessages_en.properties 파일은 자동으로 인식된다.
spring: messages: basename: message, config.i18n.messgaes
YAML
복사
스프링 부트에서 메세지 소스를 추가적으로 설정하는 방법은 위와 같이 application.yml 파일에 등록하여 설정할 수 있다.
스프링 메세지 소스를 사용하는 방법은
# messages.properties hello=안녕 hello.name=안녕 {0}
YAML
복사
# messages_en.properties hello=hello hello.name=hello
YAML
복사
이와 같이 properties 파일에 메세지를 추가해두고,
@SpringBootTest public class MessageSourceTest { @Autowired MessageSource ms; @Test void helloMessage() { // getMessage(String code, String args, String locale) String result = ms.getMessage("hello", null, null); assertThat(result).isEqualTo("안녕"); } }
Java
복사
이와 같이 MessageSource를 주입 받아서 사용하면 된다.
찾는 메세지가 없는 경우에는 NoSuchMessageException이 발생하는데,
String result = ms.getMessage("no_code", null, "기본 메시지", null);
Java
복사
이와 같이 기본 메세지(default message)를 넣어 없는 경우 기본 메세지를 반환하도록 할 수 있다.
String result = ms.getMessage("hello", null, Locale.ENGLISH); assertThat(result).isEqualTo("hello");
Java
복사
국제화의 경우, 이와 같이 Locale 정보를 넘겨주면 해당 properties 파일을 찾아 데이터를 가져온다.

타임리프의 메세지, 국제화

<div class="py-5 text-center"> <h2 th:text="#{page.addItem}">상품 등록</h2> </div> ... <div> <label for="price" th:text="#{label.item.price}">가격</label> <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요"> </div>
HTML
복사
메세지를 properties 파일에 추가해두면, 타임리프에서 #{...} 문법을 통해 위와 같이 스프링의 메세지를 간편하게 조회할 수 있다.
국제화의 경우, 위와 같이 타임리프의 #{...} 문법으로 메세지를 적용해두면 해당 언어에 대한 propreties 파일이 존재한다면 타임리프에서 자동으로 locale에 맞춰 적용해준다.
public interface LocaleResolver { Locale resolveLocale(HttpServletRequest request); void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale); }
Java
복사
추가적으로 위의 LocaleResolver 인터페이스의 구현체를 변경하여, 쿠키나 세션 기반의 Locale 선택 기능을 사용하여 사용자가 직접 Locale을 선택하도록 만들 수 있다.

검증

웹 애플리케이션은 폼 입력 시 받는 데이터의 유효성을 검증하여, 검증 오류가 발생하면 오류 화면으로 이동하여 사용자에게 어떤 오류가 발생했는지 알려주어야 한다. 이러한 검증을 클라이언트에서 수행하면, 검증을 조작할 수 있어 보안에 취약해진다. 반대로 서버에서만 검증한다면, 즉각적인 사용성이 부족해지는 문제가 있다. 때문에 둘을 적절히 섞어서 사용해야하며, 최종적으로 반드시 서버에서 검증을 수행해야 한다.
이러한 이유로 컨트롤러의 중요한 역할 중 하나는 HTTP 요청을 검증하는 것이다.
이와 같은 처리 과정을 가지는 동작에서,
사용자가 입력한 값에 대해 검증을 수행했을 때 제대로 된 값을 입력받지 못한다면 오류에 대한 내용을 다시 사용자에게 알려주어야 한다.

검증 직접 처리하기

//검증 오류 결과를 보관 Map<String, String> errors = new HashMap<>(); //검증 로직 if (!StringUtils.hasText(item.getItemName())) { errors.put("itemName", "상품 이름은 필수입니다."); } if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) { errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."); } if (item.getQuantity() == null || item.getQuantity() > 9999) { errors.put("quantity", "수량은 최대 9,999 까지 허용합니다."); } //검증에 실패하면 다시 입력 폼으로 if (!errors.isEmpty()) { model.addAttribute("errors", errors); return "validation/v1/addForm"; }
Java
복사
이와 같이 데이터 검증을 직접 수행하고, 그 결과를 다시 model에 담아 리다이렉트 시키는 방식이다.
<div> <label for="itemName" th:text="#{label.item.itemName}">상품명</label> <input type="text" id="itemName" th:field="*{itemName}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="이름을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"> 상품명 오류 </div> </div>
HTML
복사
뷰 템플릿에서 전달 받은 erros 객체를 th:if를 통해 사용자에게 보여주게 된다. 여기서 ?. 문법은 Safe Navigation Operator로 errors가 null인 경우 그대로 null을 반환하고, null이 아니라면 이후의 프로퍼티 접근법을 적용하는 SpringEL 문법이다.
하지만 이 방식에는 뷰 템플릿 내 중복이 많고, Integer에 문자열을 입력하는 등의 타입에 대한 오류 처리가 되지 않는다. 이는 컨트롤러까지 닿지도 못하기 때문에, 400 Bad Requset가 발생하여 오류 페이지로 넘어간다.

BindingResult

스프링이 제공하는 검증 오류 처리 방법으로는 BindingResult가 있다.
@PostMapping("/add") public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { if (!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다.")); } if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) { bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.")); } if (item.getQuantity() == null || item.getQuantity() >= 10000) { bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다.")); } //특정 필드 예외가 아닌 전체 예외 if (item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if (resultPrice < 10000) { bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } } ... }
Java
복사
이와 같이 파라미터를 통해 BindingResult를 받으면, 기존의 오류에 대해 처리했던 동작들을 대체하여 사용할 수 있다. 중요한 것은 BindingResult는 객체와 연관된 오류를 담고 있기 때문에, @ModelAtrribute 애노테이션이 붙은 파라미터의 바로 다음에 위치해야 한다는 것이다.
연관된 객체의 필드에 오류가 있다면 FieldError 객체를 생성해서 오류에 대한 내용을 담아두면 된다.
FieldError의 경우 시그니처가 다음과 같다.
public FieldError(String objectName, String field, String defualtMessage)
Java
복사
objectName : @ModelAttribute 이름
field : 오류가 발생한 필드 이름
defaultMessage : 오류 메세지
특정 필드가 아닌 글로벌 오류에 대한 처리는 ObjectError 객체를 생성하여 처리할 수 있다.
public ObjectError(String objectName, String defaultMessgae)
Java
복사
objectName : @ModelAttribute 이름
defaultMessage : 오류 메세지
BindingResult로 처리한 오류를 뷰 템플릿에서는
<div th:if="${#fields.hasGlobalErrors()}"> <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p> </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" placeholder="이름을 입력하세요"> <div class="field-error" th:errors="*{itemName}"> 상품명 오류 </div> </div>
HTML
복사
이와 같이 #fields를 통해 BindingResult의 검증 오류에 접근할 수 있고, th:if의 편의 버전인 th:errors를 통해 해당 필드에 오류가 있다면 태그를 출력하도록 할 수 있다. 또한 th:errorclass를 통해 지정한 필드에 오류가 있다면 class 정보를 추가하는 속성도 부여할 수 있다.
추가적으로 BindingResult를 사용하는 경우에 데이터 바인딩 오류가 발생해도 400 에러가 발생하는 것이 아니라 컨트롤러가 호출된다. 컨트롤러가 호출될 때는 스프링이 FieldError를 생성하여 BindingResult에 발생한 오류 정보를 담아준다.
위에서도 언급했지만, 이와 같이 필드에서 발생하는 오류들에 대한 정보도 담아주기 때문에 BindingResult는 항상 @ModelAttribute 파라미터의 다음에 있어야 한다.
추가적으로 Model을 통해 넘어온 데이터를 그대로 유지하고 싶다면,
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nuallable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
Java
복사
위 필드 생성자를 사용하여 넣어줄 수 있다.
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류와 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메세지 코드
arguments : 메세지에서 사용하는 인자
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")`
Java
복사
이와 같이 item 객체에 들어있는 값을 꺼내어 model에서 넘어온 데이터를 BindingResult에 다시 넘겨줌으로써 데이터를 유지할 수 있다.
이러한 오류 문구들을 메세지 기능과 국제화 기능을 적용할 수 있다.
required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
YAML
복사
이처럼 errors.properties를 생성한 후,
spring: messgaes: basename: messages, errors
YAML
복사
이와 같이 basename 설정에 만든 errors.properties 파일을 추가한다.
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)); } }
Java
복사
그 후 위의 FieldError 생성자의 파라미터 중 codesargumentes에 메세지와 해당 메세지에 들어가는 인자를 전달하면 처리된다.

rejectValue, reject

하지만 위 FieldError와 ObjectError 생성자를 호출하는 방식은 코드가 너무 길고 번거롭다. BindingResult의 경우 @ModelAttribute 파라미터 바로 다음에 오는 이유가, 바인딩 되는 객체가 어떤 객체인지를 명확히 인지 시키기 위함이라고 언급했었다. 이를 활용하여 더 적은 파라미터만 넘기는 rejectValue()reject()를 통해 코드를 간결하게 만들 수 있다.
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); } } ...
Java
복사
이와 같이 필드명, error 위치, 인자로 넘기는 값만을 전달하여 간결하게 정리할 수 있다. rejectValue와 reject의 시그니처는 다음과 같다.
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage); void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
Java
복사
field : 오류 필드명
errorCode : 오류 코드
errorArgs : 오류 메세지의 파라미터 전달 값
defaultMessage : 오류 메세지를 못 찾는 경우 기본 메세지
위 코드에서 errorCode 부분에 requried.item.itemName이 아니라 required만 입력한 것을 볼 수 있다.
# Level 2 required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1} # Level 1 required=필수 값 입니다. range=범위는 {0} ~ {1} 까지 허용합니다. max=최대 {0}까지 허용합니다.
YAML
복사
이는 스프링이 MessageCodesResovler를 통해 지원하는 기능으로, 위와 같이 errors.properties에 여러 값들을 정의해두면 스프링이 더 구체적인 항목부터 선택하여 사용한다.
rejectValue와 reject 모두 내부에서 MessageCodeResolver를 사용하는데, 오류 코드를 여러 개를 가질 수 있기 때문에 내부 규칙에 따라 오류 코드를 생성해 보관한다. MessageCodeResolver의 메세지 생성 규칙은 다음과 같다.
객체 오류 1. code + "." + object name 2. code ex) 1. required.item 2. required 필드 오류 1. code + "." + object name + "." + field 2. code + "." + field 3. code + "." + field type 4. code ex) 1. typeMismatch.user.age 2. typeMismatch.age 3. typeMismatch.int 4. typeMismatch
Plain Text
복사
객체 오류의 예시로 reject("totalPriceMin")를 호출하면 다음과 같은 2개의 오류 코드가 생성된다.
1. totalPriceMin.item 2. totalPrice
Plain Text
복사
필드 오류의 예를 들면 rejectValue('itemName", "required")를 호출하면 다음과 같은 4개의 오류 코드를 생성한다.
1. required.item.itemName 2. required.itemName 3. required.java.lang.String 4. required
Plain Text
복사
이렇게 생성된 오류 코드를 가지고, errors.properties에서 오류 메세지를 찾아 출력하게 되는 것이다.
위 내용을 보면, 구체적인 오류 코드를 먼저 생성하고 덜 구체적일수록 나중에 생성한다. 이러한 순서로 오류 코드들을 넘기기 때문에, 오류 메세지를 선택할 때도 그에 맞춰 더 자세한 오류 메세지와 덜 자세한 오류 메세지를 나누는 방식으로 화면마다 다른 오류 메세지를 출력할 수 있다.
추가적으로 VaildationUtils를 사용하여 공백이나 null과 같은 단순한 검증은 더 간편하게 사용할 수 있다.
if (!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다."); } // ValidationUtils 적용 ValidationUtils.rejectIfEmptyOrWhiteSpace(bindingResult, "itemName", "required");
Java
복사
위 방식을 활용하여 스프링이 직접 검증 오류를 수행한 결과에 대해서도 처리할 수 있다. Integer 타입인 price에 문자를 입력하게 되면 BindingResult에 오류 내용이 들어있다. 그 내용 중 오류 코드를 살펴보면,
codes[typeMismatch.item.price, typeMismatch.price, typeMismatch.java.lang.Integer, typeMismatch]
Java
복사
이와 같이 들어있다. 때문에 이에 대한 오류 메세지를 다음과 같이 errors.properties에 추가해주면 해당 오류에 우리가 원하는 메세지를 출력하도록 설정할 수 있다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다.
YAML
복사

Validator 분리하기

위 코드들을 살펴보면 컨트롤러 내에서 검증 로직이 차지하는 부분이 상당하다. 이를 Validator 인터페이스를 통해 분리하고 추가적으로 재사용이 가능하도록 만들 수 있다.
@Component public class ItemValidator implements Validator { @Override public boolean supports(Class<?> clazz) { return Item.class.isAssignableFrom(clazz); } @Override public void validate(Object target, Errors errors) { Item item = (Item) target; ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required"); if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) { errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null); } if (item.getQuantity() == null || item.getQuantity() > 10000) { errors.rejectValue("quantity", "max", new Object[]{9999}, null); } //특정 필드 예외가 아닌 전체 예외 if (item.getPrice() != null && item.getQuantity() != null) { int resultPrice = item.getPrice() * item.getQuantity(); if (resultPrice < 10000) { errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null); } } }
Java
복사
이와 같이 Validator를 구현하는 클래스를 생성하여 그 안에 검증 로직을 넣으면,
private final ItemValidator itemValidator; @PostMapping("/add") public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { itemValidator.validate(item, bindingResult); if (bindingResult.hasErrors()) { log.info("errors={}", bindingResult); return "validation/v2/addForm"; } ... }
Java
복사
이렇게 의존성을 주입받아 검증 로직을 간편하게 호출하여 사용할 수 있다.
이처럼 직접 의존성을 주입받아 호출하여 사용하는 것도 가능하지만, Validator 인터페이스를 구현하면 스프링에서는 애노테이션을 통해 간단하게 검증할 수 있는 기능을 제공한다.
@InitBinder public void init(WebDataBinder dataBinder) { dataBinder.addValidators(itemValidator); } ... @PostMapping("/add") public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { log.info("errors={}", bindingResult); return "validation/v2/addForm"; } ... }
Java
복사
이와 같이 @InitBinder를 통해 검증기를 추가한 후, @Validated 애노테이션을 검증 객체에 달아주면 자동으로 검증을 호출한다. 이 과정에서 아까 Validator 인터페이스를 구현한 support()가 호출되어, 적합한 검증기를 찾는 과정을 거치게 된다.
이러한 검증기는 해당 컨트롤러에서만 동작하지만, 추가적으로 글로벌 설정을 통해 모든 컨트롤러에 일괄적으로 적용하는 것도 가능하다.
@SpringBootApplication public class ItemServiceApplication implements WebMvcConfigurer { public static void main(String[] args) { SpringApplication.run(ItemServiceApplication.class, args); } @Override public Validator getValidator() { return new ItemValidator(); } }
Java
복사
하지만 Vaildator 글로벌 설정을 추가하면 BeanValidator가 자동 등록이 되지 않는 단점이 있다.

BeanValidator

검증 기능을 매 기능마다 위와 같이 코드로 작성하는 것은 상당히 번거로운 일이다. 때문에 스프링에는 검증 로직을 공통화하고 표준화하여 편하게 수행할 수 있도록 BeanValidator를 제공한다.
public class Item { private Long id; @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 10000000) private Integer price; @NotNull @Max(9999) private Integer quantity; ... }
Java
복사
BeanValidator는 위와 같이 애노테이션 하나로 검증 로직을 간단하게 적용할 수 있다.
NotBlank : 빈값, 공백만 있는 것을 허용하지 않음
NotNull : null을 허용하지 않음
Range(min = 100, max = 1000) : 범위 안의 값이어야 함
Max(9999) : 최대 9999까지만 허용
BeanValidator에는 jakarata.validation이 있고 hibernate-validator가 있는데, jakarta는 인터페이스로 어떤 구현체를 사용하여도 해당 기능을 쓸 수 있고 hibernate는 해당 구현체를 사용해야만 사용할 수 있는 기능을 의미한다.
이와 같이 BeanValidator 조건을 추가했다면,
@PostMapping("/add") public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { log.info("errors={}", bindingResult); return "validation/v3/addForm"; } ... }
Java
복사
기존의 InitBinder 코드를 삭제하고 위와 같이 @Validated 애노테이션만 붙어있으면 해당 요청 발생 시 자동으로 스프링에서 검증 후 처리하여 BindingResult에 담아준다.
BeanValidator는 다음과 같은 순서로 검증을 수행한다.
1.
@ModelAttribute 각각의 필드에 타입 변환 시도
a.
성공 시 다음으로
b.
실패 시 typeMismatch로 FieldError 추가
2.
Validator 적용 및 검증
검증 과정에서 1번에서 실패한 필드는 BeanValidator(2번) 검증을 수행하지 않는다. 예를 들면 Integer 타입인 price에 문자열을 넣으면, 타입 변환에 실패하고 이후 price 필드는 BeanVaildation을 적용하지 않는다.
BeanValidator에서 발생한 오류 코드도 BindingResult에 담겨서 전달된다. 이를 활용해, 특정 BeanValidation에서 실패했을 때의 에러 메세지를 지정할 수 있다.
@NotBlank 1. NotBlank.item.itemName 2. NotBlank.itemName 3. NotBlank.java.lang.String 4. NotBlank @Range 1. Range.item.price 2. Range.price 3. Range.java.lang.Integer 4. Range
Java
복사
BeanValidatior 중 일부분만 적었지만, 이와 같은 오류 코드들을 반환하기 때문에 다음과 같이 설정하여 오류 메세지를 지정할 수 있다.
NotBlank={0} 공백X Range={0}, {2} ~ {1} 허용 Max={0}, 최대 {1}
YAML
복사
전달받는 인자의 수는 각 애노테이션마다 다르니 확인하고 사용해야 한다.
필드 오류가 아닌 오브젝트 오류의 경우에는 다음과 같이 처리할 수 있다.
@Data @ScriptAssert(lang = "javascript". script = "_this.price * _this.quantity >= 10000") public class Item { ... }
Java
복사
여기에서 발생하는 오류 코드는 다음과 같다.
1. ScriptAssert.item 2. ScriptAssert
Java
복사
하지만 위 애노테이션은 제약이 많고 사용하기에 복잡하다. 대응하기 어려운 경우도 발생하기 때문에, 억지로 @ScriptAssert를 사용하기보다 위에서 했듯이 Java 코드로 직접 처리하는 것이 권장된다.
이런 BeanValidator도 한계점이 있다. 데이터를 등록하는 로직과 수정하는 로직에서 서로 다른 검증을 요하는 경우에 문제가 될 수 있다. 데이터를 받는 객체는 Item으로 동일한데 서로 다른 검증을 수행하는 방법은 2가지가 있다.
1.
groups 사용하기
public interface SaveCheck { } public interface UpdateCheck { }
Java
복사
이와 같이 서로 다른 마커 인터페이스를 2개 만들어두고,
@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; ... }
Java
복사
이와 같이 groups를 사용하여 각 마커 인터페이스를 분리하여 사용하는 방식이다.
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, @BindingResult bindingResult, RedirectAttributes redirectAttributes) { ... }
Java
복사
public String editItem(@Validated(UpdateCheck.class) @ModelAttribute Item item, @BindingResult bindingResult, RedirectAttributes redirectAttributes) { ... }
Java
복사
사용하는 곳에서 스프링의 @Validated 애노테이션으로 검증을 수행하고자 하는 마커 인터페이스를 넘겨 해당 검증을 수행한다. 참고로 java 표준인 @Valid 애노테이션에는 해당 기능이 없다.
하지만 위 기능은 보이는 것처럼 번거롭고 복잡도가 높아 잘 사용되지 않고, 다음에 나오는 DTO를 분리하여 사용하는 방식으로 자주 사용된다.
2.
DTO 분리하기
실무에서 API 요청의 데이터가 도메인 객체와 정확하게 들어 맞는 경우는 거의 없다. 또한 보안적인 측면에서도 외부에 도메인 객체를 공개하는 것은 좋지 않기 때문에, 일반적으로 컨트롤러까지 전달할 별도의 객체를 만들어서 사용한다.
추가적으로 각 API 요청에 맞는 별도의 객체를 만들어서 사용하면, 필요한 검증 로직을 각 요청에 맞춰 수행할 수 있게 된다.
@Data public class ItemSaveForm { @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; @NotNull @Max(value = 9999) private Integer quantity; } @Data public class ItemEditForm { @NotNull private Long id; @NotBlank private String itemName; @NotNull @Range(min = 1000, max = 1000000) private Integer price; @NotNull private Integer quantity; }
Java
복사
이와 같이 별개의 데이터 전달 객체(DTO)를 두고, 각 요청에 필요한 BeanValidator를 추가한다.
@PostMapping("/add") public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) { ... }
Java
복사
이와 같이 @ModelAttribute와 함께 만든 DTO를 통해 데이터를 받으면, 검증을 수행하고 관련된 오류를 BindingResult에 저장한다.
REST API 컨트롤러에서의 @RequestBody로 전달 받는 값은 조금 다르게 동작한다.
@RestContorller @RequestMapping("/validation/api/items") public class ValidationItemApiController { @PostMapping("/add") public Object addItem(@Validated @RequestBody ItemSaveForm form, BindingResult bindingResult) { if (bindingResult.hasError()) { return bindingResult.getAllErrors(); } return form; } }
Java
복사
이와 같은 REST 컨트롤러에 JSON 데이터를 요청 보내는 경우 다음과 같이 나뉘게 된다.
1.
요청 성공 : 정상 동작
2.
요청 실패 : JSON 데이터를 객체로 변환하는 것 자체를 실패
3.
검증 오류 요청 : JSON 데이터를 객체로 변환에 성공, 검증에서 실패
@ModelAttribute의 경우 생성자를 통해 만든 객체를 각 필드 단위로 값을 바인딩하는데, 그 과정에서 특정 필드에 에러가 발생해도 다른 필드는 정상적으로 바인딩되고 Validator 검증이 수행된다.
반면 @RequsetBody의 경우 HttpMessageConverter에서 JSON 데이터를 받아 직렬화를 통해 객체로 변환하는데, Integer 타입에 문자가 들어있는 것과 같이 데이터 자체가 잘못 되어있으면 객체 변환 자체를 실패해 400 Bad Request가 발생하고 컨트롤러 호출도 되지 않으며 Validator도 동작하지 않는다.