메시지,국제화
기획자가 화면에 보이는 문구를 바꿔달라고 요청하면 어떻게 해야할까?
예를들면 상품명 -> 상품이름으로 바꿔달라고 요청한다.
여러 화면에 보이는 상품명이란 단어를 다 찾아가면서 바꿔야 할 것이다.
화면수가 적으면 문제가 되지 않지만 화면이 수십개 이상이라면 수십개의 파일을 모두 고쳐야한다.
이러한 문제를 해결 하기위해 스프링은 메시지 관리 기능을 제공한다.
메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource를 스프링 빈으로 등록하면되는데,
MessageSource는 인터페이스이다. 따라서 구현체인 ResourceBundleMessageSource를 스프링 빈으로 등록하면 된다.
스프링 부트는 MessageSource를 자동으로 스프링 빈으로 등록한다.
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정
button.save=저장
button.cancel=취소
국제화
메시지 파일(messages.properties)을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다.
messages_ko.properties
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정
button.save=저장
button.cancel=취소
messages_en.properties
label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity
page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update
button.save=Save
button.cancel=Cancel
영어를 사용하는 사람이면 messages_en.properties를 사용하고
한국어를 사용하면 messages_ko.properties 를 사용하게 개발하면 된다.
한국에서 접근한 것인지 영어에서 접근한 것인지 인식하는 방법은 HTTP accept-langauage 헤더 값을 사용하거나
사용자가 직접 언어를 선택해서 쿠키 등을 사용해서 처리하면 된다.
스프링 부트 메시지 소스 설정
application.properties
spring.messages.basename=messages,config.i18n.messages
스프링 부트 메시지 소스 기본 값
spring.messages.basename=messages
MessageSource를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages 라는 이름으로 기본등록 된다.
따라서 messages_en.properties , messages.properties 파일만 등록하면 자동으로 인식된다.
웹 애플리케이션에 메시지 적용하기
타임리프 메시지 적용
타임리프의 메시지 표현식 #{...}을 사용하면 스프링의 메시지를 편리하게 조회할 수 있다.
messages.properties
label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량
page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정
button.save=저장
button.cancel=취소
addForm.html
<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>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/message/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
파라미터 같은 경우에는
hello.id=아이디 {0}
<p th:tet="#{hello.id(${item.itemId})}"></p>
이런식으로 사용하면 된다.
웹 애플리케이션에 국제화 적용하기
label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity
page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update
button.save=Save
button.cancel=Cancel
크롬 브라우저에 언어 설정을 영어로 가장 우선순위로 두게 되면
화면처럼 영어로 작성된 화면을 볼 수 있다.
다시 한국어로 돌리면 한국말로 작성된 화면을 볼 수 있다.
웹 브라우저에 Accept-Language의 값이 변경되어 변경된 값에 따라
messages_en.properties 로 보여줄지 messages.properties 보여 줄지 정해진다.
스프링의 국제화 메시지 선택
메시지 기능은 Locale 정보를 알아야 언어를 선택할 수 있다.
스프링도 Locale 정보를 알아야 언어를 선택할 수 있는데, 스프링 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용한다.
LocaleResolver
스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 라는 인터페이스를 제공하는데,
스프링 부트는 기본으로 Accept-Language를 활용하는 AcceptHeaderLocaleResolver 를 사용한다.
Locale 선택 방식을 변경하려면 LocaleResolver 의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능을 사용 할 수있다.
검증1 - Validation
이전에 만들었던 상품관리 시스템에 검증 로직을 추가해보자
웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나, 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야한다.
컨트롤러의 중요한 역할중 하나는 http 요청이 정상인지 검증하는 것이다.
클라이언트 검증, 서버 검증
클라이언트 검증은 조작할 수 있으므로 보안에 취야하다.
서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 사용하고 최종적으로는 서버 검증을 한다.
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Controller
@RequestMapping("/validation/v1/items")
@RequiredArgsConstructor
public class ValidationItemControllerV1 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v1/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v1/item";
}
@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) {
//검증 오류 결과를 보관
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(item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if(!errors.isEmpty()) {
log.info("errors={}",errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
@GetMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v1/editForm";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
itemRepository.update(itemId, item);
return "redirect:/validation/v1/items/{itemId}";
}
}
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<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="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}"></p>전체 오류 메시지
</div>
<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' ">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}"></div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v1/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
검증 오류 보관을 하기위해
Map에다가 정보를 담아 두었다.
검증 오류가 발생하면 errors에 담아둔다. 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을
key로 사용하였다.
검증에서 오류메시지가 하나라도 있으면 오류메시지를 출력하기위해 model, errors를 담고 검증에 실패하면 다시 입력 폼으로 돌려 보낸다.
//검증에 실패하면 다시 입력 폼으로
if(!errors.isEmpty()) {
log.info("errors={}",errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
현재 소스를 보면 검증 오류가 발생했을때 model에 errors 를 담는 소스는 보이지만 item을 담는 소스는 보이지 않는다.
그럼에도 addForm.html 에서는 반환되는 값들이 잘 보인다.
그 이유는 @ModelAttribute 특징으로 자동으로 model.addAttribute("item",item) 을 만들어주기 때문에 html에서 값들이 잘 보이게 된다.
<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' ">
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>
errors값이 null 이 나올 경우가 있을지 생각해본다면 최초 등록폼에 진입한 시점에는 errors가 없기 때문에 null이 나올 것이다.
errors값이 null이라면 errros.containsKey() 호출하는 순간 NullPointerException 에러가 발생했을 것이다.
errors?.은 errros가 null일때 NullPointerException이 발생하는 대신, null을 반환하는 문법이다.
th:if에서 null은 실패로 처리되므로 오류 메시지가 출력되지 않는다.
출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2
'SPRING' 카테고리의 다른 글
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 5일차 (0) | 2022.01.17 |
---|---|
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 4일차 (0) | 2022.01.14 |
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 2일차 (0) | 2022.01.11 |
김영한 - 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 1일차 (0) | 2022.01.10 |
김영한 - 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 7일차 (0) | 2022.01.07 |