Locale에 해당하는 메세지 파일이 없으면 어떻게 될까? (feat.Spring MessageSource)

김유정·2023년 3월 25일
0

들어가며

다음과 같은 상황에서는 어떤 메세지 파일에서 메세지를 가져오게 될까?
locale만 다르고 그 외 설정값은 동일하다.

상황 1.

  • locale = "en_KR"
  • 시스템 설정 언어 = 영어
  • 시스템 설정 국가 = 한국
  • 존재하는 메세지 파일 = messages.properties, messages_en.properties

상황 2.

  • locale = "ko_KR"
  • 시스템 설정 언어 = 영어
  • 시스템 설정 국가 = 한국
  • 존재하는 메세지 파일 = messages.properties, messages_en.properties

답을 말하자면,
1번은 영어용 메세지 파일에서 메세지를 가져온다.
2번은 MessageSource의 fallbackToSystemLocale 변수값과 시스템 설정 언어에 따라 달라진다. 만약 fallbackToSystemLocale가 true라면 환경설정 언어에 해당하는 영어용 메세지 파일을 가져올 것이고, false라면 기본 메세지 파일에서 메세지를 가져올 것이다.

fallbackToSystemLocale

요청받은 locale에 대한 메세지 파일이 없을 때, 시스템 locale을 사용할 것인지에 대한 옵션이다. 디폴트값은 true이다. false로 값을 변경하면, locale에 대한 메세지 파일 찾기를 실패했을 때 항상 기본 메세지 파일을 사용한다.

public class MessageSourceProperties {
	...
	/**
	 * Whether to fall back to the system Locale if no files for a specific Locale have
	 * been found. if this is turned off, the only fallback will be the default file (e.g.
	 * "messages.properties" for basename "messages").
	 */
	private boolean fallbackToSystemLocale = true;
    ... 
}

동작과정

테스트 환경

컴퓨터 환경설정에는 아래와 같이 설정되어 있는 상태이고, 기본 메세지 파일(messages.properties)과 영어 메세지 파일(messages_en.properties)만 존재하는 상태에서 locale이 한국(ko_kr)일 때, 어떤 과정을 거쳐 메세지를 반환하는지 살펴보자.

시스템 설정 언어: 영어 (en)
시스템 설정 지역: 한국 (KR)
테스트해볼 locale: LOCALE.KOREA(ko_KR)

  • messages.properties
    hello=안녕하세요!
  • messages_en.properties
    hello=Hello!
  • application.properteis
    spring.messages.basename=message/messages
    spring.messages.encoding=UTF-8
  • 테스트코드
@SpringBootTest
public class MessageTest {

    @Autowired
    MessageSource messageSource;

    @Test
    @DisplayName("locale이 KOREA일 때 테스트(변수 X)")
    void koreaMessageWithoutArgs() {
        System.out.println(messageSource.getMessage("hello", null, Locale.KOREA));
    }
}

요약

ResourceBundle과 ResourceBundleMessageSource에서 제공하는 메서드로 리소스 번들을 가져오고, 해당 리소스 번들에서 메세지 코드에 해당하는 메세지를 찾아서 반환하게 된다.

  1. AbstractMessageSource.getMessage(): getMessageInternal의 결과값을 반환한다.
  2. AbstractMessageSource.getMessageInternal(): resolveCodeWithoutArguments의 결과값을 반환한다.
  3. ResourceBundleMessageSource.resolveCodeWithoutArguments(): getResourceBundle을 통해 가져온 번들에서 메세지 코드에 해당하는 메세지를 찾아 반환한다.
  4. ResourceBundleMessageSource.getResourceBundle(): 캐시에 저장된 리소스 번들이 있다면, 해당 번들을 반환한다. 없다면 doGetBundle을 통해 생성한 리소스 번들 객체를 반환한다. 이 때 생성된 객체는 캐시에 저장한다.
  5. ResourceBundleMessageSource.doGetBundle(): getBundle을 통해 가져온 번들을 반환한다.
  6. ResourceBundle.getBundle(): getBundleImpl을 통해 가져온 번들을 반환한다.
  7. ResourceBundle getBundleImpl(): 요청받은 Locale부터 fallbackLocale까지 반복문으로 돌면서 findBundle을 통해 번들을 가져온다. 마지막에 가져온 번들을 반환한다.
  8. ResourceBundle.findBundle(): candidateLocales 리스트를 뒤에서부터 차례대로 해당 locale에 대한 번들을 생성 또는 탐색한다.

상세

MessageSource는 인터페이스이고, getMessage() 메서드는 AbstractMessageSource 에 구현되어 있다. 가장 먼저 호출되는 메서드부터 차례대로 살펴보자.

  • AbstractMessageSource.getMessage()
    @Override
	public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
		String msg = getMessageInternal(code, args, locale);
		if (msg != null) {
			return msg;
		}
		String fallback = getDefaultMessage(code);
		if (fallback != null) {
			return fallback;
		}
		throw new NoSuchMessageException(code, locale);
	}
  1. getMessageInternal을 통해 메세지를 가져온다.
  2. 가져온 메세지가 null이 아니면 바로 반환하고, null이라면 getDefaultMessage로 디폴트 메세지를 가져온다.
  3. 디폴트 메세지도 null이라면 NoSuchMessageException 예외를 발생시킨다.
  • AbstractMessageSource.getMessageInternal()
    @Nullable
	protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
        ...
		Object[] argsToUse = args;

		if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) { // 해당 코드 블럭이 실행됨
			String message = resolveCodeWithoutArguments(code, locale);
			if (message != null) {
				return message;
			}
		}

		else {
			...
		}
		...
	}

isAlwaysUseMessageFormat()가 false이면서 getMessage에 넘겨준 변수가 없을 때 if안의 코드 블럭을 수행하게 된다. isAlwaysUseMessageFormat()는 디폴트값이 false이고, 해당 테스트에서는 값을 변경하지 않았기 때문에 if 안의 코드 블럭이 수행될 것이다.

  • ResourceBundleMessageSource.resolveCodeWithoutArguments()
	@Override
	protected String resolveCodeWithoutArguments(String code, Locale locale) {
		Set<String> basenames = getBasenameSet();
		for (String basename : basenames) {
			ResourceBundle bundle = getResourceBundle(basename, locale);
			if (bundle != null) {
				String result = getStringOrNull(bundle, code);
				if (result != null) {
					return result;
				}
			}
		}
		return null;
	}

getResourceBundle을 통해 가져온 번들에서 메세지 코드에 해당하는 메세지를 찾아 반환한다.

  • ResourceBundleMessageSource.getResourceBundle()
	@Nullable
	protected ResourceBundle getResourceBundle(String basename, Locale locale) {
		if (getCacheMillis() >= 0) {
			// MessageSource에 cacheDuration 옵션을 설정했다면, 해당 블럭이 수행된다.
			return doGetBundle(basename, locale);
		}
		else {
			// 옵션을 설정하지 않았다면, 캐시는 영구 저장된다. 캐시(cachedResourceBundles)에 저장된 값이 있으면 가져오고, 아니면 번들을 가져온다.
			Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
			if (localeMap != null) {
				ResourceBundle bundle = localeMap.get(locale);
				if (bundle != null) {
					return bundle;
				}
			}
			try {
				ResourceBundle bundle = doGetBundle(basename, locale);
				if (localeMap == null) {
					localeMap = this.cachedResourceBundles.computeIfAbsent(basename, bn -> new ConcurrentHashMap<>());
				}
				localeMap.put(locale, bundle);
				return bundle;
			}
            catch {...}

ResourceBundleMessageSource는 리소스 번들 객체를 캐시하여 성능을 향상시키기 위해 cachedResourceBundles를 사용한다. 엑세스한 리소스 번들과 각각의 메세지에 대해 생성된 MessageFormat을 모두 캐시한다. 캐시된 번들이 있다면, 해당 번들을 바로 반환한다. 캐시된 게 없거나 cacheDuration을 설정한 경우, doGetBundle() → getBundle() → getBundleImpl() 순으로 호출된다. 개인적으로 getBundleImpl() 이 핵심이라는 생각이 들었다.

  • ResourceBundle.getBundleImpl()
private static ResourceBundle getBundleImpl(Module callerModule, Module module, String baseName, Locale locale, Control control) {
        
        ...
        
        ResourceBundle baseBundle = null;
        // control.getFallbackLocale()의 값이 null이 나올 때까지 반복한다.
        for (Locale targetLocale = locale;
             targetLocale != null;
             targetLocale = control.getFallbackLocale(baseName, targetLocale)) {
            List<Locale> candidateLocales = control.getCandidateLocales(baseName, targetLocale);
            if (!isKnownControl && !checkList(candidateLocales)) {
                throw new IllegalArgumentException("Invalid Control: getCandidateLocales");
            }

            bundle = findBundle(callerModule, module, cacheKey,
                                candidateLocales, formats, 0, control, baseBundle);

            // 로드된 번들이 base bundle이면서 요청받은 locale이거나 유일한 candidate locale이라면, 해당 번들을 결과로 사용한다.
            // 로드된 번들이 base bundle이라면, 모든 fallback locales 수행을 완료할 때까지 보류된다.
            if (isValidBundle(bundle)) {
                boolean isBaseBundle = Locale.ROOT.equals(bundle.locale);
                if (!isBaseBundle || bundle.locale.equals(locale)
                    || (candidateLocales.size() == 1
                        && bundle.locale.equals(candidateLocales.get(0)))) {
                    break;
                }

                // If the base bundle has been loaded, keep the reference in
                // baseBundle so that we can avoid any redundant loading in case
                // the control specify not to cache bundles.
                if (isBaseBundle && baseBundle == null) {
                    baseBundle = bundle;
                }
            }
        }

        if (bundle == null) {
            if (baseBundle == null) {
                throwMissingResourceException(baseName, locale, cacheKey.getCause());
            }
            bundle = baseBundle;
        }

        // keep callerModule and module reachable for as long as we are operating
        // with WeakReference(s) to them (in CacheKey)...
        Reference.reachabilityFence(callerModule);
        Reference.reachabilityFence(module);

        return bundle;
    }

반복문을 돌면서 locale에 해당하는 번들을 찾아서 bundle 변수에 할당되고, 마지막으로 할당된 값이 반환된다. 따라서 control.getFallbackLocale(baseName, targetLocale)의 값이 언제 null이 되느냐가 중요하다.

  • ResourceBundleMessageSource.getFallbackLocale()
		@Override
		@Nullable
		public Locale getFallbackLocale(String baseName, Locale locale) { // baseName: "message/messages", locale: "ko_KR"
			Locale defaultLocale = getDefaultLocale();
			return (defaultLocale != null && !defaultLocale.equals(locale) ? defaultLocale : null);
		}

getFallbackLocale()은 getDefaultLocale()로 반환된 defaultLocale값이이 null이거나 defaultLocale와 locale이 같지 않을 때 null을 반환하게 된다.

  • AbstractResourceBasedMessageSource.getDefaultLocale()
	@Nullable
	protected Locale getDefaultLocale() {
		if (this.defaultLocale != null) {
			return this.defaultLocale;
		}
		if (this.fallbackToSystemLocale) { // 설정을 변경하지 않았기 때문에 해당 코드 블럭이 수행됨
			return Locale.getDefault();
		}
		return null;
	}

defaultLocale의 디폴트값은 null이고, fallbackToSystemLocale의 디폴트값은 true이기 때문에, Locale.getDefault()를 반환할 것이다. 여기서 Locale.getDefault()는 시스템에 설정되어 있는 locale 이다.

현재 시스템 설정된 defaultLocale은 "en_KR"이므로 null이 아니고 locale과 같지 않기 때문에, getFallbackLocale() 에서는 defaultLocale인 "en_KR"을 반환하게 된다. 그렇게 되면 getBundleImpl()는 "en_KR"에 대한 번들을 가져오고 종료하게 될 것이다. 그래서 결과는 영어 메세지인 "Hello"가 나왔다.

그럼 fallbackToSystemLocale을 false로 설정을 변경했다면, 어떻게 됐을까?

AbstractResourceBasedMessageSource.getDefaultLocale() 에서 null을 반환하게 될 것이고, getBundleImpl()의 findBundle()을 통해 가장 최근 가져온 번들(기본 메세지 파일)을 반환할 것이다.

근데 ko_KR은 관련 메세지 파일이 없는데, findBundle에서는 어떻게 번들을 가져오는걸까?

이 부분은 candidateLocales과 관련이 있다. 예를 들어 "ko_KR"에 대한 candidateLocales는 ["ko_KR", "ko", ""]이런식으로 언어+지역, 언어, 공백 3개로 구성된 리스트 형태이다. findBundle에서는 재기 호출을 통해 각각의 locale에 대한 ResourceBundle을 탐색하는데, 맨 뒤에 요소부터 거꾸로 탐색한다.

  • ResourceBundle.findBundle()
    private static ResourceBundle findBundle(Module callerModule, Module module, CacheKey cacheKey, List<Locale> candidateLocales, List<String> formats, int index, Control control, ResourceBundle baseBundle) {
        Locale targetLocale = candidateLocales.get(index);
        ResourceBundle parent = null;
        if (index != candidateLocales.size() - 1) {
            parent = findBundle(callerModule, module, cacheKey,
                                candidateLocales, formats, index + 1,
                                control, baseBundle);
        } else if (baseBundle != null && Locale.ROOT.equals(targetLocale)) {
            return baseBundle;
        }
        
        ...
        
        cacheKey.setLocale(targetLocale);
        ResourceBundle bundle = findBundleInCache(cacheKey, control);
        if (isValidBundle(bundle)) {
            expiredBundle = bundle.expired;
            if (!expiredBundle) {
                // If its parent is the one asked for by the candidate
                // locales (the runtime lookup path), we can take the cached
                // one. (If it's not identical, then we'd have to check the
                // parent's parents to be consistent with what's been
                // requested.)
                if (bundle.parent == parent) {
                    return bundle;
                }
                // Otherwise, remove the cached one since we can't keep
                // the same bundles having different parents.
                BundleReference bundleRef = cacheList.get(cacheKey);
                if (bundleRef != null && bundleRef.get() == bundle) {
                    cacheList.remove(cacheKey, bundleRef);
                }
            }
        }

        if (bundle != NONEXISTENT_BUNDLE) {
            trace("findBundle: %d %s %s formats: %s%n", index, candidateLocales, cacheKey, formats);
            if (module.isNamed()) {
                bundle = loadBundle(cacheKey, formats, control, module, callerModule);
            } else {
                bundle = loadBundle(cacheKey, formats, control, expiredBundle);
            }
            if (bundle != null) {
                if (bundle.parent == null) {
                    bundle.setParent(parent);
                }
                bundle.locale = targetLocale;
                bundle = putBundleInCache(cacheKey, bundle, control);
                return bundle;
            }

            // Put NONEXISTENT_BUNDLE in the cache as a mark that there's no bundle
            // instance for the locale.
            putBundleInCache(cacheKey, NONEXISTENT_BUNDLE, control);
        }
        return parent;
    }

["ko_KR", "ko", ""] 맨 뒤에서부터 탐색하면서 번들이 존재한다면, 반환하고 없다면 그 전의 요소가 반환한 번들을 반환한다. 즉, 맨 앞에 요소가 제일 우선 순위가 높다.

해당 테스트에서는 "ko_KR" 또는 "ko" 과 관련된 번들이 없으니 ""(baseLocale)에 해당하는 기본 메세지 파일을 반환하게 될 것이다.

0개의 댓글