다국어 처리의 모든 것!

maketheworldwise·2022년 5월 27일
0


🌑 . 프로젝트 동기

회사의 서비스는 글로벌 서비스이기에 다국어 지원이 필요했다. 스프링의 다국어 처리는 어떤 프로젝트던지 많이 활용하기에 이번에 정리도할겸 별도로 구현해보고자 한다.

🌒 . 시작하기 전에 알아보자!

i18n & i10n

보통 다국어 처리를 한다고 한다면 함께 검색되는 단어가 바로 i18n이다. 도대체 무엇을 의미하는 걸까?

정말 어이없게도 i18n은 Internationalization 단어가 i와 n 사이에 18글자가 있다는 의미로 줄여놓은 단어다. (별다줄..⭐️) 단어를 해석해보면 알 수 있듯이, 국제화라는 의미로 - 다양한 언어 및 지역에 적용할 수 있도록 프로그램을 설계하는 프로세스를 의미한다.

각 지역과 언어에 맞게 서비스를 제공(번역, 인코딩, 문자열 치환)할 수 있도록 개발하되 - 코드의 변경 없이도 지원을 하도록 구성하는게 가장 중요하다고 볼 수 있다.

그리고 i10n라는 단어도 존재한다. 역시 마찬가지로 Localization을 줄여서 만든 단어다. 현지화라고 한다면, 우리가 해외로 여행갈 때를 생각해보면 된다.

한국에서는 통화기호를 원을 사용하지만, 미국에서는 달러를 사용한다. 심지어 우리나라는 'yyyy-mm-dd' 형식을 사용하지만, 다른 나라에서는 'dd-mm-yyyy' 날짜 형식을 사용한다. 즉, 각 나라마다 표현하는 방법이 모두 다르다는 것이다.

내가 살펴본 블로그에 따르면, i18n은 i10n에 큰 영향을 미치기 때문에 현지화 중심으로 결과물을 변경하는 것은 글로벌하게 제공을 의도하는 개발 및 디자인하는 것보다 어렵고 시간과 비용이 많이 소비되기에 i18n은 설계 및 개발 프로세스 초기 단계에서 실시해야한다고 한다.

ko vs ko_KR

아직 본격적인 내용을 정리하지는 않았으나, 일반적으로 리소스 파일에는 'ko'와 'ko_KR'라는 문자열이 붙는다. 어떤 차이가 있을까?

이 내용은 Locale과 관련있다. Locale은 사용자 인터페이스에서 사용되는 언어, 지역, 출력 형식 등을 의미한다. ('ko'는 ISO 639-1 표준을 따르고 'KR'은 ISO 3166-1 표준 형식을 따른다고 한다.)

아무튼, 여기서 내가 얻고자하는 내용은 Locale 명명 방법 아니니까 간단하게 살펴보고 - 개발할 때 이 두 개의 차이에 집중해보자.

차이는 정말 간단하다. 바로 Locale 정보를 어떤 순서로 찾을지에 있다.

예를 들어 Locale이 en_US일 경우에는 message_en_US > message_en > messages 순으로 찾는다고 한다.

🌓 . 구현해보자!

message*.properties

크게 어려운 것은 없다. 그저 각 언어별로 파일을 구성해주면 된다.

여기서 중요한점! 비어있어도 상관없으니 messages.properties 라는 기본 프로퍼티 파일이 필요하다는 것을 잊지말자. 처음에 기본 파일을 추가하지 않은 채로 여러 번 시도했었으나 제대로 동작하지 않았던 문제가 있었다. 해당 부분은 뒤에서 내가 직면한 버그 항목에서 다시 다루겠다.

# resource/i18n/messages/message.properties
#  resource/i18n/messages/message_ko_KR.properties
say.hello=안녕!
#  resource/i18n/messages/message_en_US.properties
say.hello=hi!

💡 Unsupported character for the charset 'ISO-8859-1'

혹여나 나와 동일하게 IntelliJ를 사용하는 개발자라면, 프로퍼티 파일에 한글을 사용하면 위와 같은 경고 문구가 보이는데, 이는 프로젝트의 환경 설정에서 기본 인코딩을 UTF-8로 수정해주면 된다.

File > Settings > Editor > File Encoding > 'Default encoding for properties' to UTF-8

application.yml

먼저 application.yml 파일에 필요한 정보를 기입해보자. 보통 대부분의 블로그에서는 basename과 encoding에 대한 설정만 하는 편인데, 그 외의 값도 설정이 가능하다. (MessageSourceAutoConfiguration 파일을 열어보면 알 수 있다.)

# Set whether to always apply the MessageFormat rules, parsing even messages without arguments.
spring.messages.always-use-message-format=false 
# Comma-separated list of basenames, each following the ResourceBundle convention.
spring.messages.basename=messages 
# Loaded resource bundle files cache expiration, in seconds. When set to -1, bundles are cached forever.
spring.messages.cache-seconds=-1
# Message bundles encoding.
spring.messages.encoding=UTF-8
# Set whether to fall back to the system Locale if no files for a specific Locale have been found.
spring.messages.fallback-to-system-locale=true 

알아보기 힘드니, 해석하자면 다음과 같은 설정들이 있다.

  • MessageFormat을 전체 메시지에 적용할 것인지에 대한 여부
  • Message 파일을 여러 개 사용할 경우 콤마로 구분해서 여러 개의 basename을 설정
  • 캐시 주기 설정 (기본 값은 forever)
  • 인코딩 방식
  • 감지된 Locale에 대한 파일이 없는 경우 System Locale 사용 여부
  • Message 파일을 차지 못했을 때, 예외 처리 대신 메시지 코드를 그대로 반환 여부
spring:
  messages:
    basename: i18n/messages/message, i18n/exceptions/exception
    encoding: UTF-8
    cache-duration: 30
    always-use-message-format: true
    use-code-as-default-message: true
    fallback-to-system-locale: true

LocaleConfig

다국어 처리를 하기 위한 작업에서는 Locale 설정을 하는 작업이 80%라고 생각하면 될 듯하다. 일단 대부분의 블로그에서 다룬 내용을 기준으로 진행해보자.

스프링에서 제공하는 LocaleChangeInterceptor를 이용하여 lang이라는 파라미터가 요청에 있을 경우 해당 값을 읽어 Locale 정보를 변경하도록 구성하고, 기본 Locale 정보는 Session에서 읽어와 저장하도록 SessionLocaleResolver를 지정해보자.

여기서 LocaleResolver를 빈으로 등록할 때 'ko'나 'ko_KR' 둘 중 하나를 지정해야하는데, 나는 조금 더 구체적으로 표현하는 편이 좋다고 판단하여 Locale.KOREA (ko_KR)를 이용했다.

@Configuration
public class LocaleConfig implements WebMvcConfigurer {

	@Bean
	public LocaleResolver localeResolver() {
		SessionLocaleResolver locale = new SessionLocaleResolver();
		locale.setDefaultLocale(Locale.KOREA); // ko_KR
		return locale;
	}

	// [GET] /say/hello?lang=ko_KR
	// [GET] /say/hello?lang=en_US
	@Bean
	public LocaleChangeInterceptor localeChangeInterceptor() {
		LocaleChangeInterceptor locale = new LocaleChangeInterceptor();
		locale.setParamName("lang");
		return locale;
	}

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		registry.addInterceptor(localeChangeInterceptor());
	}
}

LocaleResolver 외에도 다른 Resolver들이 존재한다. 상황에 따라 적절한 Resolver를 설정하여 사용하면 된다.

  • AbstractLocaleContextResolver
  • AbstractLocaleResolver
  • AcceptHeaderLocaleResolver
  • CookieLocaleResolver
  • FixedLocaleResolver
  • SessionLocaleResolver

앞에서 구현한 lang이라는 파라미터를 요청에 담는 형식은 나름대로 장점은 있지만, 개인적으로는 그리 좋다고 생각하지 않는다. 그 이유는 lang이라는 파라미터 외에도 다른 파라미터들이 API에서 요구될 수 있는데, 굳이 lang이라는 파라미터를 추가하여 URI의 길이(?)를 늘릴 필요가 있을까 싶어서다.

그러니 일단 이번에 SessionLocaleResolver로 구성한 내용은 master 브랜치에 푸시해두고, 새로운 브랜치를 만들어 lang을 파라미터로 받지 않고 AcceptHeaderLocaleResolver로 구현해보자.

처음에는 추가적으로 내용을 구성할 필요가 있을까 싶었지만, LocaleResolver를 별도로 빈을 등록하지 않으면 AcceptHeaderLocaleResolver를 기본 Resolver로 이용하기 때문에 LocaleConfig 클래스가 필요가 없다. 그러니 LocaleConfig 클래스를 삭제해주면 된다!

MessageSource

나와 같이 Spring Boot로 프로젝트를 구성한 경우에는 이 항목은 건너뛰어도 된다.

Spring Boot에서는 ResourceBundleMessageSource가 이미 자동으로 빈으로 등록되어있어, 굳이 별도로 MessageSource를 구성하여 빈을 등록해줄 필요가 없다. 하지만 만약 Spring Boot를 사용하지 않는 사람이라면, 다음과 같이 별도로 빈을 등록해주어야 한다.

@Bean
public MessageSource messageSource() {
	ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
	messageSource.setBasename("classpath:i18n/messages/message");
	messageSource.setDefaultEncoding("UTF-8");
	messageSource.setAlwaysUseMessageFormat(true);
	messageSource.setUseCodeAsDefaultMessage(true);
	messageSource.setFallbackToSystemLocale(true);
	return messageSource;
}

위에서는 직접 basename과 encoding의 정보를 문자열로 넣어서 처리했지만, 코드에서 문자열로 처리하는 것은 그리 좋은 습관이 아니므로, 앞에서 구성한 프로퍼티의 값을 파라미터로 받아 처리를 하거나 별도로 작업해주는 편이 좋다. (사실 하단의 방법도 그리 좋은 방법은 아니라고 생각한다. 프로퍼티의 값들을 받아오는 클래스를 생성하고 그를 이용하는 방법이 가장 좋지 않을까 싶다. 🤔)

@Bean
public MessageSource messageSource(
    @Value("${spring.messages.basename"}) String basename,
    @Value("${spring.messages.encoding"}) String encoding, 
    // 그 외 프로퍼티 값들
    ) {
    // (생략 ...)
}

추가적으로 MessageSource의 구현체인 ResourceBundleMessageSource가 아닌 ReloadableResourceBundleMessageSource를 이용하는 경우도 있다. 이 구현체를 이용하면 애플리케이션 실행 중에 메시지 리소스 변경을 다시 Reload하게 하여 변경사항을 적용할 수 있다. 서비스의 유연성을 고려한다면 ReloadableResourceBundleMessageSource를 빈으로 등록하는 것도 하나의 방안이 될 수 있을 것 같다.

그리고 몇몇 블로그에서는 MessageSourceAccessor를 빈으로 등록하여 사용하는 경우도 있는데, 다양한 getMessage() 메서드를 제공하여 MessageSource를 직접 사용할 때보다 더 편리하게 사용할 수 있다고 한다. 이번 프로젝트에서는 직접 getMessage() 메서드를 구현했지만, 실제 서비스에는 MessageSourceAccesor를 빈으로 등록하여 사용하는 편이 더 좋아보인다.

@Bean
public MessageSource messageSource() {
	ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasename("classpath:i18n/messages/message");
    // (생략...)
}

@Bean
public MessageSourceAccessor messageSourceAccessor() {
	return new MessageSourceAccessor(messageSource());
}

MessageSourceController

설정하는 부분은 모두 끝났다. 이제 제대로 동작을 하는지는 확인해봐야하지 않겠는가? 테스트 코드를 작성해보자.

@Slf4j
@RequiredArgsConstructor
@RestController
public class MessageSourceController {

	private final MessageSource messageSource;

	@GetMapping("/say/hello")
	public String getMessage() {
		return getMessage("say.hello");
	}

	private String getMessage(String code) {
		return getMessage(code, null);
	}

	private String getMessage(String code, Object[] args) {
		log.info("현재 Locale 정보 : " + LocaleContextHolder.getLocale());
		return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
	}
	
}

테스트 코드는 lang이라는 파라미터로 처리할 경우와 Accept-Language 헤더로 처리할 경우로 나뉜다. (참고로 Locale 정보는 'ko_KR'이 아닌 'ko-kr'로 넣어서 처리해야한다는 점을 주의하자! 😤)

@WebMvcTest(MessageSourceController.class)
class MessageSourceControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@Test
	@DisplayName("[GET] /say/hello?lang=ko-kr")
	public void sayHelloInKo() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/say/hello")
				.contentType(MediaType.APPLICATION_JSON)
				.param("lang", "ko-kr"))
			.andExpect(status().isOk())
			.andExpect(content().string("안녕!"))
			.andDo(print());
	}

	@Test
	@DisplayName("[GET] /say/hello?lang=en-us")
	public void sayHelloInEn() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/say/hello")
				.contentType(MediaType.APPLICATION_JSON)
				.param("lang", "en-us"))
			.andExpect(status().isOk())
			.andExpect(content().string("hi!"))
			.andDo(print());
	}

}
@WebMvcTest(MessageSourceController.class)
class MessageSourceControllerTest {

	@Autowired
	private MockMvc mockMvc;

	@Test
	@DisplayName("Accept-Language: ko-kr, [GET] /say/hello")
	public void sayHelloInKo() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/say/hello")
        		.header(HttpHeaders.ACCEPT_LANGUAGE, "ko-kr")
				.contentType(MediaType.APPLICATION_JSON))
			.andExpect(status().isOk())
			.andExpect(content().string("안녕!"))
			.andDo(print());
	}

	@Test
	@DisplayName("Accept-Language: en-us, [GET] /say/hello")
	public void sayHelloInEn() throws Exception {
		mockMvc.perform(MockMvcRequestBuilders.get("/say/hello")
        		.header(HttpHeaders.ACCEPT_LANGUAGE, "en-us")
				.contentType(MediaType.APPLICATION_JSON))
			.andExpect(status().isOk())
			.andExpect(content().string("hi!"))
			.andDo(print());
	}

}

🌔 . 구현 결과

구현 화면도 두 가지다. 하나는 lang 파라미터로 처리하는 경우와 헤더로 처리하는 경우다. 이미지가 작아서 제대로 안보일 수도 있지만, 제대로 동작한다!

🌕 . 내가 직면한 버그

내가 직면한 🐞 버그에 대해서 정리해보자. 가장 많이 발생하는 에러중에 하나가

ResourceBundle [...] not found for MessageSource: Can't find bundle for base name ...

일 것이다. 대부분 이 문제는 basename을 제대로 설정하지 않아서 발생한다.

물론 내가 맞딱뜨린 문제도 역시 basename을 제대로 설정하지 않은 탓이였지만, 위와 같은 에러가 발생하지 않아 뭐가 문제인지 몰라 꽤나 고생을 했다. 참고로 이 문제는 위에서 언급한 '왜 messages.properties' 기본 파일을 구성해야 할까?'라는 의문과 연관되어있다.

먼저 내가 처음에 구성한 내용은 프로퍼티 파일이 단 두 개만 존재했고, 이 두개를 basename으로 등록해놓았었다.

  • message_ko_KR.properties
  • message_en_US.properties
spring:
  messages:
    basename: i18n/messages/message_ko_KR, i18n/messages/message_en_US
    encoding: UTF-8

에러는 안나오는데.. 왜 내가 설정한 Locale 정보대로 결과가 변하지 않을까? 도대체 어떤 문제지? 😭

lang 파라미터로 'en-us'를 넘겨보며 디버깅을 해보았다. 그리고 계속 따라가다보니 이상한 부분을 발견할 수 있었다. 바로 resourceName이 'i18n/messages/message_ko_KR_en_US.properties' 라는 것이다.

이 부분에서 '아! basename 설정이 잘못되었구나!' 라는 것을 깨닫고 다음과 같이 basename을 수정했다. (간단하게 나의 방식으로 basename 설정 방법을 이해하자면, basename에 들어가야하는 값은 prefix이고 'ko_KR'라는 suffix는 현재 Locale 정보를 기반으로 나중에 뒤에 합쳐지는 거라고 보면 될 것 같다.)

spring:
  messages:
    basename: i18n/messages/message
    encoding: UTF-8

그리고 그제서야 NoSuchMessageException 에러가 발생했다. 이제 이 문제를 해결해야하는데, 결론만 말하자면 - 바로 'message.properties' 라는 기본 프로퍼티 파일이 없어서 발생하는 문제였다.

이 파일은 시스템의 언어나 지역에 맞는 프로퍼티 파일이 존재하지 않을 경우에 사용하기 위한 파일이다. 생각해보면 간단한 문제다. 내 방식대로 생각해본다면 - prefix가 'i18n/messages/message'라면 'i18n/messages/message.properties'가
도 있어야 하는데 해당 파일이 없다는 것은 이상하지 않은가?! 그리고 이 기본 프로퍼티 파일의 목적이 지역에 맞는 프로퍼티 파일이 없을 경우에 사용하는 것인데 이 파일이 없다는 것은 이상하다!
라는 것이다.

결국, 내가 직면한 버그는 올바르지 않은 basename과 기본 프로퍼티의 부재에서 발생한 문제였다.


추가적으로 이건 조금 더 알아봐야하는 내용이지만, ReloadableResourceBundleMessageSource를 직접 빈으로 등록하는 방법으로 진행했을 때는 기본 프로퍼티 파일이 없어도 제대로 동작하는 것을 확인할 수 있었다. 아마 리소스 변경을 다시 Relaod하는 과정에서 마법같은 일이 벌어지지 않았을까 싶다. 🤔

🌖 . Yaml 파일로도 관리할 수 있을까?

번외의 이야기도 해보자. 일단 김영한님이 말씀해주시길, (비록 9달 전의 기록이지만) 메시지 리소스 파일은 Yaml 파일을 사용할 수 없다고 했다.

물론 그렇다고 해서 절대 Yaml 파일로 다루지 못한다는 것은 아닌 것 같다. 내가 찾은 내용에 따르면 Yaml 파일로도 관리할 수 있는 라이브러리가 존재한다는 것을 찾아내었다. 이왕 찾은 김에 구현도 해보자.

현재의 이 라이브러리는 버전업이되고 다른 곳으로 이동되어있는 것 같다. 간단하게 구현하는 것이니, 초기의 라이브러리를 이용해서 구현해보자.

우선 라이브러리를 추가해주자.

implementation group: 'net.rakugakibox.util', name: 'yaml-resource-bundle', version: '1.1'

그리고 프로퍼티의 파일들을 모두 Yaml 확장자로 변경해준 뒤, Yaml용 MessageSource를 빈으로 등록해주자.

@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "spring.messages")
public class MessagesProperty {

	private String basename;
	private String encoding;
	private int cacheDuration;
	private boolean alwaysUseMessageFormat;
	private boolean useCodeAsDefaultMessage;
	private boolean fallbackToSystemLocale;

	@Bean
	public MessageSource messageSource() {
		YamlMessageSource messageSource = new YamlMessageSource();
		messageSource.setBasename(this.basename);
		messageSource.setDefaultEncoding(this.encoding);
		messageSource.setCacheSeconds(this.cacheDuration);
		messageSource.setAlwaysUseMessageFormat(this.alwaysUseMessageFormat);
		messageSource.setUseCodeAsDefaultMessage(this.useCodeAsDefaultMessage);
		messageSource.setFallbackToSystemLocale(this.fallbackToSystemLocale);
		return messageSource;
	}

	private static class YamlMessageSource extends ResourceBundleMessageSource {
		@Override
		protected ResourceBundle doGetBundle(String basename, Locale locale) throws MissingResourceException {
			return ResourceBundle.getBundle(basename, locale, YamlResourceBundle.Control.INSTANCE);
		}
	}
}

그리고 이 상태로 구동해보자. 일단 동작은 하긴 하는데, 기존에 작성한 테스트 코드에서는 NoMessageException 에러가 나면서 통과되지 않고, 'ko_KR' Locale로 결과를 받아오는데 'messages_ko_KR.properties'가 아닌 기본 프로퍼티 파일에서의 결과를 가져오는 문제가 있었다. (간단히 확인만 해보는 것이기 때문에 에러를 해결하지는 하지 않았다.)

Yaml 파일로 관리할 경우에는 물론 편리한점도 있지만, 위의 경우처럼 추가한 라이브러리에 문제가 생겼을 때에 대한 대처가 어렵고, 의존(?)해야한다는 점이 마음에 걸린다. 기본적으로 스프링이 제공하는 방법으로 구성하는 편이 가장 좋지 않을까 싶다.

🌗 . 이 글의 레퍼런스

🌘 . 저를 응원해주세요!

이 글이 도움이 되셨다면 커피 한잔 후원해주세요!

profile
세상을 현명하게 이끌어갈 나의 성장 일기 📓

0개의 댓글