i18next와 Navigator API: 불필요한 네트워크 요청 제거하기

nijuy·2024년 11월 30일

🍿 스낵게임

목록 보기
2/3
post-thumbnail

오늘은 스낵게임의 성능 최적화 과정에서 다국어 관련 불필요한 네트워크 요청을 발견하고, 제거한 얘기를 해보려고 합니다. 다국어 지원 라이브러리인 i18next 위주의 내용으로 구성되어 있으며, 구체적인 사용법에 관해서는 설명하지 않았습니다.

제목은 퍼플렉시티가 지어줬습니다.

i18next로 다국어 지원하기

스낵게임은 i18next를 사용해 한국어와 영어로 서비스를 제공하고 있습니다.
기본적으로 브라우저 언어를 감지해서 해당 언어에 맞는 텍스트를 표시하는 방식으로 동작하며,
회원 가입 후 마이 페이지에서 언어 설정을 변경할 수도 있습니다.

i18next를 사용해 본 분들은 알겠지만, JSON 파일에 실제 텍스트를 저장하여 사용합니다.
즉, ‘브라우저 언어에 맞는 텍스트를 표시하는 방식으로 동작한다’는 설명은 사실
‘브라우저 언어에 해당하는 JSON 파일을 로드해서 사용한다’는 의미인 거죠.

따라서 언어별 JSON 파일이 어느 경로에 위치하는지 알려줘야 하고, init() 내의 loadPath를 통해 이를 지정합니다.

i18next
  .use(LanguageDetector)
  .use(initReactI18next)
  .use(HttpBackend)
  .init({
    fallbackLng: 'ko',
    backend: {
      loadPath: '/locale/{{lng}}/{{ns}}.json',
    },
    ...
  })

LanguageDetector에서 감지한 언어가 loadPath의 {{lng}} 자리에 들어가면서 JSON 파일을 불러올 경로가 결정됩니다.
스낵게임은 public/locale 폴더 하위에 언어별 폴더를 만들어 JSON 파일을 관리하기 때문에 위와 같은 형식으로 설정했습니다.

스낵게임은 효율적인 관리를 위해 페이지 단위로 (메인, 게임, 랭킹, …) namespace를 나눠두었습니다.
loadPath의 {{ns}}는 이 namespace를 의미합니다.

만약 감지한 언어가 영어나 한국어가 아니라면 존재하지 않는 경로로 요청을 보내게 되어 실패하고,
fallbackLng로 설정한 한국어 파일을 불러오게 됩니다.

i18next를 어떻게 사용하고 있는지 대략적으로 설명했으니, 이제 문제로 들어가 보겠습니다.

크롬에서만 요청이 두 번씩 가는데요

성능 측정을 위해 개발자 도구의 성능/네트워크 탭을 뜯어보던 중, 수상한 요청을 발견했습니다.

브라우저 기본 언어를 한국어로 설정해뒀는데도, 첫 번째 요청이 실패하고 있었어요.
크롬에서 종종 이런 현상이 발견되더라고요.

실패하는 요청의 세부 정보를 보니, {{lng}} 자리에 ‘ko-KR’이 들어가고 있었습니다.
한국어 파일은 ‘ko’ 폴더에 있기 때문에 ‘ko-KR’이 들어간 경로는 존재하지 않습니다.
파일을 찾지 못해 fallback 요청을 보내느라 두 번씩 요청이 가고 있는 거였어요

i18next에서 {{lng}} 자리에 어떤 값을 사용하길래 이런 일이 생기는 걸까요?

i18next가 브라우저 언어를 감지하는 방법

공식 문서의 Plugins and Utils 페이지를 보면 브라우저 환경의 언어 감지를 위한 플러그인을 소개하고 있습니다.
스낵게임에서도 이를 사용하고 있는데, 내부 코드를 살펴본 적이 없어 이참에 어떻게 언어를 감지하는지 알아봤습니다.

README.md를 보면 다양한 옵션을 지원하고 있으며, 감지 순서를 사용자가 설정할 수 있습니다.

i18next
  .use(LanguageDetector)
  .use(initReactI18next)
  .use(HttpBackend)
  .init({
		...,
    detection: {
      order: [
        'navigator',
        'querystring',
        'cookie',
        'localStorage',
        'htmlTag',
        'path',
        'subdomain',
      ],
    },
  });

스낵게임에서는 위 순서로 감지하도록 설정해 둔 상태이고, navigator를 최우선으로 판단하기 때문에 navigator.js 코드를 살펴볼게요.

export default {
  name: 'navigator',

  lookup(options) {
    const found = [];

    if (typeof navigator !== 'undefined') {
      const { languages, userLanguage, language } = navigator;
      if (languages) {
        // chrome only; not an array, so can't use .push.apply instead of iterating
        for (let i = 0; i < languages.length; i++) {
          found.push(languages[i]);
        }
      }
      if (userLanguage) {
        found.push(userLanguage);
      }
      if (language) {
        found.push(language);
      }
    }

    return found.length > 0 ? found : undefined;
  }
};

Navigator 객체에서 3개의 값을 가져와서 found 배열에 넣는 것을 확인할 수 있습니다.

i18next에서 {{lng}} 자리에 어떤 값을 사용하길래 이런 일이 생기는 걸까요?

그렇다면 객체에서 반환하는 값이 브라우저마다 달라서 이런 일이 생기는 거네요.

만약 감지한 언어가 영어나 한국어가 아니라면 lng 값이 ‘en’이나 ‘ko’가 아니라면
존재하지 않는 경로로 요청을 보내게 되어 실패하고, fallbackLng로 설정한 한국어 파일을 불러오게 됩니다.

위에서 했던 설명을 정확하게 고치면 이렇게 되겠구요.

Navigator API

좀 더 알아보기 위해 MDN의 navigator API 문서를 보면 이런 내용이 있습니다.

RFC 5646: Tags for Identifying Languages(BCP 47)가 정의한 언어 태그의 DOMString입니다.
유효한 값은 "en", "en-US", "ko", "ko-KR" 등입니다.

iOS Safari 버전 10.2 이전에서는 "en-us", "ko-kr"처럼 국가 코드를 소문자로 반환합니다.

RFC 5646

RFC 5646 문서를 보면 언어 식별용 태그에 대한 내용이 나와있습니다.

여기에서 ABNF 형식으로 구문 정의된 내용을 볼 수 있는데, 부분적으로 살펴보니

  • 여러 개의 태그가 -으로 연결된 형태
  • language 태그는 필수
  • 나머지 하위 태그는 선택 (대괄호로 감싸면 선택 영역이라는 뜻입니다)

임을 알 수 있었습니다.

대한민국의 언어 코드는 ‘ko’이고, 지역 코드는 ‘KR’이니까 네트워크 요청에서 발견했던 lng 값은 이렇게 해석할 수 있겠네요

  • ‘ko’ = language
  • ‘ko-KR’ = language-region

convertDetectedLanguage()

Navigator API에서 항상 language로만 이루어진 2자리 문자열이 돌아온다는 보장을 할 수 없다는 것을 알게 되었습니다.
저는 언어 코드로 폴더명을 지었으니, -으로 연결된 하위 태그는 경로에 사용하지 않도록 lng 값을 가공해야 했어요.

다행히 해당 플러그인의 v7.1.0 부터 convertDetectedLanguage() 옵션이 추가되었습니다. (스낵게임은 v7.2.0 사용 중)
언어 태그에 대해 알아보고 나니 다양한 언어 코드 형식을 일관적으로 처리하려면 꼭 필요한 함수라는 생각이 드네요.

타입 정의는 다음과 같이 생겼습니다

  /**
   * optional conversion function to use to modify the detected language code
   */
  convertDetectedLanguage?: 'Iso15897' | ((lng: string) => string);

저는 lng를 - 기준으로 분리하고, 0번째 요소만 반환하는 간단한 함수를 넣어줬습니다.

i18next
  .use(LanguageDetector)
  .use(initReactI18next)
  .use(HttpBackend)
  .init({
    fallbackLng: 'ko',
    backend: {
      loadPath: '/locale/{{lng}}/{{ns}}.json',
    },
    detection: {
      convertDetectedLanguage: (lng) => lng.split('-')[0],
      ...
    },
  });

결과

convertDetectedLanguage() 옵션을 추가한 뒤 확인해 보니, ‘ko-kR’이 들어간 경로로 보내던 요청이 사라졌네요.

RFC 문서에서 프로그래밍 언어 시간에 배운 BNF가 보여서 아주 반가웠습니다.
또 Web API를 사용할 때는 브라우저마다 구현에 차이가 있다는 점을 인지하고 일관된 처리가 필요하지는 않은지 한 번 더 고민하는 계기가 될 것 같아요...👀

profile
안녕하세요, 프론트엔드 개발자 이유진입니다.

0개의 댓글