Search

Spring활용 - 국제화/Validation

메시지

상품명이라는 단어를 모두 상품이름으로 고쳐달라고 하면??
다 찾아가면서 고쳐야한다.
이를 위해 메시지를 한 곳에서 관리하도록 한다
messages.properties라는 메시지 관리용 파일을 만들고
item = 상품 item.id = 상품 ID item.itemName = 상품명 item.price = 가격 item.quantity = 수량
JavaScript
복사
해당 데이터를 키값으로 불러서 사용하는 것
<label for = "itemName" th:text = "#{item.itemName}"></label>

국제화

messages.properties를 나라별로 별도로 관리하면 서비스를 국제화할 수 잇다.
messages_en.properties & messages_ko.properties
영어를 사용하면 _en을 한국어를 사용하면 _ko를 하면 됨
accept-language헤더 값을 사용하거나 사용자가 직접 언어를 선택하도록 하고, 쿠키 등을 사용해서 처리하면 된다.
스프링 → 기본적인 메시지와 국제화 기능을 모두 제공함

메시지 소스 설정

기본 스프링
@Bean public MessageSource messageSource(){ ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); messageSource.setBasenames("messages","errors"); messageSource.setDefaultEncoding("utf-8"); return messageSource; }
Java
복사
basenames: 설정 파일의 이름을 지정한다.
messages로 지정하면 messages.properties 파일을 읽어서 사용한다.
추가로 국제화 기능을 적용하려면 messages_en.properties, messages_ko.properties와 같이 파일명 마지막에 언어 정보를 주면 된다.
여러 파일을 지정할 수 있다
defaultEncoding: 인코딩 정보 → utf-8

스프링 부트

MessageSource를 자동으로 스프링 빈으로 등록함, application.properties에 설정만 주면 된다.
spring.messages.basename = messages, config.i18n.messages;

스프링 메시지 소스 사용

MessageSource 인터페이스에는 getMessage와 setMessage라는 함수가 정의되어있다.
@SpringBootTest public class MessageSourceTest{ @Autowired MessageSource ms; @Test void helloMessage(){ String result = ms.getMessage("Hello",null , null); assertThat(result).isEqualTo("안녕"); } }
Java
복사
ms.getMessage("hello",null,null)
code: hello
args: null
locale: null
@Test void NotFoundMessageCode(){ assertThatThrownBy(() -> ms.getMessage("no_code",null,null)) .isInstanceOf(NoSuchMessageException.class); } @Test void notFoundMessageCodeDefault(){ String result = ms.getMessage("no_code",null,"기본 메시지",null); assertThat(result).isEqualTo("기본 메시지"); }
Java
복사
메시지가 없는 경우에는 NoSuchMessageException이 발생
메시지가 없어도 기본 메시지를 사용하면 기본 메시지가 반환됨
@Test void argumentMessagE(){ String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null); assertThat(result).isEqualTo("안녕 Spring"); }
Java
복사
다음 메시지의 {0}부분은 매개변수를 전달해서 치환 가능
hello.name = 안녕 {0}

국제화 파일 선택

locale 정보를 기반으로 국제화 파일을 선택
Locale이 en_US의 경우 messages_en_US → messages_en → messages 순서로 찾음
Locale에 맞추어 구체적인 것이 있으면 구체적인 것을 찾고 없으면 디폴트

웹 어플리케이션에 적용

타임리프 → 메시지 표현식 #{...}을 사용하면 스프링의 메시지를 편리하게 조회 가능

Validation

컨트롤러의 중요한 역할 중 하나는 HTTP요청이 정상인지 검증하는 것이다.
클라이언트 검증, 서버 검증
클라이언트 검증 → 보안에 취약
서버만으로 검증하면 → 즉각적인 고객 사용성이 부족해짐
둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API응답 결과에 잘 남겨 주어야함.

검증 직접 처리

실패해도 실패한 값이 그대로 전달된다
왜? ModelAttribute를 하면 model에 param값을 자동으로 집어넣기 때문

Safe Navigation Operator

여기서 errors가 null이라면 어떻게 될까?
errors.containsKey()를 호출하는 순간 NullPointerException 이 발생함
errors?.는 errors가 null 일 때 NullPointerException 이 발생하는 대신, null을 반환하는 문법
th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다.

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.getQuantity() != null){ int resultPrice = item.getPrice() * item.getQuantityt(); if(resultPrice < 10000) bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } }
Java
복사
BindingResult bindingResult의 파라미터 위치는 @ModelAttribute Item item 다음에 와야한다.
<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> <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>
HTML
복사
#fields: #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th:errors → 해당 필드에 오류가 있는 경우에 태그를 출력한다.
th:errorclass → th:field에서 지정한 필드에 오류가 있으면 class정보를 추가한다/

BindingResult

스프링이 제공하는 검증 오류를 보관하는 개체이다. 검증 오류가 발생하면 여기에 보관
@ModelAttribute에 바인딩 시 타입 오류가 발생하면?
→ bindingResult가 업승면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동
→ bindingResult가 있으면 오류 정보(FieldError)를 bindingResult에 담아서 컨트롤러를 정상 호출한다.

BindingResult에 검증 오류를 적용하는 3가지 방법

1.
타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
2.
개발자가 직접 넣어준다.
3.
Validator 사용

FieldError, ObjectError

사용자 입력 오류 메시지가 화면에 남도록 하자.
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)
Java
복사
파라미터 목록
objectName: 오류가 발생한 객체 이름
field: 오류 필드
rejectedValue: 사용자가 입력한 값
bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지
codes: 메시지 코드
arguments: 메시지에서 사용하는 인자
defaultMessage: 기본 오류 메시지

오류 발생시 사용자 입력 값 유지

new FieldError("item", "price", item.getPrice(), false, null, null "가격은 1,000~ 1,000,000 까지 허용합니다.")
사용자의 입력 데이터가 컨트롤러 @ModelAttribute에 바인딩되는 시점에 오류가 발생하면, 사용자 입력 값을 모델 객체에 유지하기 어려움.
따라서 사용자 입력 값을 보관하는 별도의 방법이 필요. FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공 → rejectedValue가 그 방법

타임리프의 사용자 입력 값 유지

th:field = "*{price}"
정상 상황: 모델 객체의 값을 사용.
오류 발생: FieldError에서 보관한 값을 사용해서 출력

오류 코드와 메시지 처리

오류 메시지를 체계적으로 다루자
required.item.itemName=상품 이름은 필수입니다. range.item.price=가격은 {0} ~ {1} 까지 허용합니다. max.item.quantity=수량은 최대 {0} 까지 허용합니다. totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
HTML
복사
이후
application.properties 에 spring.messages.basename = messages,errors를 추가
rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
@PostMaping("/add") public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttribute){ 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.getPrice() != null && item.getQuantity() != null){ int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice < 10000) { bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null); } }
Java
복사
rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
field: 오류 필드명
errorCode: 오류 코드(messageResolver를 위한 오류 코드)
errorArgs: 오류 메시지에서 {0}을 치환하기 위한 값
defaultMessage: 오류 메시지를 찾을 수 없을때 사용하는 기본 메시지
MessageCodesResolver를 사용하면 오류 코드를 알아서 만들어준다.
#Level1 required.item.itemName: 상품 이름은 필수 입니다. #Level2 required: 필수 값 입니다.
Java
복사
이런 식으로 Level1을 뒤지고 Level2를 뒤진다.
MessageCodesResolver의 코드 생성 규칙
필드 오류 1.: code + "." + object name + "." + field 2.: code + "." + field 3.: code + "." + field type 4.: code 예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 1. "typeMismatch.user.age" 2. "typeMismatch.age" 3. "typeMismatch.int" 4. "typeMismatch"
Java
복사
객체 오류의 경우 다음 순서로 2가지 생성 1.: code + "." + object name 2.: code 예) 오류 코드: required, object name: item 1.: required.item 2.: required
Java
복사
즉 rejectValue("itemName", "required")를 사용하면
required.item.itemName
required.itemName
required.java.lang.String
required 이 4개가 자동으로 나온다.

스프링이 직접 만든 오류 메시지 처리

타입 오류가 발생하면 typeMistmatch라는 오류 코드를 사용함
이 오류 코드가 MessageCodesResolver를 통하면서 4가지 메시지 코드가 생성된 것
따라서 typeMistmatch라는 오류 코드를 error.properties에 추가해주면 된다.
typeMismatch.java.lang.Integer = 숫자를 입력해주세요. typeMistmatch = 타입 오류 입니다.
Java
복사

Validator

복잡한 검증 로직을 별도로 분리하자.
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; 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() > 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를 따로 만들어서 supports 함수와 validate함수를 구현한다.
supports → 어떤 객체를 validate할 건지를 정하고
validate → 객체를 어떻게 validate할 건지 정한다.
그리고 이거를 사용할 때는 @Validated를 사용함
private final ItemValidator itemValidator; @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"; } //성공 로직 Item savedItem = itemRepository.save(item); redirectAttributes.addAttribute("itemId", savedItem.getId()); redirectAttributes.addAttribute("status", true); return "redirect:/validation/v2/items/{itemId}"; }
Java
복사

Bean Validation

Bean Validation은 특정한 구현체가 아니라 Bean Validaiton 2.0 이라는 기술 표준
쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음
하이버네이트 Validator가 구현체다
Ex) @NotBlank, @NotNull @Range(min = 1000, max = 100000) , @Max(9999)

스프링 부트와 Bean Validator

스프링 부트는 자동으로 글로벌 Validator를 등록함
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
애노테이션을 보고 검증을 수행함.
이 때 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않음.

검증 순서

@ModelAttribute 각각의 필드에 타입 변환 시도
성공하면 다음으로
실패하면 typeMismatch로 FieldError 추가
Validator 적용
바인딩에 성공한 필드만 Bean Validation을 적용한다.
만약 변환에 실패했다면 typeMismatch FieldError를 추가한다.

애노테이션 기반 에러 코드

@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank
@Range
Range.item.price
Range.price
Range.java.lang.Integer
Range
#Bean Validation 추가 NotBlank={0} 공백X Range={0}, {2} ~ {1} 허용 Max={0}, 최대 {1} {0} -> 필드명, {1}.{2}는 각 애노테이션 마다 다름
JavaScript
복사

BeanValidation 메시지 찾는 순서

1.
생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2.
애노테이션의 message 속성 사용 → @NotBlank(message = "공백! {0} ")
3.
라이브러리가 제공하는 기본 값 사용 → 공백일 수 없습니다.

Bean Validation - 한계

데이터를 등록할 때와 수정할 때는 요구 사항이 다를 수 있다.
groups 기능을 사용하든가,
public class Item{ @NotNull(groups = UpdateCheck.class) private Long id; @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) private String itemName; }
JavaScript
복사
Form 객체별로 RequestDTO를 만들어낸던가
ItemSaveForm , ItemUpdateForm을 따로 만든다.
HTML Form → ItemSaveForm → Controller → Item 생성 → Repository

Http 메시지 컨버터

@Valid, @Validated는 HttpMessageConverter에도 적용할 수 있음
@RequestBody에도 적용 가능

3가지 경우

성공 요청: 성공
실패 요청 : JSON을 객체로 생성하는 것 자체가 실패
검증 오류 요청 : JSON을 객체로 생성하는 것은 성공했으나, 검증은 실패
JSON을 객체로 생성하지 못하거나, Validator에서 오류가 나는 경우, 적절한 API스펙을 저의하고 그에 맞는 객체를 만들어서 반환해야함.