~/hovelopin
← back to index
25 min read@hovelopin

i18next enableSelector는 런타임에서 어떻게 동작할까?

i18next의 enableSelector 옵션을 설정하면 번역 키를 함수로 쓸 수 있다. 문자열이 아닌 함수를 넘겼는데도 번역이 되는 이유를 Proxy·revoke·Symbol 중심으로 확인한다.

const { t } = useTranslation();
 
<h1>{t(($) => $.pages.notFound.title)}</h1>;

1편에서는 문자열 key에 i18next 타입을 연결해 오타와 삭제된 key를 컴파일 단계에서 잡는 방법을 정리했다. 그 다음으로 마주치는 선택지가 enableSelector다. 이 옵션을 설정하면 번역 key를 문자열로 직접 작성하지 않고 함수 형태로 표현할 수 있다.

문자열 key 방식

t("pages.notFound.title");

문자열 안에 경로를 직접 적는 방식이다.

selector 방식

t(($) => $.pages.notFound.title);

번역 리소스를 객체처럼 탐색하는 형태가 된다.

문자열 대신 함수를 넘겼는데도 번역은 정상적으로 동작하고, IDE에서는 pages → notFound → title까지 프로퍼티를 따라가며 자동완성도 제공된다. 그렇다면 내부에서는 어떻게 동작할까?

결론부터 말하면, i18next는 selector 함수에 Proxy 객체를 넣어 실행하면서 함수 안에서 접근한 프로퍼티 경로를 기록한다. 그 경로를 다시 일반적인 문자열 키로 변환한 뒤 기존 번역 엔진에 전달하는 구조다.

즉, 위 selector는 결국 런타임에서 다음 문자열로 변환된다.

"pages.notFound.title";

그 이후의 번역 과정은 기존 i18next와 동일하다. 아래에서는 타입 선언이 아니라 런타임 동작을 따라간다. useTranslation()에서 받은 t가 어떻게 만들어지고, selector 함수가 어떤 과정을 거쳐 문자열 키가 되는지 내부 구현 흐름으로 확인해보자.


전체 흐름 먼저 보기

예제 하나를 끝까지 따라가 보자. 세부 구현으로 들어가기 전에, 함수가 어느 지점을 거쳐 번역 엔진에 닿는지부터 훑어두면 뒤 내용이 훨씬 수월하다.

selector 함수가 기존 번역 엔진에 닿기까지

중간에 selector 를 문자열 키로 바꾸는 단계 하나가 끼어 있을 뿐, 나머지는 익숙한 경로다.

t(($) => $.pages.notFound.title);

전체 흐름은 다음과 같다.

selector 호출의 전체 흐름

핵심은 중간에 selector를 문자열 키로 변환하는 단계가 하나 추가되었다는 점이다. selector → 문자열 key 변환이 어디서 시작되는지 보려면 먼저 t가 어떤 함수인지부터 봐야 한다.


화살표 함수는 바로 실행되지 않는다

다음 코드를 보면 $가 어디서 오는지 궁금해진다.

t(($) => $.pages.notFound.title);

중요한 점은 이 시점에서 화살표 함수가 아직 실행되지 않는다는 데 있다. t()가 받는 값은 다음과 같다.

($) => $.pages.notFound.title;

위 값은 아직 실행되지 않은 함수일 뿐이다. 나중에 i18next가 selector 함수를 실행하면서 $ 자리에 자신이 만든 Proxy 객체를 넣는다.

selector(createProxy());

즉, 개념적으로는 다음 형태다.

const selector = ($) => $.pages.notFound.title;
 
const proxy = createProxy();
 
selector(proxy);

이때부터 $가 실제 값을 갖는다. 이제 selector를 호출하는 t가 어디서 만들어지는지 살펴보자.


useTranslation()이 반환하는 t

const { t } = useTranslation();

useTranslation()이 돌려주는 t는 단순히 전역 i18next.t를 그대로 반환한 것이 아니다. useTranslation()은 현재 컴포넌트의 상태에 맞는 전용 t 함수를 만들어 돌려준다.

useTranslation()에서 하는 일

react-i18next v16.6.1의 useTranslation.js에서는 대략 다음 과정을 거친다.

export const useTranslation = (
  ns,
  props = {},
) => {
  // ① i18n 인스턴스 확보
  const { i18n: i18nFromProps } = props;
  const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {};
 
  const i18n = i18nFromProps || i18nFromContext || getI18n();
 
  // ② React 관련 옵션 병합
   const i18nOptions = useMemo(
      () => ({ ...getDefaults(), ...i18n?.options?.react, ...props }),
      [i18n, props],
    );
 
 
  // ③ namespace 결정
  const nsOrContext = ns || defaultNSFromContext || i18n?.options?.defaultNS;
 
  const unstableNamespaces =
    isString(nsOrContext)
      ? [nsOrContext]
      : nsOrContext || ['translation'];
 
  const namespaces = useMemo(
    () => unstableNamespaces,
    unstableNamespaces,
  );
 
  // ...
 
  // ④ 현재 상태에 맞는 t 생성
  const calculatedT =
    i18n.getFixedT(...);
 
  // ...
};

중요한 지점은 getFixedT()다.


getFixedT()는 왜 필요한가?

예를 들어 다음 두 컴포넌트가 있다고 해보자.

const { t } = useTranslation("common");
 
t("hello");

→ common에서 찾아야 함

const { t } = useTranslation("auth");
 
t("hello");

→ auth에서 찾아야 함

하지만 전역 i18next.t만 사용하면 매번 namespace를 직접 전달해야 한다.

i18next.t("hello", {
  ns: "common",
  lng: "ko",
});

반면 useTranslation('common')에서 받은 t는 이미 다음 정보를 기억하고 있어야 한다.

namespace = common
language  = ko
keyPrefix = ...

namespace·language 정보를 매 호출마다 넘기는 대신 미리 묶어 둔 함수가 필요하고, 그 역할을 getFixedT()가 맡는다.

const calculatedT = i18n.getFixedT(
  currentLng,
  i18nOptions.nsMode === "fallback" ? namespaces : namespaces[0],
  keyPrefix,
  { scopeNs: namespaces }
);

getFixedT()는 쉽게 말해 특정 namespace와 language를 기억하는 전용 t 함수를 만들어 주는 함수다.

const { t } = useTranslation("common");
 
t("hello");

위 로직을 실행하면 내부적으로는 common namespace를 기본값으로 사용하는 t가 동작하며, selector 함수도 바로 전용 t 안으로 들어온다.

ns와 scopeNs를 나눈 이유

여기서 조금 흥미로운 부분이 있다.

i18n.getFixedT(
  currentLng,
  namespaces[0], // ns
  keyPrefix,
  { scopeNs: namespaces }
);

nsscopeNs가 서로 다른 역할을 한다.

예시역할
ns'translation'실제 번역을 찾을 때 사용
scopeNs['translation']selector에서 namespace를 판별할 때 사용

예를 들어 다음과 같이 여러 namespace를 사용할 수 있다.

useTranslation(["a", "b"]);

개념적으로는 다음과 같이 동작한다.

ns와 scopeNs가 나뉘어 전달되는 구조

왜 둘을 나눴을까. 일반적인 번역 호출에서는 기본 namespace만 사용하는 편이 효율적이기 때문이다.

t("foo");

하지만 selector에서는 다음과 같은 표현도 가능해야 한다.

t(($) => $.b.foo);

이때 b가 일반적인 번역 키인지, namespace인지 판단하려면 전체 namespace 목록이 필요하다. 그래서 실제 조회에 사용할 ns와 selector 판정에 사용할 scopeNs를 나누어 전달한다.


fixedT가 selector 함수를 받는다

이제 getFixedT()가 만든 전용 t 안으로 들어가 보자. 원본 구현을 단순화하면 대략 이런 형태다.

const fixedT = (key, opts, ...rest) => {
  // ...
 
  o.lng = o.lng || fixedT.lng;
 
  const explicitCallNs = o.ns !== undefined && o.ns !== null;
 
  o.ns = o.ns || fixedT.ns;
 
  // ...
 
  const selectorOpts = {
    ...this.options,
    ...o,
  };
 
  if (Array.isArray(scopeNs) && !explicitCallNs) {
    selectorOpts.ns = scopeNs;
  }
 
  // selector 함수라면 문자열 키로 변환
  if (typeof key === "function") {
    key = keysFromSelector(key, selectorOpts);
  }
 
  return this.t(resultKey, o);
};

핵심 분기는 아래 코드다.

if (typeof key === "function") {
  key = keysFromSelector(key, selectorOpts);
}

우리가 전달한 값은 문자열이 아니라 함수다.

t(($) => $.pages.notFound.title);

따라서 함수인지 확인하는 분기에서 keysFromSelector가 호출된다.

keysFromSelector(($) => $.pages.notFound.title);

keysFromSelector 안에서 selector가 실행되고 문자열 키로 변환된다. 다음 단계의 핵심은 Proxy다.


Proxy를 먼저 이해하자

i18next 구현으로 들어가기 전에 Proxy를 먼저 짚고 가자. Proxy는 원본 객체에 바로 접근하지 않고, 앞에 가로채는 객체를 세우는 JavaScript 기능이다. 원본 객체를 target, 앞에서 접근을 가로채는 규칙을 handler라고 부른다.

const proxy = new Proxy(target, handler);

handler에 들어가는 함수는 trap이라고 부른다. 값 읽기, 값 쓰기, in 연산자, 함수 호출 같은 동작을 가로챌 수 있다. 그중 가장 자주 보는 trap은 getset이다.

get은 값을 읽을 때 실행된다.

const user = { name: "지은" };
 
const safeUser = new Proxy(user, {
  get(target, key) {
    return key in target ? target[key] : "정보 없음";
  },
});
 
console.log(safeUser.name); // "지은"
console.log(safeUser.age); // "정보 없음"

safeUser.age를 읽으면 원본 객체에는 age가 없지만 바로 undefined가 나오지 않는다. 먼저 get trap이 실행되고, handler가 정한 규칙에 따라 "정보 없음"이 반환된다. 원본 객체를 고치지 않고 읽기 동작만 바꾼 셈이다.

set은 값을 쓸 때 실행된다.

const person = new Proxy(
  {},
  {
    set(target, key, value) {
      if (key === "age" && typeof value !== "number") {
        throw new TypeError("나이는 숫자여야 한다");
      }
 
      target[key] = value;
      return true;
    },
  }
);
 
person.age = 20; // OK
person.age = "스무살"; // TypeError

person.age = 20을 실행하면 set trap에는 target, key, value가 차례대로 들어온다. 위 예시에서는 key가 "age"이고 value가 20이다. 값이 숫자이면 원본 객체에 저장하고 true를 반환한다. set trap은 쓰기 성공 여부를 불리언으로 알려줘야 한다.

Proxy가 유용한 이유는 원본 객체 코드를 바꾸지 않고도 접근 방식을 바꿀 수 있기 때문이다. i18next selector도 같은 성질을 이용한다. $가 실제 번역 리소스가 아니어도, 프로퍼티를 따라 내려가며 접근하는 순간을 Proxy가 가로채 경로를 기록할 수 있다.

하나 더 알아둘 API가 있다. Proxy.revocable이다.

const { proxy, revoke } = Proxy.revocable(target, handler);

일반 Proxy는 한 번 만들면 계속 접근할 수 있다. 반면 Proxy.revocable은 proxy와 함께 revoke 함수를 반환한다. 이 함수를 호출하면 Proxy를 통하는 통로가 끊긴다.

const user = { name: "지은" };
 
const { proxy, revoke } = Proxy.revocable(user, {
  get(target, key) {
    return target[key];
  },
});
 
console.log(proxy.name); // "지은"
 
revoke();
 
console.log(proxy.name);
// TypeError: Cannot perform 'get' on a proxy that has been revoked

취소된 Proxy는 읽기, 쓰기, in 연산자 같은 접근에서 모두 TypeError를 낸다. revoke()를 여러 번 호출해도 추가 에러는 나지 않는다. 끊긴 것은 Proxy를 통하는 통로일 뿐이라 원본 객체는 그대로 남는다.

console.log(user.name); // "지은"

i18next는 이 성질을 이용해 중간 Proxy가 다시 쓰이지 못하게 만든다. 경로가 오염돼도 엉뚱한 key가 그대로 만들어지지 않도록, 폐기된 Proxy에 접근하는 순간 TypeError를 낸다.


Proxy가 접근 경로를 기록한다

selector.js의 핵심은 Proxy다. selector가 실제 리소스 객체를 받는 것은 아니지만, Proxy를 받으면 프로퍼티 접근을 모두 기록할 수 있다. createProxy 구현을 단순화하면 다음과 같다.

const PATH_KEY = Symbol("i18next/PATH_KEY");
 
function createProxy() {
  const state = [];
  const handler = Object.create(null);
 
  let proxy;
 
  handler.get = (target, key) => {
    proxy?.revoke?.();
 
    if (key === PATH_KEY) {
      return state;
    }
 
    state.push(key);
 
    proxy = Proxy.revocable(target, handler);
 
    return proxy.proxy;
  };
 
  return Proxy.revocable(Object.create(null), handler).proxy;
}

그리고 이 Proxy를 selector에 넣어 실행한다.

const { [PATH_KEY]: path } = selector(createProxy());

여기서 $가 처음 런타임 값을 갖는다. 개념적으로는 다음과 같다.

const $ = createProxy();
 
selector($);

즉, selector 함수가 실제로 실행된다. 다만 $는 번역 리소스가 아니라 접근을 기록하는 Proxy다.

$.pages.notFound.title에서 무슨 일이 일어날까?

Proxy는 모든 프로퍼티 접근을 가로챌 수 있으므로, $.pages.notFound.title을 평가하는 동안 get 트랩은 세 번 호출된다. 그림으로 보면 이렇다.

프로퍼티 접근마다 경로가 기록되는 과정

첫 번째 접근

$.pages;
key = 'pages'

state는 다음과 같다.

["pages"];

그리고 새로운 Proxy를 반환한다.

P1

두 번째 접근

P1.notFound;
key = 'notFound'

state는 다음과 같다.

["pages", "notFound"];

그리고 새로운 Proxy를 반환한다.

P2

세 번째 접근

P2.title;
key = 'title'

state는 다음과 같다.

["pages", "notFound", "title"];

그리고 새로운 Proxy를 반환한다.

P3

전체 흐름을 표로 보면 다음과 같다.

순서접근한 키state반환
1pages['pages']P1
2notFound['pages', 'notFound']P2
3title['pages', 'notFound', 'title']P3

$.pages.notFound.title — get 트랩이 경로를 쌓아가는 과정

중요한 점은 selector의 반환값이 문자열이 아니라는 것이다.

($) => $.pages.notFound.title;

selector 함수가 실제로 반환하는 것은 최종 Proxy다. 하지만 i18next는 반환값 자체가 아니라, Proxy를 거치며 기록된 경로를 사용한다.

['pages', 'notFound', 'title']

이제 기록된 경로 배열만 꺼내면 된다.


왜 revoke()가 필요할까?

Proxy를 만드는 코드에는 조금 특이한 부분이 있다.

proxy?.revoke?.();

즉, 새로운 프로퍼티에 접근할 때 이전 Proxy를 폐기한다. 이유는 경로를 기록하는 배열 state를 체인 전체가 공유하기 때문이다. 예를 들어 Proxy를 재사용할 수 있다고 가정해 보자.

($) => {
  const a = $.aidraft;
 
  a.service;
 
  return a.title;
};

원래 의도는 다음일 수 있다.

aidraft.title

하지만 a.service를 한 번 읽으면서 state에 다음 값이 들어간다.

['aidraft', 'service']

그 상태에서 다시 a.title을 호출하면 다음처럼 경로가 오염될 수 있다.

aidraft.service.title

그림으로 보면 이렇다.

Proxy 재사용으로 경로가 오염되는 상황과 revoke

경로 오염은 조용히 발생하기 때문에 오히려 위험하다. i18next가 이전 Proxy를 즉시 폐기하는 이유도 createProxy 안의 revoke 호출에 있다.

proxy?.revoke?.();

이제 중간 Proxy를 다시 사용하면 잘못된 경로를 만들지 않고 즉시 에러가 발생한다.

TypeError:
Cannot perform 'get' on a proxy
that has been revoked

즉, revoke()의 목적은 중간 Proxy를 한 번만 사용할 수 있도록 만들어 경로가 조용히 오염되는 것을 막는 데 있다.

같은 코드를 두 경우로 돌려보면 차이가 분명하다.

a.service 를 한 번 읽고 a.title 을 부르면 어떻게 되는가


기록한 경로는 어떻게 꺼낼까?

여기까지 경로는 배열에 저장되어 있다.

["pages", "notFound", "title"];

하지만 Proxy는 모든 프로퍼티 접근을 가로챈다. 예를 들어 다음처럼 일반적인 문자열 프로퍼티를 사용하면,

result.path;

Proxy는 이것도 경로 접근으로 인식해서 'path'를 state에 추가한다. 따라서 단순히 다음처럼 값을 꺼낼 수 없다.

result.path;

경로를 기록하는 접근과 내부 상태를 꺼내는 접근은 구분되어야 한다. 그래서 PATH_KEY Symbol을 사용한다.

const PATH_KEY = Symbol("i18next/PATH_KEY");

그리고 다음과 같이 접근한다.

const { [PATH_KEY]: path } = result;

Proxy 입장에서는 일반적인 번역 키가 아니라 특별한 Symbol이 들어왔다는 것을 알 수 있다.

if (key === PATH_KEY) {
  return state;
}

따라서 이 접근은 state에 기록되지 않고 바로 배열을 반환한다.

["pages", "notFound", "title"];

두 종류의 접근을 그림으로 보면 이렇다.

일반 키 접근과 PATH_KEY 접근이 분기되는 구조

왜 문자열이 아니라 Symbol일까?

문자열 키는 사용자 데이터와 충돌할 가능성이 있다. 예를 들어 라이브러리가 내부적으로 __internal 같은 필드를 사용한다고 해보자. 사용자가 우연히 동일한 키를 번역 리소스에 넣으면 충돌할 수 있다.

{
  "__internal": "user value"
}

반면 Symbol은 이름이 같아도 서로 다른 값이다.

// false
Symbol("key") === Symbol("key");

따라서 다음과 같은 특성이 생긴다.

문자열
→ 이름이 같으면 같은 키
 
Symbol
→ 설명이 같아도 서로 다른 키

번역 리소스의 키는 일반적으로 문자열이기 때문에 내부 제어용 Symbol과 충돌할 가능성이 없다. 즉, PATH_KEY는 Proxy에게 "이건 번역 키 접근이 아니니 지금까지 기록한 경로를 반환하라"는 특별한 신호가 된다.


배열을 문자열 키로 변환한다

경로를 얻었다면 이제 다음 배열을 문자열로 바꾸면 된다.

["pages", "notFound", "title"];

기본적인 경우 결과는 간단하다.

path.join(".");
pages.notFound.title

다만 i18next에는 namespace라는 개념이 있어서 한 가지 과정이 더 필요하다. 예를 들어 다음 selector가 있다고 해보자.

($) => $.b.foo;

여기서 b는 두 가지 의미를 가질 수 있다.

일반적인 번역 키

b.foo

namespace

b:foo

이 둘을 구분해야 한다.

namespace 판정

관련 코드는 selector.js의 namespace 판정 로직과 대략 같다.

const keySeparator = opts?.keySeparator ?? ".";
 
const nsSeparator = opts?.nsSeparator ?? ":";
 
const strict = opts?.enableSelector === "strict";
 
if (path.length > 1 && nsSeparator) {
  const ns = opts?.ns;
 
  const nsList = strict
    ? Array.isArray(ns)
      ? ns
      : ns
      ? [ns]
      : null
    : Array.isArray(ns)
    ? ns
    : null;
 
  if (nsList) {
    const candidates = strict
      ? nsList
      : nsList.length > 1
      ? nsList.slice(1)
      : [];
 
    if (candidates.includes(path[0])) {
      return `${path[0]}${nsSeparator}${path.slice(1).join(keySeparator)}`;
    }
  }
}
 
return path.join(keySeparator);

예를 들어 다음과 같은 경우다.

상황namespace 후보$.b.foo 결과
useTranslation()[]b.foo
useTranslation(['a', 'b'])['b']b:foo
enableSelector: 'strict'['a', 'b']b:foo

이 과정을 거치면 selector 함수는 최종적으로 일반적인 i18next 키가 된다.

$ => $.pages.notFound.title

pages.notFound.title

여기부터는 기존 i18next와 동일하다

여기까지 오면 selector와 관련된 특별한 동작은 거의 끝난다. fixedT가 this.t()를 호출하면 결국 Translator.translate로 이어진다. 최종적으로는 다음 호출과 동일해진다.

t("pages.notFound.title");

번역 엔진은 문자열 키를 받아 namespace와 언어를 기준으로 리소스를 탐색한다. 개념적으로는 다음과 같은 흐름이다.

   pages.notFound.title


      namespace 분리


      언어 후보 탐색      ko → en


     namespace 탐색


        key 탐색


   리소스에서 최종 값 조회

최종적으로는 다음과 같은 경로를 찾게 된다.

data["ko"]["translation"]["pages"]["notFound"]["title"];

그리고 결과를 반환한다.

페이지를 찾을 수 없습니다

전체 과정을 다시 정리하면

처음 코드로 돌아가 보자.

t(($) => $.pages.notFound.title);

실제로는 다음 순서로 동작한다. 아래 표는 i18next v26.4.0 · react-i18next v16.6.1 소스 기준이다.

단계위치역할
1useTranslationi18n 상태와 namespace를 준비
2getFixedT현재 컴포넌트에 맞는 전용 t 생성
3fixedTselector 함수인지 확인
4keysFromSelectorProxy를 selector에 전달
5createProxy프로퍼티 접근 경로 기록
6PATH_KEY기록된 경로 회수
7namespace 판정배열을 문자열 키로 변환
8Translator기존 i18next 방식으로 번역 조회

이 흐름에서 중요한 점은 selector 문법을 지원하기 위해 번역 엔진 자체가 크게 바뀐 것은 아니라는 데 있다. 기존 번역 엔진 앞에 다음과 같은 변환 단계가 하나 추가되었다.

   selector 함수

  Proxy로 경로 기록      ← 새로 추가된 레이어

    문자열 키 생성

  기존 i18next 번역 엔진   ← 기존 그대로

마무리

selector 호출은 번역 엔진을 갈아엎은 API가 아니다. selector 함수를 문자열 key로 바꾸는 레이어가 앞에 하나 더 붙었을 뿐이다.

런타임에서 추가되는 것은 Proxy·revoke·Symbol 세 가지다. 경로를 기록하고, 오염을 막고, 기록된 배열을 꺼낸다. 이 변환이 끝나면 흐름은 다시 문자열 key를 넘긴 호출과 같아진다.

다음 글에서는 같은 selector가 TypeScript 타입 레벨에서 어떻게 다르게 보이는지 본다.


함께 보기

i18next 타입 안전성 탐구 시리즈의 2편이다.

# comments