1. 배경 지식
LocaleResolver 인터페이스를 사용하면 서블릿 단에서 웹 요청과 응답에 관련된 locale 작업들을 다룰 수 있다.
This interface allows for implementations based on request, session, cookies, etc. The default implementation is org. springframework. web. servlet. i18n. AcceptHeaderLocaleResolver, simply using the request's locale provided by the respective HTTP header
Spring에서 해당 인터페이스의 기본 구현은 AcceptHeaderLocaleResolver 클래스다.
이 클래스는 HTTP 요청 헤더의 Accept-Language 값을 사용하여 사용자 locale 정보를 조회할 수 있는 기능을 제공한다.
LocaleResolver implementation that simply uses the primary locale specified in the Accept-Language header of the HTTP request (that is, the locale sent by the client browser, normally that of the client's OS).
LocaleContextHolder 클래스를 통해 세팅된 locale 정보를 조회하거나 조작할 수 있다.
Simple holder class that associates a LocaleContext instance with the current thread. The LocaleContext will be inherited by any child threads spawned by the current thread if the inheritable flag is set to true.
Used as a central holder for the current Locale in Spring, wherever necessary: for example, in MessageSourceAccessor. DispatcherServlet automatically exposes its current Locale here. Other applications can expose theirs too, to make classes like MessageSourceAccessor automatically use that Locale.
2. 상황
클라이언트에서 요청을 보낼 때 Accept-Language 헤더와 값을 보내주고 있다.
서버에서는 사용자의 locale 정보가 필요한 곳에서 해당 헤더 값을 사용하고자 했다.
Accept-Language 헤더가 존재하지 않는 경우 default locale을 설정하고자, 설정 클래스에서 아래와 같은 bean 정보를 구성했다.
@Bean
LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(Locale.KOREA);
return localeResolver;
}
그리고 비즈니스 로직에서 LocaleContextHolder 클래스의 getLocale() 메서드를 통해 사용자 locale을 조회한다.
Locale locale = LocaleContextHolder.getLocale();
String languageCode = locale.getLanguage();
개발 환경에서 아래 항목들을 테스트해 보았고, 의도한 대로 동작하는 것을 확인하여 운영 환경에도 배포를 진행했다.
- Accept-Language 헤더가 존재하지 않으면, default locale(한국)이 세팅되어야 한다.
- Accept-Language 헤더가 존재하면, 해당 값이 세팅되어야 한다.
- 1, 2번에서 세팅된 locale 정보는 LocaleContextHolder.getLocale()으로 조회 가능하며, 늘 존재한다.
3. 이슈 발생
LocaleContextHolder.getLocale()로 조회한 사용자 locale에 기반하여 데이터를 조회하는 비즈니스 로직이 있다.
사용자 locale은 늘 존재한다고 생각했기 때문에, 만약 존재하지 않으면, IllegalStateException을 발생시켰다.
운영 배포하자마자 마법처럼 해당 exception이 발생했고, 사용자는 관련 화면에서 서비스 이용 불가 메세지를 보게 되었다.
사용자의 서비스 이용이 우선이었기 때문에 exception을 발생시키는 대신 하드 코딩으로 locale을 설정했고, 디버깅을 위해 조회한 locale에 대한 로깅들을 추가하여 임시 조치했다.
4. 현상 파악
로깅을 통해 Locale 객체가 텅 비어있는 것을 확인했다.
좀 더 테스트하다보니, 로그인 사용자의 요청에 한해서만 해당 현상이 나타났다.
로그아웃 상태의 미인증 사용자는 Accept-Language 헤더 자체가 존재하지 않고, LocaleResolver bean에서 설정한 default locale이 잘 적용되었다.
'로그인 사용자의 요청 정보가 잘못되었나?'라는 생각에 요청 정보를 뜯어보았는데, 아래처럼 값이 세팅되어 있었다.
Accept-Language: ko_KR
[후술 할 문제의 원인 두 개가 이미 모두 나왔다]
5. 삽질
로그인 여부에 따라 locale 상태가 달라지니 자연스레 인증 쪽에 먼저 눈길이 갔다. 인증 방식으로 JWT를 사용하고 있고, 관련 filter 클래스들을 뒤져보기 시작했다.
'사용자 인증 필터를 거치면서 내부적으로 Locale 정보를 초기화하는 로직이 있는 건가?' 싶어서 custom locale 필터를 만들어 인증 필터 전후로 붙여보기도 했다.
그러나 해결책을 찾을 수 없었다.
원점으로 돌아와, 사용자 locale 정보를 반환해주는 LocaleContextHolder.getLocale()을 하나씩 벗겨보기로 방향을 틀었다.
6. 디버깅
LocaleContextHolder.getLocale()에 break point를 찍고 Accept-Language 헤더 값에 "ja_JP"를 넣어 요청을 보냈다.
(line 205) getLocaleContext()로 LocalContext를 조회하고, 그 값을 getLocale() 메서드의 argument로 넘겨준다.
getLocaleContext() 메서드를 먼저 보자.
(line 121) localeContextHolder.get()으로 조회하여 할당한 변수 localeContext의 상태는 아래와 같다.
(참고로, 변수 localeContextHolder 타입이 ThreadLocal<LocaleContext>이기 때문에 ThreadLocal 클래스의 get() 메서드를 통해 조회한다)
defaultLocale은 LocaleResolver bean에서 설정한 값인 "ko_KR"로 잘 세팅되어 있다.
그런데 요청 정보 파싱을 통해 저장된 locale 정보가 이상하다.
"und"? 구글링해보면 undefined의 약자인데, 여기서 무언가 잘못되었음을 느꼈다.
이쯤에서 슬랙을 통해 팀원들에게 상황을 공유했다.
클라이언트 팀원 분이 Accept-Language 헤더의 값으로 넣었던 "ja_JP"에 대해 underscore(_)가 아닌 hyphen(-)이 되어야 한다고 말씀주셨다(ㅠㅠ underscore로 요청이 오고 있는걸요..).
실제로 rfc 문서에서도 언급하고 있다(https://www.rfc-editor.org/rfc/rfc9110#language.tags).
근본적인 원인은 알았으니 마저 킵고잉.
(line 222~223) 첫 사진에서 보았던 getLocale() 메서드의 파라미터로 LocaleContext 타입의 값이 not null인 상태로 넘어왔다.
(line 224) localeContext.getLocale()으로 반환되는 값은 바로 위에서 보았던 정의되지 않은 언어 코드인 "und" 값을 담고 있는 Locale 객체이다.
결국, LocaleContextHolder.getLocale()을 통해 조회해 온 Locale 객체는 엄밀히 말해 빈 객체가 아니라, 정의되지 않은 언어 코드("und")를 가진 반쪽짜리 객체다.
클라이언트 팀원에게 전달드려 우선 근본적인 원인 파악과 신규 앱 버전 배포를 통해 일차적인 해결은 했다.
그러나 이전 버전에서는 해결책이 되지 못했고, 그렇다고 강제 업데이트를 하기엔 사용자 경험을 몹시 떨어뜨리기 때문에 서버에서 대비책을 마련해야 했다.
아참, 개발 서버에서 문제가 없었던 이유는 Accept-Language 헤더 값이 hyphen(-)으로 잘 구분되어 있었기 때문이었다.
7. 대비책 마련
대비책으로 Accept-Language 헤더 값이 올바르지 않은 경우에도 default locale이 적용되도록 하고자 했다.
그러기 위해 요청 과정 중 어느 시점에서 LocaleContext 객체를 만들어서 세팅하는지 알아야 했다.
LocaleContextHolder 클래스의 docs를 자세히 읽어보면 아래 빨간색 문구가 있다.
DispatcherServlet에서 현재 locale 정보를 자동으로 노출한다는 것을 보니 여길 조져봐야 할 것 같다.
Simple holder class that associates a LocaleContext instance with the current thread. The LocaleContext will be inherited by any child threads spawned by the current thread if the inheritable flag is set to true.
Used as a central holder for the current Locale in Spring, wherever necessary: for example, in MessageSourceAccessor. DispatcherServlet automatically exposes its current Locale here. Other applications can expose theirs too, to make classes like MessageSourceAccessor automatically use that Locale.
"localeContext"로 검색하니까 금세 나온다.
(line 1179) 실제로 break point를 찍고 요청을 날려보면, LocaleResolver bean 설정대로 AcceptHeaderLocaleResolver 타입의 값이 변수 lr에 할당된다.
(line 1184) lr은 not null이기 때문에 AcceptHeaderLocaleResolver 클래스의 resolveLocale() 메서드를 실행한다.
(line 99) 특정 조건을 만족하면 멤버 변수인 defaultLocale을 반환한다.
그 조건이란 default locale이 설정되어 있고 + Accept-Language 헤더 자체가 존재하지 않아야 한다.
하지만 요청 헤더에 Accept-Language 헤더가 존재하기 때문에 조건문을 만족하지 못하여 default locale을 반환하지 않는다.
LocaleResolver bean을 설정할 때 'default locale만 세팅해 주면 잘못된 요청 정보가 와도 무시하고 default로 세팅해주지 않을까?' 하는 생각을 어렴풋이 했는데 막상 까보니 아니었다.
(line 102) HttpServletRequest에서 locale 정보를 조회하여 변수 requestLocale에 할당한다. 위에서 미리 만나보았던 언어코드 "und"가 있는 반쪽짜리 locale 객체이다.
(line 103) List<Locale> 타입의 supportedLocales 멤버를 조회한다. 해당 멤버의 setter 메서드 docs를 보면 변수명 그대로 지원되는 locale을 나타낸다.
Configure supported locales to check against the requested locales determined via HttpServletRequest.getLocales(). If this is not configured then HttpServletRequest.getLocale() is used instead.
(line 104) 여기서부터 대비책을 적용할 수 있는 중요한 내용들이다. supportedLocales가 없거나 requestLocale이 supportedLocale 중 하나라면 requestLocale을 반환한다, 즉, 요청 정보 그대로 반환한다.
나는 LocaleResolver bean 설정에서 supportedLocales를 세팅해주지 않았기 때문에 이 조건을 만족하여 반쪽짜리 locale 객체를 반환하게 된다.
그 말인즉슨, 조건을 만족시키지 않으면 반쪽짜리 locale 객체를 반환하지 않게 할 수 있다는 말과 동일하다. 그러기 위해선 supportedLocale을 세팅해줘야 한다.
(line 107~111) 이 포스트를 작성하고자 했던 핵심 내용이다. findSupportedLocale() 메서드를 호출하여 찾은 supportedLocale이 null이면 의도대로 defaultLocale을 반환시킬 수 있다.
혹시 모르니 findSupportedLocale() 메서드도 잘 살펴봐야 한다.
(line 118) 요청 정보에서 가져온 locale 정보들을 순회하면서 supportedLocales에 속한 locale인지 확인한다.
(line 120~125) supportedLocales 중 요청 정보의 locale과 언어 코드, 나라 코드가 모두 일치하는 경우(Full match) 요청 정보의 locale을 반환한다.
(line 126~135) 그렇지 않은 경우, 언어만 일치하는(language-only match) supportedLocale을 languageMatch 변수에 할당하는 fallback 로직이 실행된다.
(line 137) languageMatch를 반환하는데, 해당 값이 null이어야 생각했던 대비책을 적용시킬 수 있다. 다행히 이 케이스에서는 null이 반환된다. 요청 정보의 locale이 "und"라는 정의되지 않은 언어 코드가 담긴 반쪽짜리 locale이기 때문에 supportedLocale에 "und" 언어 코드를 넣지 않는 이상 어느 조건에도 걸리지 않기 때문이다.
8. 결론
- 클라이언트에서 요청을 보낼 때 Accept-Language 헤더 값은 언어 코드만 작성하거나 언어 코드와 나라 코드를 hyphen(-)으로 구분해야 한다. 그 외 기호로 구분 시 정의되지 않은(undefined, und) 값이 매핑된다.
- 서버에서 LocaleResolver bean을 설정할 때, default locale을 설정해 주는 것도 중요하지만, supported locales도 설정해 주어야 코너 케이스를 커버할 수 있다.
끝!