[스프링 MVC 2편] 메시지, 국제화
포스트
취소

[스프링 MVC 2편] 메시지, 국제화

프로젝트 설정

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • message
      • Name
        • message
      • Package name
        • hello.message
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • Spring Web
      • Thymeleaf
      • Lombok

추가 설정

  • 이전 프로젝트인 form에서 일부 소스를 가져오자.
    • .java 파일의 패키지는 form 부분을 message으로 변경하자.
    • 참고
  • 가져올 목록
    • src/main
      • java/hello/itemservice
        • domain/item
          • Item.java
          • ItemRepository.java
      • web/item/basic
        • BasicItemController.java
    • resources
      • static
        • css
          • bootstrap.min.css
        • index.html
    • templates/basic
      • addForm.html
      • editForm.html
      • item.html
      • items.html
  • 마지막으로 메시지, 국제화 예제에 집중하기 위해서 복잡한 체크, 셀렉트 박스 관리 기능은 모두 제거하자.
    • java
    • html

메시지, 국제화 소개

메시지

  • 서비스를 개발하다 보면 특정 문구가 문제가 될 때가 있다.
  • 보통 로그인을 시도할 때 5회 정도 틀리면 대략 “5회 이상 로그인이 실패하였습니다.”처럼 문구가 노출된다.
    • 물론 이런 공통적인 문구는 개발할 때도 공통으로 관리할테니 수정을 하게 되더라도 크게 문제가 될 일은 없다.
  • 하지만 고유 명칭이 필요한 경우에는 어떨까?
    • 만약 쇼핑몰인데 뭔가의 행사 상품을 “기획 상품”이라고 판매한다고 치자.
    • 하지만 이걸 “행사 상품”으로 바꿔야 하게 된다면 어떻게 될까?
    • 문구가 한,두군데 있는게 아니라서 함부로 바꿀 수가 없다.
  • 일괄적으로 바꾸면 안 될까?
    • 서비스가 정말 작은 곳이라도 위험한 행위다.
    • 예상치 못한 곳에서 해당 문구를 이미 사용 중일수도 있어서 기존 로직에 문제가 생길 수도 있다.
    • 그나마 한글을 바꾸는 거면 뭐 문법이 안 맞거나 주석이 고쳐지는 정도로 끝날 가능성이 있다.
    • 하지만 영어를 일괄 변경하게 된다면 클래스명이나 필드명이 안 맞아서 빌드가 안 되거나 API 스펙이 변경되버리는 대참사가 날 수도 있다.
  • 이럴 때 다양한 메시지를 한 곳에서 관리하도록 하는 기능인 메시지 기능을 사용하면 된다.
    • messages.properties처럼 메시지용 관리 파일을 만들고 메시지를 정의하면 된다.
      • item.itemName=상품명
    • 그런 다음에 실제 HTML에서 아래 처럼 사용하면 된다.
      • <label for="itemName" th:text="#{item.itemName}"></label>

국제화

  • 만약에 해당 서비스가 영어권에서도 제공된다면 어떨까?
    • en.item.itemName=Item Name처럼 일일이 항목을 관리하는 것도 쉬운 일은 아니다.
  • 그래서 보통 같은 내용으로 파일명 뒤에 _(언어별 코드)를 붙여서 각 언어별 메시지를 관리한다.
  • 어떤 나라에서 접근했는지를 파악하는 것은 다양한 방법이 있다.
    • 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.propertiesmessages_ko.properties처럼 파일명에 언어 정보를 추가해줘야 한다.
      • 만약 찾을 수 있는 국제화 파일이 없다면 언어 정보가 없는 기본 파일인 messages.properties을 사용한다.
    • 파일의 위치는 /resources/basics.properties로 하면 된다.
    • 여러 파일을 한번에 지정할 수 있다.
      • 여기서는 messageserror로 지정했다.
    • defaultEncoding
      • 인코딩 정보를 지정한다.
      • 일반적으로 UTF-8을 사용한다.

스프링 부트

  • 스프링 부트를 사용하면 스프링 부트가 MessageSource를 자동으로 스프링 빈으로 등록한다

스프링 부트 메시지 소스 설정

  • 스프링 부트를 사용하면 다음과 같이 메시지 소스를 설정할 수 있다.
    • spring.messages.basename=messages,config.i18n.messages
    • application.properties에서 설정한다.
    • 기본 값은 messages다.
  • MessageSource를 스프링 빈으로 등록하지 않고, 스프링 부트와 관련된 별도의 설정을 하지 않으면 messages라는 이름으로 기본 등록된다.
    • 그래서 별도 설정을 하지 않아도 messages_en.properties, messages_ko.properties, messages.properties 파일을 생성하면 자동으로 인식된다.

실습을 위한 메시지 만들기

  • 파일명은 massage가 아닌 massages로 마지막에 s가 붙으니 주의하자.

  • messages.properties

hello=안녕
hello.name=안녕 {0}
  • messages_en.properties
hello=hello
hello.name=hello {0}

스프링 메시지 소스 사용

  • MessageSource 인터페이스는 코드를 포함한 일부 파라미터로 메시지를 읽어오는 기능을 제공한다.
  • 스프링이 제공하는 메시지 소스를 어떻게 사용하는지 테스트 코드를 통해서 학습해보자.

테스트 생성

  • 만약 인텔리제이에서 오류가 ``라고 발생한다면 아래와 같이 진행해보자.
    • 설정 → 에디터 → 파일 인코딩으로 이동
    • 전역 인코딩, 프로젝트 인코딩이 UTF-8인지 확인
    • 프로파일 파일에 대한 디폴트 인코딩이 UTF-8인지 확인
    • 명확한 Native에서 ASCII로의 변환 체크
  • 설정을 바꾸면 한글이 ??로 바뀌어져 있을테니 다시 확인해보자.
@Autowired
MessageSource ms;

@Test
void helloMessage() {
  String result = ms.getMessage("hello", null, null);
  assertThat(result).isEqualTo("안녕");
}
  • 메시지가 없는 경우에는 NoSuchMessageException이 발생한다.
@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("기본 메시지");
}
  • 메시지의 {0} 부분은 매개변수를 전달해서 치환할 수 있다.
@Test
void argumentMessage() {
  String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
  assertThat(result).isEqualTo("안녕 Spring");
}
  • 이전에 설명했듯이 국제화를 적용할 수 있다.
@Test
void searchLocale() {
  //LOCALE 정보가 없어서 messages.properties 사용
  assertThat(ms.getMessage("hello", null, null)).isEqualTo("안녕");

  //LOCALE 정보는 있지만 messages_ko.properties 없어서 messages.properties 사용
  assertThat(ms.getMessage("hello", null, Locale.KOREA)).isEqualTo("안녕");

  //basics_en.properties이 있어서 해당 파일 사용
  assertThat(ms.getMessage("hello", null, Locale.ENGLISH)).isEqualTo("hello");
}

웹 애플리케이션에 메시지 적용하기

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

메시지 수정

  • 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=취소

타임리프 메시지 적용

  • 타임리프의 메시지 표현식 #{...}를 사용하면 스프링의 메시지를 편리하게 조회할 수 있다.
  • #{label.item}처럼 사용하면 된다.

  • 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>
      <h4 class="mb-3">상품 입력</h4>
      <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='@{/basic/items}'|"
                    type="button" th:text="#{button.cancel}">취소</button>
          </div>
        </div>
      </form>
    </div> <!-- /container -->
  </body>
</html>
  • 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='@{/basic/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='@{/basic/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='@{/basic/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='@{/basic/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="@{/basic/items/{itemId}(itemId=${item.id})}" th:text="${item.id}">회원id</a></td>
                <td><a href="item.html" th:href="@{|/basic/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>

실행해보기

  • 잘 동작하는지 확인하기 위해 messages.properties 파일의 내용을 일부분 수정해보고 정상 동작하면 다시 돌려두자.

파라미터 사용법

  • 파라미터는 다음과 같이 사용할 수 있다.
  • <p th:text="#{hello.name(${item.itemName})}"></p>

웹 애플리케이션에 국제화 적용하기

  • 이번에는 웹 애플리케이션에 국제화를 적용해보자.

메시지 수정

  • 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

테스트

  • 서버를 실행해서 크롬 브라우저로 접속해보자.
  • 크롬 브라우저 → 설정 → 언어를 검색해서 우선 순위를 변경해보자.
  • 웹 브라우저의 언어 설정 값을 변경하면 요청 시 Accept-Language의 값이 변경된다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.