[Spring MVC 2편] 1번 ~ 5번 강의 내용 정리

HJ·2023년 3월 18일
0

Spring MVC 2편

목록 보기
12/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. Thymeleaf 문법

1-1. 기본 문법

  • 텍스트 출력 : 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 추가


1-2. 폼 문법

  • th:object : <form> 에서 사용할 객체를 지정

    • ex> th:object = ${모델명}
  • *{...} : th:object 에서 선택한 객체의 속성에 접근할 때 사용

    • ex> th:object = ${item} 이면 *{itemName} = ${item.itemName}
  • th:field : HTML 태그의 id , name , value 속성을 지정한 값으로 자동 처리

    • ex> th:field = *{itemName} 이면 현재 태그의 id, name, value 속성이 itemName 으로 처리된다



2. 메세지

  • 메세지 기능 : 화면에 보이는 문구 혹은 메세지를 한 곳에서 관리하는 기능

  • 국제화 기능 : 메세지 파일의 이름을 다르게 하여 각 나라 별로 관리

    • ex> messages_ko.properties / messages_en.properties 가 있을 때

    • locale 정보로 KOREA 가 넘어오면 _ko 가, ENGLISH 가 넘어오면 _en 파일이 사용된다


2-1. MessageSource 수동 등록

@Bean
public MessageSource messageSource() {
	ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
	messageSource.setBasenames("messages", "errors");
	messageSource.setDefaultEncoding("utf-8");
	return messageSource;
}
  • 메세지 기능을 사용하기 위해서는 MessageSource 를 스프링 빈으로 등록해야한다

  • setBasenames() : 설정 파일( 메세지 파일 )의 이름을 지정


2-2. MessageSource 자동 등록

< applicatin.properties >

spring.messages.basename=
  • 스프링부트를 사용하면 자동으로 MessageSource 를 스프링 빈으로 등록해준다

  • 이 때 위에서 setBasenames() 로 설정 파일 이름을 지정한 것처럼 application.properties 에 설정 파일의 이름을 지정할 수 있다

  • 별도의 설정을 하지 않으면 messages 라는 설정 파일의 이름이 등록된다

    • 이 때, messages로 시작하는 messagesXXX.properties 파일만 등록하면 자동으로 인식된다

2-3. 메세지 기능 테스트

2-3-1. 메세지 파일 작성

< messages.properties >

hello=안녕
hello.name=안녕 {0}
  • messages 파일 내부에는 key, value 형식으로 작성한다

  • getMessage() 를 통해 key 값이 넘어오면 value 를 반환한다

  • 중괄호가 붙은 것은 매개변수가 전달되는 부분이다


2-3-2. 메세지 테스트

@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)

    • code 가 메세지 파일의 key 에 해당한다
  • locale 정보가 없으면 basename 에서 설정한 이름의 메세지 파일을 조회한다


  • getMessage(String code, Object[] args, String defaultMessage, Locale locale);

  • 메세지 파일에 code 에 해당하는 key 값이 존재하지 않는 경우 디폴트 메세지를 지정해서 출력할 수 있다


  • args 에 배열을 넘기면 messages 에 {0} 인 부분에 값이 전달되어 치환된다

  • 즉, 전달한 배열의 {0} 가 "Test" 이기 때문에 안녕 {0} ➜ 안녕 Test 로 치환된다


2-3-3. 국제화 테스트

@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 = nullLocale.getDefault()messages_ko.properties 조회 ➜ 조회 실패 ➜ messages.properties 조회

2-4. 웹에 메세지 기능 적용

  • 타임리프에서 #{...} 라는 메세지 표현식을 사용해서 스프링의 메세지를 조회

  • ex> th:text="#{page.addItem}"

    • 기본 메세지 파일인 messages.properties 에서 key 가 page.addItem 인 항목을 찾는다
  • ex> hello.name=안녕 {0} 처럼 파라미터를 사용하는 경우, 치환될 데이터도 같이 전달해야한다

    • th:text="#{hello.name(${item.itemName})}"

2-5. 메세지 국제화 동작 방식

  • 스프링은 언어 선택 시, 기본적으로 HTTP 요청 메세지의 헤더에 있는 Accept-Language 정보를 사용해 Locale 정보를 알아낸다

  • Locale 선택 방식으로 Accept-Language를 활용할 수도 있고, 사용자가 언어를 선택하도록 하고 사용자의 선택을 쿠키나 세션같은 곳에 저장하여 계속 사용하도록 할 수 있다

  • 스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 라는 인터페이스를 제공한다

    • Accept-Language 를 사용하면 AcceptHeaderLocaleResolver 가 사용된다



3. @ModelAttribute

3-1. 데이터 전달 흐름

@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) {
    ...
}
  1. 처음 화면을 보여줄 때 Item 객체를 새로 만들고 model 에 담아서 전달 ( 빈 객체 )

  2. 정보를 입력하고 상품 등록 버튼을 누르면 model 의 item 에 입력한 정보가 들어간다

  3. 이 때, 입력된 정보가 @ModelAttribute 에 의해 파라미터인 Item 객체에 담긴다

    • 그렇기 때문에 사용자가 입력한 정보를 유지할 수 있다

    • 또한 Item 객체를 통해 사용자가 입력한 값을 검증할 수 있다

  4. 이후 페이지를 보여줄 때 @ModelAttribute 에 의해 자동으로 Item 이 model 에 담긴다

    • 즉, model.addAttribute("item", item); 과 같은 기능을 한다

3-2. 남은 문제점

  • price 는 Integer 인데 String 이 들어오면 Controller 에 진입하기 전에 예외가 발생해서 오류 페이지를 출력한다

  • 이렇게 되면 사용자가 입력한 값을 유지해서 화면에 남겨야하는데 String 을 Integer 에 보관할 수 없기 때문에 이 작업이 불가능해진다

  • ➡️타입 오류로 바인딩이 실패하는 경우에도 Controller 가 호출되어야 한다

  • ➡️사용자가 타입에 맞지 않게 잘못 입력해도 유지할 수 있어야한다




4. BindingResult

4-1. 오류 추가

public interface BindingResult extends Errors {
    void addError(ObjectError error);
}
  • BindingResult 는 검증 오류를 보관하는 객체이다

  • addError() 메서드를 통해 bindingResult 객체에 오류를 추가한다

  • addError() 로 bindingResult 객체에 FieldErrorObjectError 를 추가할 수 있다

    • 파라미터를 보면 ObjectError 를 받지만 FieldError 는 ObjectError 를 상속받기 때문에 추가할 수 있다
  • @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 자동으로 FieldError 생성해서 BindingResult 에 넣어준다


4-2. FieldError 와 ObjectError

// 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 : 타입 오류로 바인딩이 실패했을 때 사용자가 입력한 값을 저장

  • FieldError 사용 예시


4-3. Thymeleaf 와 BindingResult

  • 검증 로직을 수행하고 이후 페이지를 보여줄 때 BindingResult 는 저장된 내용은 model 에 담지 않아도 자동으로 view 에 같이 넘어간다

  • Thymeleaf 에서 bindingResult 를 사용할 수 있도록 #fieldsth:errors 와 같은 것들이 제공된다

  • #fields : bindingResult가 제공하는 검증 오류에 접근할 수 있고, ${#fields} 처럼 변수 표현식과 함께 사용한다

  • th:errors="*{필드명}" : 지정된 필드에 오류가 있는 경우 태그를 출력한다

  • th:field="*{필드명}" th:errorclass="field-error" : 지정된 필드에 오류가 있는 경우 "field-error"라는 class 정보를 추가


4-4. 문제 해결

  1. 타입 오류로 바인딩이 실패하는 경우 Controller 가 호출되지 않는 경우의 문제
  • BindingResult를 사용하면 바인딩이 실패하는 경우 오류 정보( FieldError )를 BindingResult에 담아서 Controller를 정상 호출하게 된다

  1. 정수형에 문자가 들어와도 사용자가 입력한 값을 유지할 수 있도록 해야한다
  • 타입 오류가 발생해 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려운데 이와 같은 상황에서도 사용자가 입력한 값을 유지할 수 있도록 BindingResult와 FieldError 객체를 사용한다

  • 과정을 살펴보면 타입 오류가 발생하면 스프링이 자동으로 FieldError를 생성해서 BindingResult 에 넣어준다

  • 바로 이 때, FieldError의 rejectedValue에 사용자가 입력했던 값을 넣어주기 때문에 바인딩 시점에 타입 오류가 발생해도 사용자가 입력한 값을 유지할 수 있는 것이다




5. 오류 코드와 메세지 처리

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 생성자의 codesargument 를 통해 메세지 파일에 지정된 메세지를 출력할 수 있다

    • codes : 메세지 파일의 key 에 해당하는 부분 (메세지 코드 )

    • argument : 메세지 파일에서 {0} 과 같이 파라미터에 치환될 값




6. 코드 간소화

6-1. 배경

  • BindingResult 는 @ModelAttribute 뒤에 위치해야한다고 했는데 그로 인해 BindingResult 는 검증 해야 할 객체가 무엇인지 알고 있다

  • 즉, objectName 을 알고 있는 것이기 때문에 FieldErrorObjectError 를 생성하면서 objectName 을 넘겨주지 않아도 된다

  • 더 나아가서 bindingResult 에 오류 객체를 담을 때 rejectValue()reject() 를 활용하면 FieldErrorObjectError 를 직접 생성하지 않아도 된다


6-2. rejectValue(), reject()

// 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 을 넘겨줄 필요가 없기 때문이다


6-3. 사용 예시

// 간소화 전 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);
}



7. MessageCodesResolver

7-1. 설명

  • 위의 메서드를 보면 errorCode 가 있는데 이것은 메세지 파일의 코드( key ) 가 아니다

  • MessageCodesResolver 가 메세지 파일의 코드를 생성하기 위한 errorCode 이며, 이를 통해 에러 메세지를 찾게 된다

  • 결론적으로 보자면 MessageCodesResolvererrorCode + objectName + 필드명 을 조합해서 메세지 코드를 생성하고, 메세지 파일에서 해당 코드를 찾는다


7-2. 메세지 코드 생성

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 배열로 반환한다


7-3. 오류 객체 생성

  • 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 가 생성된 오류 메세지 코드를 순서대로 돌아가면서 메세지를 찾게 되고, 찾지 못하면 디폴트 메세지를 출력한다




8. Validator

@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; )




9. WebDataBinder

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() 가 사용된다

  • 하나의 컨트롤러에서 여러 개의 모델을 검증하는 경우




10. 검증 과정 정리

10-1. 기본 흐름

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 에 담는다

10-2. 검증 로직 분리

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 어노테이션으로 검증 객체 지정하면 자동으로 검증 수행




11. Bean Validation

11-1. 설명

  • Bean Validation : 검증 로직을 간편하게 작성하고 모든 프로젝트에 적용할 수 있도록 공통화하고 표준화한 것

  • Bean Validation 은 @NotBlank, @NotNull 과 같은 어노테이션을 사용하며, 검증 객체 클래스의 필드에 붙인다

  • 의존관계를 추가하면 스프링부트는 LocalValidatorFactoryBean 을 글로벌 Validator 로 등록하는데 이것은 @NotNull 과 같은 어노테이션을 보고 검증을 수행한다

  • 글로벌 Validator 이기 때문에 검증 대상에 @Valid@Validated 만 적용하면 된다

  • 즉, Controller 에서 Validator 를 구현한 클래스를 주입 받을 필요도 없고, WebDataBinder 에 등록하지 않아도 된다

  • 검증 오류가 발생하면 FieldError, ObjectError 를 생성해서 BindingResult에 담아준다


11-2. FieldError 처리

  1. @ModelAttribute 가 각각의 필드에 타입 변환 시도

    • 실패하면 typeMisMatchFieldError 추가

    • 성공하면 Validator 적용

  2. 바인딩에 성공한 필드만 Bean Validation 을 적용해 어노테이션을 통한 검증을 수행한다 ( 값이 정상적으로 들어와야 검증이 의미가 있기 때문에 )

    • 바인딩에 성공해 검증 수행을 하던 중에 검증 오류가 발생하면 FieldError 추가

  • FieldError 객체를 만들 때 어노테이션 이름을 errorCode 로 사용한다

  • 즉, MessageCodesResolver 가 어노테이션 이름을 errorCode 로 사용해 메세지 코드를 만들어낸다

  • ex> @NotBlank 가 지켜지지 않은 경우 ➜ NotBlank.item.itemName, NotBlank.item 등과 같이 메세지 코드가 생성


11-3. ObjectError 처리

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message="총합이 10000원 넘게 입력해주세요")
public class Item { ... }
  • 검증 대상에 @ScriptAssert 어노테이션을 붙이면 ObjectError 를 처리할 수 있다

  • 생성되는 메세지 코드는 ScriptAssert.item, ScriptAssert 이다

  • but> 사용에 제약이 많고, 실제 실무에서는 객체의 범위를 넘어서는 경우가 많은데 이럴 때 대응하기 어렵다

  ➡️ ObjectError를 처리할 때는 @ScriptAssert 보다 자바 코드로 작성하는 것을 권장


11-4. Bean Validation 의 한계

  • 등록할 때와 수정할 때의 검증 요구사항이 다르면 같은 객체에서 검증 조건이 충돌

  • 결과적으로 등록과 수정은 같은 Bean Validation을 적용할 수 없다

  • 해결 방법 1 : Bean Validation 의 groups 기능을 사용

  • 해결 방법 2 : Item 객체를 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용


11-5. groups

  1. 인터페이스 생성 ( ex> SaveCheck, UpdateCheck )

  2. 객체 필드에 어노테이션을 붙일 때 groups 속성에 인터페이스 이름을 지정

    • 등록, 수정 모두에 사용되는 필드 : @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})

    • 수정만 사용하는 필드의 경우 : @NotNull(groups = UpdateCheck.class)

  3. Controller 에서 @Validated 를 붙일 때 등록인지, 수정인지 명시

    • 등록 : @Validated(SaveCheck.class)

    • 수정 : @Validated(UpdateCheck.class)


  • ex> 저장 요청을 처리하는 메서드에 @Validated(SaveCheck.class) 가 붙어있으면 어노테이션에 SaveCheck.class 가 붙은 객체의 필드에 대해서만 유효성 검사를 진행한다

11-6. Form 전송 객체 분리

  • Form 을 전달 받는 전용 객체를 만들어 @ModelAttribute 를 붙인다

  • 데이터를 form 에 전달 받고, getXXX() 메서드를 통해 필요한 데이터를 사용해서 도메인 객체를 생성

  • 폼에 데이터 전달에 도메인 객체 사용

    • @ModelAttribute Item item

    • HTML Form ➜ 도메인 객체 ➜ Controller ➜ 도메인 객체 ➜ Repository

  • 폼 데이터 전달을 위한 별도의 객체 사용

    • @ModelAttribute ItemSavdForm form

    • HTML Form ➜ Form 객체 ➜ Controller ➜ 도메인 객체 생성 ➜ Repository

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글