김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
텍스트 출력 : th:text
변수 표현식 : ${모델명.속성}
반복 : th:each="반복변수 : ${모델명}"
메세지 표현식 : #{...}
리터럴 대체 : | ... |
링크 표현식 : @{...}
@{/hello(param1=${param1})}
➜ /hello?param1=data1
( 쿼리 파라미터 )
@{/hello/{param1}(param1=${param1})}
➜ /hello/data1
( 경로 변수 )
속성 추가
th:attrappend="class=' large'"
: class 속성 뒤에 large 추가
th:attrprepend="class='large '"
: class 속성 앞에 large 추가
th:classappend="large"
: class 속성에 large 추가
th:object
: <form>
에서 사용할 객체를 지정
th:object = ${모델명}
*{...}
: th:object
에서 선택한 객체의 속성에 접근할 때 사용
th:object = ${item}
이면 *{itemName}
= ${item.itemName}
th:field
: HTML 태그의 id , name , value 속성을 지정한 값으로 자동 처리
th:field = *{itemName}
이면 현재 태그의 id, name, value 속성이 itemName 으로 처리된다메세지 기능 : 화면에 보이는 문구 혹은 메세지를 한 곳에서 관리하는 기능
국제화 기능 : 메세지 파일의 이름을 다르게 하여 각 나라 별로 관리
ex> messages_ko.properties / messages_en.properties 가 있을 때
locale 정보로 KOREA 가 넘어오면 _ko 가, ENGLISH 가 넘어오면 _en 파일이 사용된다
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("messages", "errors");
messageSource.setDefaultEncoding("utf-8");
return messageSource;
}
메세지 기능을 사용하기 위해서는 MessageSource 를 스프링 빈으로 등록해야한다
setBasenames()
: 설정 파일( 메세지 파일 )의 이름을 지정
< applicatin.properties > spring.messages.basename=
스프링부트를 사용하면 자동으로 MessageSource 를 스프링 빈으로 등록해준다
이 때 위에서 setBasenames()
로 설정 파일 이름을 지정한 것처럼 application.properties 에 설정 파일의 이름을 지정할 수 있다
별도의 설정을 하지 않으면 messages
라는 설정 파일의 이름이 등록된다
messagesXXX.properties
파일만 등록하면 자동으로 인식된다< messages.properties >
hello=안녕
hello.name=안녕 {0}
messages 파일 내부에는 key, value 형식으로 작성한다
getMessage()
를 통해 key 값이 넘어오면 value 를 반환한다
중괄호가 붙은 것은 매개변수가 전달되는 부분이다
@Test
void helloMessage() {
String result1 = ms.getMessage("hello", null, null);
assertThat(result1).isEqualTo("안녕");
String result2 = ms.getMessage("no_code", null, "기본 메세지",null);
assertThat(result2).isEqualTo("기본 메세지");
String result3 = ms.getMessage("hello.name", new Object[]{"Test", "Spring"}, null);
assertThat(result3).isEqualTo("안녕 Test");
}
getMessage(String code, Object[] args, Locale locale)
locale 정보가 없으면 basename 에서 설정한 이름의 메세지 파일을 조회한다
getMessage(String code, Object[] args, String defaultMessage, Locale locale);
메세지 파일에 code 에 해당하는 key 값이 존재하지 않는 경우 디폴트 메세지를 지정해서 출력할 수 있다
args 에 배열을 넘기면 messages 에 {0} 인 부분에 값이 전달되어 치환된다
즉, 전달한 배열의 {0} 가 "Test" 이기 때문에 안녕 {0} ➜ 안녕 Test 로 치환된다
@Test
void dafaultLang() {
assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}
메세지 파일은 messages.properties / messages_en.properties 만 있다고 가정
Locale 이 null 인 경우, Locale을 KOREA로 지정한 경우 모두 디폴트인 messages.properties 에서 데이터를 조회
Locale 을 ENGLISH 로 지정한 경우, messages_en.properties 에서 데이터를 조회
Locale 정보가 없으면 Locale.getDefault()
을 호출해서 시스템의 기본 로케일을 사용한다
locale = null
➜ Locale.getDefault()
➜ messages_ko.properties
조회 ➜ 조회 실패 ➜ messages.properties
조회타임리프에서 #{...}
라는 메세지 표현식을 사용해서 스프링의 메세지를 조회
ex> th:text="#{page.addItem}"
ex> hello.name=안녕 {0}
처럼 파라미터를 사용하는 경우, 치환될 데이터도 같이 전달해야한다
th:text="#{hello.name(${item.itemName})}"
스프링은 언어 선택 시, 기본적으로 HTTP 요청 메세지의 헤더에 있는 Accept-Language 정보를 사용해 Locale 정보를 알아낸다
Locale 선택 방식으로 Accept-Language를 활용할 수도 있고, 사용자가 언어를 선택하도록 하고 사용자의 선택을 쿠키나 세션같은 곳에 저장하여 계속 사용하도록 할 수 있다
스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver
라는 인터페이스를 제공한다
AcceptHeaderLocaleResolver
가 사용된다@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v1/addForm";
}
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
...
}
처음 화면을 보여줄 때 Item 객체를 새로 만들고 model 에 담아서 전달 ( 빈 객체 )
정보를 입력하고 상품 등록 버튼을 누르면 model 의 item 에 입력한 정보가 들어간다
이 때, 입력된 정보가 @ModelAttribute
에 의해 파라미터인 Item 객체에 담긴다
그렇기 때문에 사용자가 입력한 정보를 유지할 수 있다
또한 Item 객체를 통해 사용자가 입력한 값을 검증할 수 있다
이후 페이지를 보여줄 때 @ModelAttribute
에 의해 자동으로 Item 이 model 에 담긴다
model.addAttribute("item", item);
과 같은 기능을 한다price 는 Integer 인데 String 이 들어오면 Controller 에 진입하기 전에 예외가 발생해서 오류 페이지를 출력한다
이렇게 되면 사용자가 입력한 값을 유지해서 화면에 남겨야하는데 String 을 Integer 에 보관할 수 없기 때문에 이 작업이 불가능해진다
➡️타입 오류로 바인딩이 실패하는 경우에도 Controller 가 호출되어야 한다
➡️사용자가 타입에 맞지 않게 잘못 입력해도 유지할 수 있어야한다
public interface BindingResult extends Errors {
void addError(ObjectError error);
}
BindingResult 는 검증 오류를 보관하는 객체이다
addError()
메서드를 통해 bindingResult 객체에 오류를 추가한다
addError()
로 bindingResult 객체에 FieldError 와 ObjectError 를 추가할 수 있다
@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 자동으로 FieldError 생성해서 BindingResult 에 넣어준다
// FieldError
public FieldError(String objectName, String field, String defaultMessage) {
this(objectName, field, null, false, null, null, defaultMessage);
}
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {
...
}
// ObjectError
public ObjectError(String objectName, String defaultMessage) {
this(objectName, null, null, defaultMessage);
}
objectName : @ModelAttribute
로 지정된 검증 객체의 파라미터 이름
rejectedValue : 타입 오류로 바인딩이 실패했을 때 사용자가 입력한 값을 저장
검증 로직을 수행하고 이후 페이지를 보여줄 때 BindingResult 는 저장된 내용은 model 에 담지 않아도 자동으로 view 에 같이 넘어간다
Thymeleaf 에서 bindingResult 를 사용할 수 있도록 #fields
나 th:errors
와 같은 것들이 제공된다
#fields
: bindingResult가 제공하는 검증 오류에 접근할 수 있고, ${#fields}
처럼 변수 표현식과 함께 사용한다
th:errors="*{필드명}"
: 지정된 필드에 오류가 있는 경우 태그를 출력한다
th:field="*{필드명}" th:errorclass="field-error"
: 지정된 필드에 오류가 있는 경우 "field-error"라는 class 정보를 추가
- 타입 오류로 바인딩이 실패하는 경우 Controller 가 호출되지 않는 경우의 문제
- 정수형에 문자가 들어와도 사용자가 입력한 값을 유지할 수 있도록 해야한다
타입 오류가 발생해 @ModelAttribute
에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려운데 이와 같은 상황에서도 사용자가 입력한 값을 유지할 수 있도록 BindingResult와 FieldError 객체를 사용한다
과정을 살펴보면 타입 오류가 발생하면 스프링이 자동으로 FieldError를 생성해서 BindingResult 에 넣어준다
바로 이 때, FieldError의 rejectedValue에 사용자가 입력했던 값을 넣어주기 때문에 바인딩 시점에 타입 오류가 발생해도 사용자가 입력한 값을 유지할 수 있는 것이다
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));
}
오류가 발생했을 떄 보여줄 메세지를 FieldError 를 생성하면서 하나씩 지정하지 않고 메세지 기능을 사용할 수 있다
FieldError 생성자의 codes
와 argument
를 통해 메세지 파일에 지정된 메세지를 출력할 수 있다
codes
: 메세지 파일의 key 에 해당하는 부분 (메세지 코드 )
argument
: 메세지 파일에서 {0} 과 같이 파라미터에 치환될 값
BindingResult 는 @ModelAttribute
뒤에 위치해야한다고 했는데 그로 인해 BindingResult 는 검증 해야 할 객체가 무엇인지 알고 있다
즉, objectName 을 알고 있는 것이기 때문에 FieldError 나 ObjectError 를 생성하면서 objectName 을 넘겨주지 않아도 된다
더 나아가서 bindingResult 에 오류 객체를 담을 때 rejectValue()
나 reject()
를 활용하면 FieldError 나 ObjectError 를 직접 생성하지 않아도 된다
// FieldError 시 사용
void rejectValue(@Nullable String field, String errorCode);
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
//ObjectError 시 사용
void reject(String errorCode);
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
rejectValue()
를 보면 FieldError 와는 다르게 objectName 이 없고 바로 field 가 온다
그 이유는 위에서 설명한대로 bindingResult 는 검증 객체를 알고 있기 때문에 objectName 을 넘겨줄 필요가 없기 때문이다
// 간소화 전 FieldError
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
// 간소화 전 ObjectError
if (item.getPrice() != null && item.getQuantity() != null) {
...
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
// 간소화 후 FieldError
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
// 간소화 후 ObjectError
if (item.getPrice() != null && item.getQuantity() != null) {
...
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
위의 메서드를 보면 errorCode 가 있는데 이것은 메세지 파일의 코드( key ) 가 아니다
MessageCodesResolver
가 메세지 파일의 코드를 생성하기 위한 errorCode 이며, 이를 통해 에러 메세지를 찾게 된다
결론적으로 보자면 MessageCodesResolver
가 errorCode + objectName + 필드명 을 조합해서 메세지 코드를 생성하고, 메세지 파일에서 해당 코드를 찾는다
public interface MessageCodesResolver {
// Used for building the codes list of an ObjectError.
String[] resolveMessageCodes(String errorCode, String objectName);
// Used for building the codes list of an FieldError.
String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType);
}
resolveMessageCodes()
: errorCode를 받으면 여러 개의 메세지 코드를 반환해준다
FieldError 의 경우 4가지, ObjectError 의 경우 2가지의 메세지 코드를 생성해서 String 배열로 반환한다
BindingResult.rejct()
혹은 BindingResult.rejectValue()
가 내부적으로 MesssageCodesResolver 를 사용한다
MessageCodesResolver 는 resolveMessageCodes()
가 반환한 값으로 오류에 따라 아래의 코드를 실행시킨다
new ObjectError("item", new String[]{"required.item", "required"});
new FieldError("item", "itemName", null, false, messageCodes, null, null);
오류가 발생하면 타임리프 화면을 렌더링할 때 th:errors
가 실행된다
th:errors
가 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾게 되고, 찾지 못하면 디폴트 메세지를 출력한다
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) { ... }
@Override
// 검증 로직을 처리하는 부분
public void validate(Object target, Errors errors) { ... }
}
Controller 에 검증 로직과 성공 로직이 둘 다 있기 때문에 Validator 클래스를 만들어 검증 로직을 분리한다
Controller 에서 사용할 수 있도록 @Component
를 붙여 스프링 빈으로 등록되게 한다
사용 방법 : Controller 에서 해당 스프링 빈을 주입받고 validate()
를 호출한다
validate()
target : Controller 에서 @ModelAttribute
가 붙은 객체가 전달
errors : Controller 에서 BindingResult 객체를 전달 받는다
target 을 Object로 받았기 때문에 형 변환이 필요 ( Item item = (Item) target; )
public class ValidationItemControllerV2 {
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult) { ... }
}
Controller 가 호출될 때마다 @InitBinder
가 붙은 메서드가 호출되는데 WebDataBinder
는 항상 새로 만들어진다
WebDataBinder
에 검증기를 추가하면 Controller 의 모든 메서드에서 검증기 사용 가능
@Validated
를 붙이면 validate()
를 호출하지 않아도 자동으로 검증이 수행된다
@Validated
가 붙으면 WebDataBinder 에 등록한 검증기를 찾아서 실행하는데 만약 여러 검증기를 등록한다면 어떤 검증기가 실행되어야 할지 구분이 필요하기 때문에 Validator 인터페이스의 supports()
가 사용된다
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
}
bindingResult.rejectValue()
➜ MessageCodesResolver 가 메세지 코드 생성 ➜ 생성한 메세지 코드를 포함해서 new FieldError(...)
실행 ➜ BindingResult 에 담는다public class ValidationItemControllerV2 {
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) { ... }
}
검증 로직을 분리하여 itemValidator 클래스에 작성
WebDataBinder 에 검증기 추가
@Validated
어노테이션으로 검증 객체 지정하면 자동으로 검증 수행
Bean Validation : 검증 로직을 간편하게 작성하고 모든 프로젝트에 적용할 수 있도록 공통화하고 표준화한 것
Bean Validation 은 @NotBlank
, @NotNull
과 같은 어노테이션을 사용하며, 검증 객체 클래스의 필드에 붙인다
의존관계를 추가하면 스프링부트는 LocalValidatorFactoryBean 을 글로벌 Validator 로 등록하는데 이것은 @NotNull
과 같은 어노테이션을 보고 검증을 수행한다
글로벌 Validator 이기 때문에 검증 대상에 @Valid
나 @Validated
만 적용하면 된다
즉, Controller 에서 Validator 를 구현한 클래스를 주입 받을 필요도 없고, WebDataBinder 에 등록하지 않아도 된다
검증 오류가 발생하면 FieldError, ObjectError 를 생성해서 BindingResult에 담아준다
@ModelAttribute
가 각각의 필드에 타입 변환 시도
실패하면 typeMisMatch
로 FieldError 추가
성공하면 Validator 적용
바인딩에 성공한 필드만 Bean Validation 을 적용해 어노테이션을 통한 검증을 수행한다 ( 값이 정상적으로 들어와야 검증이 의미가 있기 때문에 )
FieldError 객체를 만들 때 어노테이션 이름을 errorCode 로 사용한다
즉, MessageCodesResolver 가 어노테이션 이름을 errorCode 로 사용해 메세지 코드를 만들어낸다
ex> @NotBlank
가 지켜지지 않은 경우 ➜ NotBlank.item.itemName
, NotBlank.item
등과 같이 메세지 코드가 생성
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message="총합이 10000원 넘게 입력해주세요")
public class Item { ... }
검증 대상에 @ScriptAssert
어노테이션을 붙이면 ObjectError 를 처리할 수 있다
생성되는 메세지 코드는 ScriptAssert.item
, ScriptAssert
이다
but> 사용에 제약이 많고, 실제 실무에서는 객체의 범위를 넘어서는 경우가 많은데 이럴 때 대응하기 어렵다
➡️ ObjectError를 처리할 때는 @ScriptAssert
보다 자바 코드로 작성하는 것을 권장
등록할 때와 수정할 때의 검증 요구사항이 다르면 같은 객체에서 검증 조건이 충돌
결과적으로 등록과 수정은 같은 Bean Validation을 적용할 수 없다
해결 방법 1 : Bean Validation 의 groups 기능을 사용
해결 방법 2 : Item 객체를 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용
인터페이스 생성 ( ex> SaveCheck, UpdateCheck )
객체 필드에 어노테이션을 붙일 때 groups 속성에 인터페이스 이름을 지정
등록, 수정 모두에 사용되는 필드 : @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
수정만 사용하는 필드의 경우 : @NotNull(groups = UpdateCheck.class)
Controller 에서 @Validated
를 붙일 때 등록인지, 수정인지 명시
등록 : @Validated(SaveCheck.class)
수정 : @Validated(UpdateCheck.class)
@Validated(SaveCheck.class)
가 붙어있으면 어노테이션에 SaveCheck.class
가 붙은 객체의 필드에 대해서만 유효성 검사를 진행한다Form 을 전달 받는 전용 객체를 만들어 @ModelAttribute
를 붙인다
데이터를 form 에 전달 받고, getXXX() 메서드를 통해 필요한 데이터를 사용해서 도메인 객체를 생성
폼에 데이터 전달에 도메인 객체 사용
@ModelAttribute Item item
HTML Form ➜ 도메인 객체 ➜ Controller ➜ 도메인 객체 ➜ Repository
폼 데이터 전달을 위한 별도의 객체 사용
@ModelAttribute ItemSavdForm form
HTML Form ➜ Form 객체 ➜ Controller ➜ 도메인 객체 생성 ➜ Repository