'스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 수업을 듣고 정리한 내용입니다.
- 예를 들어, 기획자가 화면에 보이는 문구가 마음에 들지 않는다고, 상품명이라는 단어를 모두 상품이름으로 고쳐달라고 하면 어떻게 해야할까?
- 여러 화면에 보이는 상품명, 가격, 수량 등,
label
에 있는 단어를 변경하려면 화면을 다 찾아가면서 모두 변경해야 한다.- 메시지 기능을 사용하지 않을 경우, 모든 메시지를 각각 하드코딩으로 입력해야한다.
- 이때, 메시지 기능을 사용해야 한다.
- 메시지 기능 : 화면에서 공통으로 사용되는 다양한 메세지를 한 곳에서 관리하도록 하는 기능
🔔 하드 코딩
데이터를 코드 내부에 직접 입력하는 것
<label for="itemName">상품명</label>
하드코딩은 값을 바로 확인할 수 있지만, 유지보수가 어렵다는 단점이 있다.
메시지 기능 사용 예시
messages.properteis
라는 메시지 관리용 파일을 만든다.
item=상품
item.id=상품 ID item.itemName=상품명 item.price=가격 item.quantity=수량
각 HTML들은 다음과 같이 해당 데이터를 key
값으로 불러서 사용하는 것이다.
addForm.html
<label for="itemName" th:text="#{item.itemName}"></label>
editForm.html
<label for="itemName" th:text="#{item.itemName}"></label>
메시지에서 설명한 메시지 파일( messages.properteis )을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다.
messages_en.propertis
item=Item
item.id=Item ID
item.itemName=Item Name
item.price=price
item.quantity=quantity
messages_ko.propertis
item=상품
item.id=상품 ID item.itemName=상품명 item.price=가격 item.quantity=수량
영어를 사용하는 사람 : messages_en.propertis
한국어를 사용하는 사람 : messages_ko.propertis
한국에서 접근한 것인지 영어에서 접근한 것인지는 인식하는 방법?
HTTP accept-language
헤더 값을 활용해서 어떤 언어를 원하는지 조사하고 쿠키 등을 사용해서 처리하면 된다.
💡 참고
- 메시지와 국제화 기능을 직접 구현할 수도 있겠지만, 스프링은 기본적인 메시지와 국제화 기능을 모두 제공한다.
- 그리고 타임리프도 스프링이 제공하는 메시지와 국제화 기능을 편리하게 통합해서 제공한다.
- 지금부터 스프링이 제공하는 메시지와 국제화 기능을 알아보자.
- 스프링은 기본적인 메시지 관리 기능을 제공한다.
- 메시지 관리 기능을 사용하려면 스프링이 제공하는
MessageSource
를 스프링 빈으로 등록하면 되는데,MessageSource
는 인터페이스이다. 따라서 구현체인ResourceBundleMessageSource
를 스프링 빈으로 등록하면 된다.
직접 등록
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new
ResourceBundleMessageSource();
messageSource.setBasenames("messages", "errors");
messageSource.setDefaultEncoding("utf-8");
return messageSource;
}
basenames
: 설정 파일의 이름을 지정한다.messages
로 지정하면 messages.properties
파일을 읽어서 사용한다.messages_en.properties
, messages_ko.properties
와 같이 파일명 마지막에 언어 정보를 주면된다. 만약 찾을 수 있는 국제화 파일이 없으면 messages.properties
(언어정보가 없는 파일명)를 기본으로 사용한다./resources/messages.properties
에 두면 된다.messages
, errors
둘을 지정했다. defaultEncoding
: 인코딩 정보를 지정한다. utf-8
을 사용하면 된다.
- 스프링 부트를 사용하면 스프링 부트가
MessageSource
를 자동으로 스프링 빈으로 등록한다.
스프링 부트 메시지 소스 설정
스프링 부트를 사용하면 다음과 같이 메시지 소스를 설정할 수 있다.
application.properties
spring.messages.basename=messages,config.i18n.messages
스프링 부트 메시지 소스 기본 값
spring.messages.basename=messages
MessageSource
를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages
라는 이름으로 기본 등록된다.messages_en.properties
, messages_ko.properties
, messages.properties
파일만 등록하면 자동으로 인식된다.
- 메시지 파일을 만들어보자!
- 국제화 테스트를 위해서
messages_en
파일도 추가하자.
/resources/messages.properties
messages.properties
: 기본 값으로 사용(한글)
hello=안녕
hello.name=안녕 {0}
/resources/messages_en.properties
messages_en.properties
: 영어 국제화 사용
hello=hello
hello.name=hello {0}
스프링이 제공하는 메시지 소스를 알아보자!
MessageSource 인터페이스
public interface MessageSource {
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
MessgaeSource
인터페이스를 보면 코드를 포함한 일부 파라미터로 메시지를 읽어오는 기능을 제공한다.
test/java/hello/itemservice/message.MessageSourceTest.java
package hello.itemservice.message;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;
@SpringBootTest
public class MessageSourceTest {
@Autowired
MessageSource ms;
@Test
void helloMessage() {
String result = ms.getMessage("hello", null, null);
assertThat(result).isEqualTo("안녕");
}
}
ms.getMessage("hello", null, null)
hello
null
null
hello
를 입력하고 나머지 값은 null
을 입력했다.locale
정보가 없으면 basename
에서 설정한 기본 이름 메시지 파일을 조회한다.basename
으로 messages
를 지정 했으므로 messages.properties
파일에서 데이터 조회한다.
💡 참고
이와 같은 오류가 발생하였다.
한글을 식별하지 못한다.
여기(클릭)를 참고하니 해결 되었다.
MessageSourceTest 추가 - 메시지가 없는 경우, 기본 메시지
@Test
void notFoundMessageCode() {
assertThatThrownBy(() -> ms.getMessage("no_code", null, null))
.isInstanceOf(NoSuchMessageException.class);
}
@Test
void notFoundMessageCodeDefaultMessage(){
String result = ms.getMessage("no_code", null, "기본 메시지", null);
assertThat(result).isEqualTo("기본 메시지");
}
NoSuchMessageException
이 발생한다.defaultMessage
)를 사용하면 기본 메시지가 반환된다.
MessageSourceTest 추가 - 매개변수 사용
@Test
void argumentMessage() {
String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
assertThat(result).isEqualTo("안녕 Spring");
}
{0}
부분은 매개변수를 전달해서 치환할 수 있다.hello.name=안녕 {0}
→ Spring 단어를 매개변수로 전달 → 안녕 Spring
locale 정보를 기반으로 국제화 파일을 선택한다.
- Locale이
en_US
의 경우messages_en_US
→messages_en
→messages
순서로 찾는다.Locale
에 맞추어 구체적인 것이 있으면 구체적인 것을 찾고, 없으면 디폴트를 찾는다고 이해하면 된다.
MessageSourceTest 추가 - 국제화 파일 선택1
@Test
void defaultLang(){
assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");
assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");
}
ms.getMessage("hello", null, null)
: locale 정보가 없으므로 messages
를 사용ms.getMessage("hello", null, Locale.KOREA)
: locale 정보가 있지만, message_ko
가 없으므로 messages
를 사용
MessageSourceTest 추가 - 국제화 파일 선택2
@Test
void enLang(){
assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}
ms.getMessage("hello", null, Locale.ENGLISH)
: locale 정보가 Locale.ENGLISH
이므로 messages_en
을 찾아서 사용
최종 결과
실제 웹 애플리케이션에 메시지를 적용해보자!
messages.properties - 메시지 추가
hello=안녕
hello.name=안녕 {0}
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=취소
- 타임리프의 메시지 표현식
#{...}
를 사용하면 스프링의 메시지를 편리하게 조회할 수 있다.- 예를 들어서 방금 등록한 상품이라는 이름을 조회하려면
#{label.item}
이라고 하면 된다.
✓ 렌더링 전
<div th:text="#{label.item}"></h2>
✓ 렌더링 후
<div>상품</h2>
✓ 타임리프 템플릿 파일에 메시지를 적용
적용 대상 :
addForm.html
,editForm.html
,item.html
,items.html
addForm.html
<!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;
}
</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>
<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>
</div> <!-- /container -->
</body>
</html>
(1) 페이지 이름에 적용
<h2>상품 등록 폼</h2>
➡️<h2 th:text="#{page.addItem}">상품 등록</h2>
(2) 레이블에 적용
<label for="itemName">상품명</label>
➡️<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
➡️<label for="price" th:text="#{label.item.price}">가격</label>
➡️<label for="quantity" th:text="#{label.item.quantity}">수량</label>
(3) 버튼에 적용
<button type="submit">상품 등록</button>
➡️<button type="submit" th:text="#{button.save}">저장</button>
➡️<button type="button" th:text="#{button.cancel}">취소</button>
editForm.html
<!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;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.updateItem}">상품 수정 폼</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div>
<label for="id" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}" class="form-control">
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" class="form-control">
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" class="form-control">
</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='item.html'"
th:onclick="|location.href='@{/message/items/{itemId}(itemId=${item.id})}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
item.html
<!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;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.item}">상품 상세</h2>
</div>
<!-- 추가 -->
<h2 th:if="${param.status}" th:text="'저장 완료'"></h2>
<div>
<label for="itemId" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="itemId" name="itemId" class="form-control" value="1" th:value="${item.id}" readonly>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}" readonly>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}" readonly>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}" readonly>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg"
onclick="location.href='editForm.html'"
th:onclick="|location.href='@{/message/items/{itemId}/edit(itemId=${item.id})}'|"
type="button" th:text="#{page.updateItem}">상품 수정</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="#{page.items}">목록으로</button>
</div>
</div>
</div> <!-- /container -->
</body>
</html>
items.html
<!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">
</head>
<body>
<div class="container" style="max-width: 600px">
<div class="py-5 text-center">
<h2 th:text="#{page.items}">상품 목록</h2>
</div>
<div class="row">
<div class="col">
<button class="btn btn-primary float-end"
onclick="location.href='addForm.html'"
th:onclick="|location.href='@{/message/items/add}'|"
type="button" th:text="#{page.addItem}">상품 등록</button>
</div>
</div>
<hr class="my-4">
<div>
<table class="table">
<thead>
<tr>
<th th:text="#{label.item.id}">ID</th>
<th th:text="#{label.item.itemName}">상품명</th>
<th th:text="#{label.item.price}">가격</th>
<th th:text="#{label.item.quantity}">수량</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${items}">
<td><a href="item.html" th:href="@{/message/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
<td><a href="item.html" th:href="@{|/message/items/${item.id}|}" th:text="${item.itemName}">상품명</a></td>
<td th:text="${item.price}">10000</td>
<td th:text="${item.quantity}">10</td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
실행
잘동작한다!
💡 참고
파라미터는 다음과 같이 사용할 수도 있다.
hello.name=안녕 {0}
→<p th:text="#{hello.name(${item.itemName})}"></p>
📌 정리
- 지금까지 메시지를 효율적으로 관리하는 방법을 알아보았다.
- 이제 여기에 더해서 국제화를 웹 애플리케이션에 어떻게 적용하는지 알아보자.
이번에는 웹 애플리케이션에 국제화를 적용해보자.
messages_en.properties - 영어 메시지 추가
hello=hello
hello.name=hello {0}
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
#{...}
를 통해서 메시지를 사용하도록 적용해두었기 때문이다.
(1) 영어가 제일 우선순위 높을 때
(2) 한국어가 제일 우선순위 높을 때
Accpt-Language
의 값이 변경된다.Accept-Language
는 클라이언트가 서버에 기대하는 언어 정보를 담아서 요청하는 HTTP 요청 헤더이다.
스프링의 국제화 메시지 선택
- 앞서
MessageSource
테스트에서 보았듯이 메시지 기능은Locale
정보를 알아야 언어를 선택할 수 있다.- 스프링도
Locale
정보를 알아야 언어를 선택할 수 있는데, 스프링은 언어 선택시 기본으로Accept-Language
헤더의 값을 사용한다.
스프링은
Locale
선택 방식을 변경할 수 있도록LocaleResolver
라는 인터페이스를 제공하는데, 스프링 부트는 기본으로Accept-Language
를 활용하는AcceptHeaderLocaleResolver
를 사용한다.
LocaleResolver 인터페이스
public interface LocaleResolver {
Locale resolveLocale(HttpServletRequest request);
void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);
}
LocaleResolver 변경
- 만약
Locale
선택 방식을 변경하려면LocaleResolver
의 구현체를 변경해서 쿠키나 세션 기반의Locale
선택 기능을 사용할 수 있다.- 예를 들어서 고객이 직접
Locale
을 선택하도록 하는 것이다.- 관련해서
LocaleResolver
를 검색하면 수 많은 예제가 나온다.
참고