다국어 지원 서비스 개발
국내뿐만 아니라 해외에서도 서비스를 운영한다면 다국어 제공이 필수적이다. '주문' 기능을 담당하는 버튼은 영어로 'Order', 일본어로 '呪文'라고 보여야 할 것이다. 이를 위해 각 국가별 웹 사이트를 만드는 것은 좋은 방법일까? '주문'이라는 기능이 다른 이름으로 대체된다면 모든 국가별 소스 코드를 수정해줘야 한다. 만약 놓치는 국가가 있다면 큰일이다.
i18n (w. Java & Spring)
다국어 서비스를 제공하기 위해서 많은 개발자들이 i18n 라이브러리를 사용한다. 전체 이름은 internationalization(국제화)이며 맨 앞 i, 맨 뒤 글자 n 사이에 18글자가 있다는 의미로 i18n이라고 부른다. 대표적으로 React 진영에선 i18n-next, Angular 진영에서는 Angular i18n을 사용한다. 하지만 스프링부트를 사용하는 경우 자체 지원을 제공하므로 별도의 외부 라이브러리 설치가 필요하지 않다.
MessageSource 인터페이스를 사용하면 된다. ApplicationContext가 로드되면 컨텍스트에 정의된 MessageSource Bean을 자동으로 검색한다. Bean 이름은 반드시 messageSource여야 하며, 만약 이러한 Bean을 발견하면 getMessage와 같은 메서드에 대한 모든 호출이 messageSource로 위임된다.
다국어 서비스 구현
간단한 구현 코드를 보며 이해해 보자.
우선 Config 클래스를 만들어 MessageSource에 대한 설정을 해줘야 한다. 또 이 설정을 하기 위해 네 가지 Bean을 등록해줘야 한다.
- localeResolver
- localeChangeInterceptor
- messageSourceAccessor
- messageSource
@Configuration
public class I18nConfig implements WebMvcConfigurer {
@Value("${spring.messages.basename}")
private String basename;
@Value("${spring.messages.encoding}")
private String encoding;
private static final String LOCALE_HEADER = "Lan";
@Bean
public LocaleResolver localeResolver() {
HeaderLocaleResolver localeResolver = new HeaderLocaleResolver();
localeResolver.setDefaultLocale(Locale.KOREAN);
return localeResolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName(LOCALE_HEADER);
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
@Bean
public MessageSourceAccessor messageSourceAccessor() {
return new MessageSourceAccessor(messageSource());
}
@Bean
public MessageSource messageSource() {
YamlMessageSource ms = new YamlMessageSource();
ms.setBasename(basename);
ms.setDefaultEncoding(encoding);
ms.setAlwaysUseMessageFormat(true);
ms.setUseCodeAsDefaultMessage(true);
ms.setFallbackToSystemLocale(true);
return ms;
}
private static class YamlMessageSource extends ResourceBundleMessageSource {
@Override
protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
return ResourceBundle.getBundle(basename, locale, YamlResourceBundle.Control.INSTANCE);
}
}
private static class HeaderLocaleResolver extends AcceptHeaderLocaleResolver {
@Override
public Locale resolveLocale(HttpServletRequest request) {
final String requestHeader = request.getHeader(LOCALE_HEADER);
return (null == requestHeader || requestHeader.isEmpty()) ? super.resolveLocale(request) : new Locale(requestHeader);
}
}
}
WebMvcConfigurer 인터페이스를 상속 받아 addInterceptors 메서드를 구현해야 한다. 이는 Spring MVC의 인터셉터를 등록하는 역할을 한다. 인터셉터란 말 그대로 클라이언트의 요청이 컨트롤러에 도달하기 전에 요청과 응답을 가로채 수정할 수 있는 기능을 제공한다. InterceptorRegistry 객체를 매개변수로 받아서 LocaleChangeInterceptor를 추가하면, 이 인터셉터가 클라이언트의 요청에서 Lan 헤더 값을 읽어와서 해당 값으로 Locale을 변경하는 역할을 한다.
이렇게 한 이유는 AcceptHeaderLocaleResolver를 상속받은 HeaderLocaleResolver 메서드 때문이다. AcceptHeaderLocaleResolver은 LocaleResolver를 상속받아 구현한 클래스로 요청에서 Accept-Language라는 헤더의 값으로 Locale을 전달받는다. 대부분 브라우저의 요청 헤더를 보면 해당 값이 세팅되어 있는 것을 확인할 수 있다.
만약 Accept-Language가 아닌 Global이라는 헤더로 보내고 싶다면? Lan이라는 헤더로 보낼 수밖에 없다면 어떻게 해야 할까? 그래서 생각한 것이 HeaderLocaleResolver이다. Lan이라는 헤더의 값을 가져와서 해당 값이 비어있거나 null이라면 Accept-Language 헤더의 값을 사용하고, 그렇지 않다면 보내준 값을 사용하도록 했다. 이렇게 하면 원하는 헤더명으로 요청을 받을 수 있다. localeResolver Bean을 등록할 때 HeaderLocaleResolver를 사용하겠다고 해주면 스프링이 이를 이용한다. 기본 값으로는 한국어를 설정해 줬다. 만약 해당 번역이 없더라도 값은 존재해야 하기 때문이다.
본격적으로 messageSource Bean을 등록하기 전에 YamlMessageSource에 대해 살펴보고 가자. 스프링의 MessageResource 파일은 .properties 파일을 사용하여 국제화 메시지를 처리하기 때문에 yml 파일 형식을 사용하기 위해서는 따로 처리가 필요하다. 상속받은 ResourceBundleMessageSource는 국제화를 제공하기 위한 MessageResourceBundle을 관리하는 클래스이다. MessageResourceBundle은 외부 리소스 파일에 저장된 YAML 번역 파일들을 로드하고, 정의된 키를 이용하여 메시지를 가져올 수 있다. 즉, YamlMessageSource 객체는 YAML 형식의 메시지 파일을 읽을 수 있는 MessageSource 구현체이다.
YamlResourceBundle를 사용하기 위해서는 dependency를 implement 해줘야 한다.
implementation 'dev.akkinoc.util:yaml-resource-bundle:2.11.0'
messageSource Bean에서는 이 YamlMessageSource 객체에 필요한 프로퍼티를 세팅해준다. 이 정보는 application.yml 파일에 적혀있다.
- basename: MessageSource에서 yml 파일을 찾을 때 사용할 기본 이름
- defaultEncoding: MessageSource에서 사용할 기본 인코딩
- alwaysUseMessageFormat: 메시지 포맷을 항상 사용할지 여부
- useCodeAsDefaultMessage: yml 파일에 해당 code에 맵핑된 값이 없을 때 NoSuchMessageException 대신 코드를 메시지로 사용할 것인지 여부
- 이걸 true로 하면 만약 특정 언어에 대한 번역이 없더라도 예외가 터지는 것을 예방할 수 있다.
- fallbackToSystemLocale: Locale에 대한 yml 파일을 찾지 못할 경우 시스템 설정 Locale을 사용할 것인지 여부
마지막으로 messageSourceAccessor Bean 등록 시에 messageSource를 넘겨주었는데 더 다양한 getMessage 메서드 포함하고 있기 때문이다. 필요하지 않다면 이 메서드는 작성하지 않아도 된다.
spring:
messages:
basename: i18n/message
encoding: UTF-8
application.yml을 위와 같이 작성하면 번역 yml 파일 구조는 아래와 같아야 한다.
yml 파일명은 message 뒤 언더바("_")와 국가명으로 지어야 한다. 읽어들일 때 basename + lang으로 찾기 때문이다. 각각 파일의 내용은 아래와 같다.
// message_en.yml
a001: "Hello!"
// message_ja.yml
a001: "こんにちは!"
// message_ko.yml
a001: "안녕!"
a001은 내가 만든 임시 코드이다. 원하는 key를 사용하면 된다.
이제 MessageSourceAccessor를 사용하여 국제화 기능을 제공하는 서비스를 만들어보자.
@Service
@RequiredArgsConstructor
public class I18nService {
private final MessageSourceAccessor ms;
public String translate(String code, String message, Locale country) {
final String translatedMessage = (null != country)
? ms.getMessage(code, country) : ms.getMessage(code);
return translatedMessage.equals(code) ? message : translatedMessage;
}
}
translate 메서드는 인자로 code(예를 들면 a001), message(만약 번역본이 없다면 사용할 기본 값), country(번역 기준이 될 국가)를 받는다. 만약 country가 null이 아니라면 code와 country 정보를 모두 getMessage 인자로 사용하고, null이라면 code만을 사용한다. 이는 getMessage 메서드가 Overloading 메서드들이기 때문이다. 같은 이름이지만 인자에 따라 다른 메서드를 호출한다.
이제 이를 이용하여 컨트롤러에서 번역된 문자열을 반환하는 메서드를 만들어보자.
@RestController
@RequiredArgsConstructor
public class Controller {
private final I18nService i18nService;
@GetMapping("/hello")
public String sayHello(
@RequestParam(name = "code") String code
) {
return i18nService.translate(code, "기본 메세지", null);
}
}
code 라는 이름으로 파라미터를 전달받도록 했다.
올바르게 작동하는지 검증
이제 요청을 보내서 테스트를 해보자.
결과는 "안녕!"이다. 왜일까 잠시 생각해보자. country 값을 null로 고정하고 요청을 보냈으니 당연히 localeResolver Bean에 디폴트 값으로 설정해 준 한국어가 사용된 것이다.
설정해 준 대로 헤더명 Lan으로 Locale 정보를 받아오기 위해 컨트롤러 메서드를 수정하자.
@RestController
@RequiredArgsConstructor
public class Controller {
private final I18nService i18nService;
@GetMapping("/hello")
public String sayHello(
@RequestParam(name = "code") String code,
@RequestHeader(name = "Lan") String lan
) {
return i18nService.translate(code, "기본 메세지", new Locale(lan));
}
}
이제 다시 테스트해보자.
결과는 "Hello!"이다. 일본어는 어떨까?
결과는 "こんにちは!"이다. 국제화가 잘 되었다.
예외 케이스 검증
어떤 예외 케이스가 있을까? 요청 파라미터로 넘어온 code가 존재하지 않을 때이다.
결과는 "기본 메세지"이다. translate 메서드에서 이에 대한 처리를 해줬기 때문이다. translatedMessage가 code와 같다면 default로 설정한 문자열을 반환하도록 했다. 이 조건은 앞서 설정한 useCodeAsDefaultMessage 프로퍼티 때문에 성사될 수 있다. 요청한 code가 yml 파일에 존재하지 않기 때문에 messageSourceAccessor는 translatedMessage를 "a002"라는 문자열 그대로 반환한다.
'Java & Kotlin & Spring' 카테고리의 다른 글
스프링 ServiceLocatorFactoryBean으로 팩토리 메소드 패턴 구현 (1) | 2023.08.23 |
---|---|
반복되는 필드를 모아 Entity를 만드는 @MappedSuperClass (1) | 2023.08.16 |
flatMap과 map 구분해서 사용하기 (0) | 2023.03.23 |
Mac에서 JDK 제거하기 (0) | 2022.12.17 |
댓글