쌩로그

스프링 MVC 2편 - Ch03. 메시지, 국제화 본문

Spring/Spring & Spring Boot

스프링 MVC 2편 - Ch03. 메시지, 국제화

.쌩수. 2024. 5. 7. 00:24
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 메시지, 국제화 소개
      2-2. 스프링 메시지 소스 설정
      2-3. 스프링 메시지 소스 사용
      2-4. 웹 애플리케이션에 메시지 적용하기
      2-5. 웹 애플리케이션에 국제화 적용하기
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 스프링 MVC 2편 Section 03 메시지, 국제화를 학습하며 정리한 포스팅이다.

이전 프로젝트에 이어서 메시지, 국제화 기능을 학습해본다.

참고로 바로 이전 섹션의 상품 관리 프로젝트를 이어가는데, 메시지, 국제화 예제에 집중하기 위해서 복잡한 체크, 셀렉트 박스 관리 기능은 제거했으므로, 제공해준 소스코드를 임포트하는 것이 좋을 거 같다.

2. 본론

2-1. 메시지, 국제화 소개

메시지

악덕(?) 기획자가 화면에 보이는 문구가 마음에 들지 않는다고, 상품명이라는 단어를 모두 상품이름으로 고쳐달라고 하면 어떻게 해야할까?
여러 화면에 보이는 상품명, 가격, 수량 등, label 에 있는 단어를 변경하려면 다음 화면들을 다 찾아가면서 모두 변경 해야 한다. (기획자 바로 욕할 거 같다.)

지금처럼 화면 수가 적으면 문제가 되지 않지만 화면이 수십 개 이상이라면 수십 개의 파일을 모두 고쳐야 한다.

  • addForm.html , editForm.html , item.html , items.html 등등..

왜냐하면 해당 HTML 파일에 메시지가 하드코딩 되어 있기 때문이다.
이런 다양한 메시지를 한 곳에서 관리하도록 하는 기능을 메시지 기능이라 한다.

예를 들어서 messages.properties 라는 메시지 관리용 파일을 만들고

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.properties )을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다.
예를 들어서 다음과 같이 2개의 파일을 만들어서 분류한다.

messages_en.properties

item=Item 
item.id=Item ID 
item.itemName=Item Name 
item.price=price 
item.quantity=quantity 

messages_ko.properties

item=상품 
item.id=상품 ID 
item.itemName=상품명 
item.price=가격 
item.quantity=수량 

영어를 사용하는 사람이면 messages_en.properties 를 사용하고,
한국어를 사용하는 사람이면 messages_ko.properties 를 사용하게 개발하면 된다.

이렇게 하면 사이트를 국제화 할 수 있다.

한국에서 접근한 것인지 영어에서 접근한 것인지는 인식하는 방법은 HTTP accept-language 해더 값을 사용하거나 사용자가 직접 언어를 선택하도록 하고, 쿠키 등을 사용해서 처리하면 된다.

메시지와 국제화 기능을 직접 구현할 수도 있겠지만, 스프링은 기본적인 메시지와 국제화 기능을 모두 제공한다.
그리고 타임리프도 스프링이 제공하는 메시지와 국제화 기능을 편리하게 통합해서 제공한다.
지금부터 스프링이 제공하는 메시지와 국제화 기능을 알아본다.

2-2. 스프링 메시지 소스 설정

스프링은 기본적인 메시지 관리 기능을 제공한다.
별도로 만들 필요도 없고, Spring Boot를 사용하면 설정까지 다 되어서 나온다.

메시지 관리 기능을 사용하려면 스프링이 제공하는 MessageSource스프링 빈으로 등록하면 되는데, MessageSource 는 인터페이스이다.
따라서 구현체ResourceBundleMessageSource스프링 빈으로 등록 하면 된다.

직접 등록

@SpringBootApplication 클래스에 다음 코드를 넣으면 된다.
되는데...!! 스프링 부트는 알아서 넣어져있다.
그냥 참고만 하면 된다.

@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 

이렇게 하면 resources/messages', resources/config/i18n/messages 로 설정된다.
디폴트 메시지 소스값은 다음과 같다.

spring.messages.basename=messages

MessageSource 를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages 라는 이름으로 기본 등록된다.

따라서 messages_en.properties , messages_ko.properties , messages.properties 파일만 등록하면 자동으로 인식된다.

참고로 properties의 다른 옵션들은 스프링 공식문서에서 볼 수 있다.

spring.message관련 옵션들은 이렇게 있다.

디테일한 건 추후에 찾아보면 된다.

메시지 파일 만들기

메시지 파일을 만들어보자.
국제화 테스트를 위해서 messages_en 파일도 추가하자.

  • messages.properties :기본 값으로 사용(한글)
  • messages_en.properties : 영어 국제화 사용

참고로 파일명은 massage가 아니라 messages다. 마지막 s에 주의하자.

messages.properties는 다음과 같다.

hello=안녕  
hello.name=안녕 {0}

messages_en.properties는 다음과 같다.

hello=hello  
hello.name=hello {0}

참고로 인텔리제이가 파일을 다음과 같이 나눠준다.
파일을 나눠주지 않더라도 상관은 없다.

지금 상태로는 외국어는 영어가 아니면 다 한국어다.

2-3. 스프링 메시지 소스 사용

이제 메시지 소스를 사용해보자.

테스트에서 itemservice.message 패키지에서 MessageSourceTest 클래스를 다음과 같이 작성하자.

@SpringBootTest  
public class MessageSourceTest {  

    @Autowired  
    MessageSource ms;   
}

이렇게 해놓으면 MessageSource를 불러오는데,
application.properties를 통해서 불러오고, application.properties에는 spring.messages.basename=messages가 선언되어 있으므로,
resources/messages에서 파일들을 가져온다.

다음과 같이 작성해보자.

@Test  
void helloMessage() {  
    String result = ms.getMessage("hello", null, null);  
    Assertions.assertThat(result).isEqualTo("안녕");  
}

가장 단순한 테스트는 메시지 코드로 hello 를 입력하고 나머지 값은 null 을 입력했다
ms.getMessage()에 보면,
첫 번째는 code이다.
즉 properties에서 hello의 값을 가져온다는 것이다.
그리고 두 번째는 argument인데, 다음과 같은 값을 가리킨다.

세 번째는 locale인데, 이 값을 지정하지 않으면 basename애서 설정한 기본 이름 메시지 파일을 조회한다.
basename 으로 messages 를 지정 했으므로 messages.properties 파일에서 데이터 조회한다.
(현재는 한국어)

테스트를 돌려보면 성공이다.

이건 내가 해본 테스트다.

@Test  
void helloArgMessage() {  
    String result = ms.getMessage("hello.name", new String[]{"바보"}, null);  
    assertThat(result).isEqualTo("안녕 바보");  
}

성공이다.

기본 메시지를 살펴보자.

@Test  
void notFoundMessageCod() {  
    assertThatThrownBy(() -> ms.getMessage("no_code", null, null))  
            .isInstanceOf(NoSuchMessageException.class);  
}

테스트는 성공인데,
no_code라는 값이 없다. 값이 없기 때문에 NoSuchMessageException이 터진다.
즉 값이 없으면 예외가 터질 때 NoSuchMessageException 터지는지 테스트했는데, 예외가 터졌기 때문에 테스트가 성공한 것이다.

혹시나 그냥
ms.getMessage("no_code", null, null); 이 코드만 테스트 해보면 예외가 터진다.

반면에 메세지가 없을 때 기본 메세지를 주게 하면 예외가 발생하지 않는다.

@Test  
void notFoundMessageCodeDefaultMessage() {  
    String result = ms.getMessage("no_code", null, "기본 메시지", null);  
    assertThat(result).isEqualTo("기본 메시지");  
}

테스트는 성공한다.

다음음 매개변수가 들어가는 메시지다.

@Test  
void argumentMessage() {  
    String message = ms.getMessage("hello.name", new Object[]{"Spring"}, null);  
    assertThat(message).isEqualTo("안녕 Spring");  
}

다음으로 국제화다.

@Test  
void defaultLang() {  
    assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");  
    assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");  
}

참고로 두 번째에서 Locale.KOREA라고 되어있는데, KOREA는 없으므로, 기본값인 "안녕"이 나온다.

이제 영어를 확인해보자.

@Test  
void enLang() {  
    assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");  
}

테스트는 성공한다.
Locale을 English로 꺼내게 되면, en을 얻어온다.
그래서 hello를 얻게 된다.

순간 고민할 것이 있을 수도 있다.
유틸리티 클래스를 만들고, 타임리프에서 빈으로 접근해서 어떻게 써야 되지...등등
여러가지 생각이 들 수 있겠지만, 선배 개발자들이 이미 수없이 고생하여 자동화를 해놨다.
우리는 그냥 그 유산을 누리면 된다.

2-4. 웹 애플리케이션에 메시지 적용하기

실제 웹 애플리케이션에 메시지를 적용해보자.

먼저 메시지를 추가 등록하자.

messages.properties에서 추가하자.

label.item=상품  
label.item.id=상품 IDlabel.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>

label.item을 읽어다가 상품으로 바뀐다.
렌더링 후

<div>상품</h2>

타임리프 템플릿 파일에 메시지를 적용해보자

적용 대상은 다음과 같다.

  • addForm.html
  • editForm.html
  • item.html
  • items.html

위의 표시한 부분을 바꿔보자.

<div class="py-5 text-center">  
    <h2 th:text="#{page.addItem}">상품 등록 폼</h2>  
</div>

위와 같이 바꾸면 상품 등록 폼상품 등록으로 바뀔 것이다.

변경되었다.

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>

결과는 다음과 같다.

수정하긴했는데, 위의 화면과 비교하면, 저장, 취소가 바꼈다.

수정 폼을 보자.

기존의 수정 폼은 다음과 같다.

다음과 같이 수정해보자.

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

바뀐 것 상품 수정 폼에서 상품 수정으로 바뀐 거 밖에 없다.

상세와 리스트도 보자.
먼저 상세다.

item.html 코드다.

<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="#{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>

위에가 코드 수정 전이고, 아래가 코드 수정 후다.
상품 수정, 목록으로 가 저장, 취소로 바꼈다.

다음은 상품 목록이다.

items.html을 다음과 같이 수정해보자.

수정 전 결과이다.

수정 후 결과는 다음과 같다.

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

...
...

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

여긴 변한 것이 없다.

프로퍼티 값을 한 번에 바꿔서 다 바뀌는지 확인해보자.

상품 목록이다.

상품 등록 폼이다.

상품 상세 폼이다.

상품 수정 폼이다.

만약 어떤 악덕 기획자가 와서 상품 명을 바꿔달라고 하면, 그냥 편리하게 프로퍼티 값만 바꾸면 된다.

이렇게 사이트를 일관성있게 운영할 수 있다.

참고로 파라미터는 다음과 같이 사용할 수 있다.

hello.name=안녕 {0} 프로퍼티에서 파라미터를 사용하는 부분

<p th:text="#{hello.name(${item.itemName})}"></p> 이처럼 사용할 수 있다.

2-5. 웹 애플리케이션에 국제화 적용하기

이번에는 웹 애플리케이션에 국제화를 적용해보자. 먼저 영어 메시지를 추가하자.

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

사실 이것(properties에 값 추가)으로 국제화 작업은 거의 끝났다.
앞에서 템플릿 파일에는 모두 #{...} 를 통해서 메시지를 사용하도록 적용 해두었기 때문이다

웹으로 확인해보자.

웹 브라우저의 언어 설정 값을 변경하면서 국제화 적용을 확인해보면 된다.
크롬 브라우저 => 설정 => 언어를 검색하고, 우선 순위를 변경하면 된다.

가장 위로 이동을 누르면 아래와 같이 순서가 바뀐다.

우선순위를 영어로 변경하고 테스트해보자.

서버를 실행하면 다음과 같이 나온다.

wow...

등록폼과 상세 폼은 다음과 같다.

보다시피 국제화가 덜됬다.... ㅋㅋㅋㅋㅋㅋㅋ

그리고 언어를 다시 한국어로 바꿔주면 서버 재실행 없이 한국어로 바뀐다.

웹 브라우저의 언어 설정 값을 변경하면 요청시 Accept-Language 의 값이 변경된다.
Accept-Language 는 클라이언트가 서버에 기대하는 언어 정보를 담아서 요청하는 HTTP 요청 헤더이다.

한국어로 되어있을 때는 다음과 같다.

ko(한국어)의 q가 1.0으로 설정되어있다.
(ko에 q가 생략되어있기 때문이다. 참고로 순서보단, q의 값이가 중요하다.)

언어를 영어로 바꾸면 다음과 같다.

en-US가 우선순위가 높아진다.
한국어가 0.8로 되어있다.

스프링의 국제화 메시지 선택

앞서 MessageSource 테스트에서 보았듯이 메시지 기능은 Locale 정보를 알아야 언어를 선택할 수 있다.

결국 스프링도 Locale 정보를 알아야 언어를 선택할 수 있는데, 스프링은 언어 선택시 기본으로 Accept-Language 헤더의 값을 사용한다.

LocaleResolver

스프링은 Locale 선택 방식을 변경할 수 있도록 LocaleResolver 라는 인터페이스를 제공하는데,
스프링 부트는 기본으로 Accept-Language 를 활용하는 AcceptHeaderLocaleResolver 를 사용한다.

LocaleResolver 인터페이스는 다음과 같다.

package org.springframework.web.servlet;  

import java.util.Locale;  
import javax.servlet.http.HttpServletRequest;  
import javax.servlet.http.HttpServletResponse;  
import org.springframework.lang.Nullable;  

public interface LocaleResolver {  
    Locale resolveLocale(HttpServletRequest var1);  

    void setLocale(HttpServletRequest var1, @Nullable HttpServletResponse var2, @Nullable Locale var3);  
}

다음 그림처럼 구현체는 여러 가지가 있다.

#### LocaleResolver 변경

만약 Locale 선택 방식을 변경하려면 LocaleResolver 의 구현체를 변경해서 쿠키나 세션 기반의 Locale 선택 기능을 사용할 수 있다.
예를 들어서 고객이 직접 Locale 을 선택하도록 하는 것이다.
관련해서 LocaleResolver 를 검색하면 수 많은 예제가 나오니 필요한 분들은 참고하자...!

3. 요약

메시지, 국제화에 대해 알아보았다.
다음은 Validation 기능에 대해 알아본다.

728x90
Comments