-
[Spring Boot] 검증 요구사항 ( fieldError, rejectValue() ..)Back-End/Spring Boot 2022. 10. 28. 10:46
웹 서비스는 폼 입력 시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려줘야한다. 그러기 위해서 검증이 필요한 것!
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 검증 로직을 잘 개발하는 것이 더 어려울 수 있다.
고객이 만약 상품 등록 폼에서 상품명을 입력하지 않거나, 가격 , 수량 등 검증 범위를 넘어서면 서버 검증 로직이 실패해야한다. 이렇게 검증이 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려줘야한다.
스프 검증에서 사용하는 주요 인터페이스와 클래스 등 점진적으로 하나씩 살펴보며 점점 나은 선택지를 적어보려고 한다. 가장 많이 사용하고 편리한 방법은 마지막 6번에 있으니 그것만 참고해도 된다.
📑 1. BindingResult - addError()
BindingResult 파라미터의 위치는 @ModelAttribute Item 다음에 와야한다.
<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //@PostMapping("/add") public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //검증 로직 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()>=9999){ bindingResult.addError(new FieldError("item","quantity","수량은 최대 9,9999까지 허용합니다.")); } //특정 필드가 아닌 복합 룰 검증 if(item.getPrice()!=null && item.getQuantity()!=null){ int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice<10000){ bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } } //검증에 실패하면 다시 입력 폼으로 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}"; }
📌 FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
adddError라는 메서드를 통해 bindingResult에 해당하는 objectName, field, defaultMessage를 담는다.
objectName은 @ModelAttribute이름, field는 오류가 발생한 필드 이름, defaulstMessage는 오류 기본 메시지를 의미한다.
📌 ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해 bindingResult에 담아두면 된다.
<상품 추가 form>
<div class="py-5 text-center"> <h2 th:text="#{page.addItem}">상품 등록</h2> </div> <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="itemName" th:text="#{label.item.itemName}">상품명</label> <!--/*text입력하는 박스도 빨갛게 하기 위해 th:class 코드 추가. <input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" class="form-control"> */--> <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> <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" placeholder="가격을 입력하세요"> <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" placeholder="수량을 입력하세요"> <div class="field-error" th:errors="*{quantity}" >수량 오류</div> </div>
타임리프는 스프링의 BindingResult를 활용해 편리하게 검증 오류를 표현하는 기능을 제공한다.
📌 #fields : #fileds로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
📌 th:errors : 해당 필드에 오류가 있는 경우의 태그를 출력한다. th:if의 편의 버전이다.
📌 th:errorclass : th:field에서 저장한 필드에 오류가 있으면 class 정보를 추가한다.아래와 같이 .filed-error 클래스에서 지정한 CSS 속성 값을 input 태그인 박스에도 적용하고, 에러가 발생했을 경우 항목 바로 밑에 빨간 글씨로 에러를 표시한다.
📑 2. BindingResult - 오류 데이터 남기기
스프링이 제공하는 검증 오류를 보관하는 객체인 BindingResult bindingResult를 사용하면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
이게 무슨말이지? 아래에 추가 설명을 하려고할 때 예를 하나 들어보자면 현재 Integer 타입의 가격 폼에 문자를 입력해 바인딩 시 타입 오류가 발생하면 400오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
그러나 BindResult를 사용하면 오류 정보(FiledError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 아래 사진과 같이 오류페이지로 넘어가는 것이 아닌 오류 메시지가 form에 뜨게 되는 것이다.
현재는 form에 데이터를 입력하고 저장 버튼을 눌렀을 때 그 데이터가 검증에서 걸러졌다면 form에 입력한 내용은 사라지게 된다. 그럼 내가 어떤 값을 입력했던건지 사용자는 헷갈리게 되므로 사용자 입력 오류 메시지가 화면에 남도록 해보려고 한다.
<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //에러났을 때 입력한 내용 기억하기 //@PostMapping("/add") public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //검증 로직 if(!StringUtils.hasText(item.getItemName())) { bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다.")); bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품 이름은 필수입니다.")); } if(item.getPrice() ==null || item.getPrice()<1000 || item.getPrice()>1000000){ bindingResult.addError(new FieldError("item","price", item.getPrice(), false, null, null, "가격은 1,000원 ~ 1,000,000원까지 허용합니다.")); } if(item.getQuantity() ==null || item.getQuantity()>=9999){ bindingResult.addError(new FieldError("item","quantity", item.getQuantity(), false, null, null, "수량은 최대 9,9999까지 허용합니다.")); } //특정 필드가 아닌 복합 룰 검증 if(item.getPrice()!=null && item.getQuantity()!=null){ int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice<10000){ bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } } //검증에 실패하면 다시 입력 폼으로 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}"; }
FieldError의 생성자는 2가지가 있다.
📌 public FieldError(String objectName, String field, String defaultMessage);
📌 public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- codes : 메세지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
사용자의 입력 데이터가 컨트롤러의 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 예를 들어 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생할 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력한다. FiledError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectValue가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다. bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용한다.
form인 타임리프에서 사용자 입력값을 유지하는데 이는 th:field="*{price}" 의 역할이다. 정상 상황에는 모델 객체 값을 사용하지만, 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다. 타입 오류로 인해 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
📑 3. 오류 코드와 메시지 처리 - properties 메시지 설정
위에서 살펴봤듯이 FiledError는 두 가지 생성자를 제공하는데 오류 코드와 메시지를 처리하기 위해서 중요한 파라미터는 codes와 arguments이다.
codes는 메시지 코드, arguments는 메시지에서 사용하는 인자를 뜻한다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위한 내용이다. 이게 무슨말인지 아래 예제로 살펴보자!
🎃 application.properties에 스프링부트 메시지 설정을 추가해준다.
spring.messages.basename=messages,errors
#spring.messages.encoding=UTF-8🎃 error 메시지 파일을 생성하기 위해 errors.properties라는 별도의 파일을 생성한다.
#에러코드.오브젝트명.필드명
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //메세지 국제화 이용해서 메시지 내용 활용, 오류를 코드화! //@PostMapping("/add") public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { log.info("objectName ={}", bindingResult.getObjectName()); log.info("target={}",bindingResult.getTarget()); //검증 로직 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()>=9999){ 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"} ,null,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}"; }
위의 코드 중 가격 부분을 살펴보면 살펴보면 다음과 같다.
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
bindingResult.addError(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}로 치환할 값을 전달한다.
📑 4. 오류 코드와 메시지 처리 2 - 간편화 rejectValue(), reject()
FieldError와 ObjectError에 파라미터 값으로 메시지를 다룰 수 있지만 아직 번거롭다. 여기서 중점은 컨트롤러에서 BindingResult는 검증해야 할 객체인 target 바로 다음에 온다. 컨트롤러 메서드명 (@ModelAttribute Item item, BindingResult bindingResult) .. 따라서 BindingResult는 이미 본인이 검증해야할 객체인 target을 알고 있다.
그렇기에 BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //rejectValue //@PostMapping("/add") public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //검증에 실패하면 다시 입력 폼으로 if(bindingResult.hasErrors()){ log.info("errors ={}", bindingResult); return "validation/v2/addForm"; } 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()>=9999){ 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"; }
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 사용한다.
📑 5. 오류 코드와 메시지 처리 3 - 메시지 단계 설정
오류 코드를 만들 때 required.item.itemName : 상품 이름은 필수 입니다와 같이 자세히 만들 수도 있고 required : 필수값 입니다. range : 범위 오류입니다와 같이 단순하게 만들 수도 있다.
단순하게 만들면 범용성이 좋아 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다. 따라서 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우는 세밀한 내용이 적용되도록 메시지에 단계를 두는 것이다.
만약 아래와 같이 오류 필드명이 required로 같을 때 자세한 것이 우선순위가 1번이 되는 것이다.
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.DefaultMessageCodesResolver의 기본 메시지 생성 규칙
🎃 객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
ex) 오류 코드: required, object name: item
1.: required.item
2.: required🎃 필드 오류
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
ex) 오류 코드: 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를 통해 생성된 순서대로 오류를 보관한다.
codes [range.item.price, range.price, range.java.lang.Integer, range]
여기서 첫 번째 요소부터 일치히는 에러 코드 설정이 있다면 메시지를 꺼내는 것.
모든 오류 코드에 대해 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다. 크게 중요하지 않은 메시지는 범용성 있는 required와 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어 사용하는 방식이 더 효과적이다.
#required.item.itemName=상품 이름은 필수입니다. #range.item.price=가격은 {0} ~ {1} 까지 허용합니다. #max.item.quantity=수량은 최대 {0} 까지 허용합니다. #totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1} #==ObjectError== #Level1 totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1} #Level2 - 생략 totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1} #==FieldError== #Level1 required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. #Level2 - 생략 #Level3 required.java.lang.String = 필수 문자입니다. required.java.lang.Integer = 필수 숫자입니다. min.java.lang.String = {0} 이상의 문자를 입력해주세요. min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요. range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요. range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요. max.java.lang.String = {0} 까지의 문자를 허용합니다. max.java.lang.Integer = {0} 까지의 숫자를 허용합니다. #Level4 required = 필수 값 입니다. min= {0} 이상이어야 합니다. range= {0} ~ {1} 범위를 허용합니다. max= {0} 까지 허용합니다.
만약 이렇게 설정되어있을 때 required라는 에러코드가 호출되면 Level1의 "required.item.itemName=상품 이름은 필수입니다. "가 출력된다. 만약 Level1을 주석처리하면 그 다음 단계인 Level3가 출력된다.
📑 6. 오류 코드와 메시지 처리 4 - 타입 정보 맞지 않음
검증 오류 코드는 2가지로 나눌 수 있다.
1. 개발자가 집적 설정한 오류 코드 -> rejectValue()를 직접 호출 ex) required.item.itemName
2. 스프링이 직접 검증 오류에 추가한 경우 -> 주로 타입 정보가 맞지 않는 TypeMismatch
만약 price에 문자를 입력해보고 로그를 확인해보면 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
이렇게 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용한다. 현재는 따로 정해둔 기본 메시지가 없어 아래 사진에 보이는 것처럼 Failed to convert ~~ 와 같은 문구가 출력된다.
이렇게 출력되는 문자도 개발자 마음대로 바꿀 수 있는데 간단하게 error.properties에 값을 추가하면 된다.
#추가 typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다📑 7. Validator 분리
컨트롤러에서 검증 로직이 차지하는 부분은 매우 큰데, 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다.
<별도 클래스>
package hello.itemservice.web.validation; import hello.itemservice.domain.item.Item; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.validation.Errors; import org.springframework.validation.Validator; @Component //스프링 빈에 등록한다. public class ItemValidator implements Validator { @Override public boolean supports(Class<?> clazz) { return Item.class.isAssignableFrom(clazz); //item==clazz //item==subItem } @Override public void validate(Object target, Errors errors) { Item item = (Item) target; //검증 로직 if(!StringUtils.hasText(item.getItemName())) { errors.rejectValue("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()>=9999){ 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); } } } }
스프링은 검증을 체계적으로 제공하기 위해 Validator라는 인터페이스를 제공한다.
📌 supports() { } : 해당 검증기를 지원하는 여부 확인
📌 validate (Object target, Errors errors) : 검증 대상 객체와 BindingResult
<컨트롤러>
//실제 데이터 저장이 일어나는 컨트롤러 //컨트롤러에서 검증 작업 로직 분리 // @PostMapping("/add") public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { itemValidator.validate(item,bindingResult); //검증에 실패하면 다시 입력 폼으로 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}"; }
반응형'Back-End > Spring Boot' 카테고리의 다른 글
[Spring Boot] 필터와 인터셉터 차이, 로그인 인증 체크하기 (0) 2022.11.02 [Spring Boot] Bean Validation , 한계와 객체 분리 (0) 2022.11.01 [Spring Boot] 메시지, 국제화 파일 생성 후 타임리프에 적용해보기 (0) 2022.10.26 [Spring Boot] 타임리프가 지원하는 form 속성 기능 (check box, radio button, select box) (0) 2022.10.25 [Spring Boot] thymeleaf, 타임리프 기본기능 - 2편 (0) 2022.10.24