번역 라이브러리 없이 다국어 상태 만들기: useSyncExternalStore와 localStorage

다국어 지원을 붙여야 한다고 하면 보통 패키지부터 찾습니다. next-intl, react-i18next, i18next 같은 이름들이 줄줄이 나오죠.

당연합니다. 복수형, ICU MessageFormat, 번역 파일 분할, 지연 로딩, 번역 플랫폼 연동까지 생각하면 검증된 라이브러리를 쓰는 게 맞습니다.

그런데 영문과 한글만 있는 작은 단일 페이지라면 어떨까요? 필요한 건 사실 네 가지뿐일 수 있습니다.

  • 현재 언어를 읽는다.
  • 언어를 바꾸면 React 컴포넌트가 다시 렌더링된다.
  • 새로고침해도 선택이 남는다.
  • 서버 렌더링 중에는 window를 찾다가 쓰러지지 않는다.

localizations 저장소에서는 이 문제를 useSyncExternalStorelocalStorage로 풀었습니다. 거창한 프레임워크를 새로 만든 건 아닙니다. 파일 세 개와 사전 두 개로 필요한 만큼만 구현했습니다.

(“필요한 만큼만”은 개발자가 가장 자주 실패하는 단위이기도 합니다.)

먼저 구조부터 봅시다

역할은 세 층으로 나뉩니다.

locale-store.ts  -> 브라우저 저장소 읽기·쓰기·구독
context.tsx      -> React Provider와 useI18n 훅
en.ts / ko.ts    -> 타입이 연결된 번역 사전

핵심은 React 상태와 브라우저 저장소를 한 파일에 욱여넣지 않는 것입니다.

locale-store.ts는 React를 모릅니다. context.tsxlocalStorage의 세부 동작을 직접 다루지 않습니다. 사전 파일은 상태 변경 방법을 모릅니다.

이렇게 나누면 각 층이 정말 작은 책임만 갖습니다. 그리고 작은 책임은 테스트하기 쉽습니다. 네, 결국 또 테스트 이야기입니다. 피할 수 있을 줄 알았죠?

1단계: 언어 값을 안전하게 정규화하기

현재 구현이 지원하는 언어는 영어와 한국어입니다.

export const DEFAULT_LOCALE = "en";

export type Locale = "en" | "ko";

export function resolveLocale(value: string | null): Locale {
  return value === "ko" ? "ko" : DEFAULT_LOCALE;
}

이 함수가 소박해 보여도 중요한 일을 합니다. localStorage에서 읽은 값은 그냥 string | null입니다. 누군가 개발자 도구에서 locale=fr로 바꿨다고 해서 애플리케이션의 Locale 타입이 갑자기 프랑스어를 지원하게 되지는 않습니다.

지원하지 않는 값은 기본 언어로 되돌립니다. 외부 저장소의 문자열을 애플리케이션 도메인 타입으로 바꾸는 경계인 셈입니다.

테스트도 바로 이 경계를 확인합니다.

test("resolveLocale falls back for unsupported locales", () => {
  assert.equal(resolveLocale(null), DEFAULT_LOCALE);
  assert.equal(resolveLocale("fr"), DEFAULT_LOCALE);
});

사전은 두 개인데 저장소에는 온 세상 언어가 들어올 수 있습니다. 코드는 그 낙관주의를 믿지 않습니다. 현명하군요.

2단계: localStorage를 외부 상태 저장소로 보기

useSyncExternalStore는 React 바깥에 있는 상태를 구독할 때 쓰는 훅입니다. 이 예제에서 외부 상태는 localStorage에 저장된 locale 값입니다.

스토어가 React에 제공해야 하는 것은 크게 두 가지입니다.

  1. 지금 값을 읽는 스냅샷 함수
  2. 값이 바뀌었을 때 알려주는 구독 함수

현재 값은 이렇게 읽습니다.

export function getLocaleSnapshot(fallback: Locale = DEFAULT_LOCALE): Locale {
  const stored = getBrowserStorage()?.getItem("locale") ?? null;
  return stored === null ? fallback : resolveLocale(stored);
}

여기서 getBrowserStorage()window가 없으면 undefined를 반환합니다. 서버에서 렌더링할 때 localStorage를 읽으려다 ReferenceError를 맞는 전통 의식을 피하기 위한 장치입니다.

값을 저장할 때는 localStorage.setItem()만 호출하지 않습니다. 같은 탭의 구독자에게도 변경을 알려야 합니다.

export function persistLocale(locale: Locale, storage = getBrowserStorage()) {
  if (!storage || typeof window === "undefined") {
    return;
  }

  storage.setItem("locale", locale);
  window.dispatchEvent(new Event("localechange"));
}

왜 별도 이벤트가 필요할까요?

브라우저의 storage 이벤트는 다른 문서에서 저장소가 바뀌었을 때 동작하는 교차 탭 동기화에 유용합니다. 하지만 현재 탭에서 직접 setItem()을 호출한 코드까지 같은 방식으로 깨워주는 장치는 아닙니다. 그래서 현재 탭용 localechange 이벤트를 하나 더 둡니다.

구독 함수는 두 이벤트를 함께 듣습니다.

export function subscribeToLocale(callback: () => void) {
  const handleStorage = (event: StorageEvent) => {
    if (event.key === null || event.key === "locale") {
      callback();
    }
  };

  window.addEventListener("storage", handleStorage);
  window.addEventListener("localechange", callback);

  return () => {
    window.removeEventListener("storage", handleStorage);
    window.removeEventListener("localechange", callback);
  };
}

실제 저장소 코드는 서버 가드와 별도 핸들러를 포함하지만, 구조는 위와 같습니다. 구독할 때 등록하고, 정리 함수에서 정확히 해제합니다.

3단계: React Provider에 연결하기

이제 context.tsx에서 외부 스토어를 React 상태처럼 사용합니다.

const locale = useSyncExternalStore(
  subscribeToLocale,
  () => getLocaleSnapshot(initialLocale),
  () => initialLocale
);

세 번째 인자가 중요합니다. 서버 렌더링에서는 브라우저의 localStorage를 읽을 수 없으므로 서버가 알고 있는 initialLocale을 반환합니다.

클라이언트에서는 저장된 값이 있으면 그 값을 사용하고, 없으면 initialLocale을 폴백으로 씁니다. 언어가 바뀌면 외부 스토어 이벤트가 구독자를 깨우고, 새 스냅샷을 읽은 React가 다시 렌더링합니다.

Provider가 내보내는 값은 단순합니다.

<I18nContext.Provider value={{ locale, setLocale, t: dictionaries[locale] }}>{children}</I18nContext.Provider>

컴포넌트에서는 이렇게 씁니다.

const { t, locale, setLocale } = useI18n();

return <button onClick={() => setLocale(locale === "en" ? "ko" : "en")}>{t.nav.contact}</button>;

페이지가 저장소나 이벤트 이름을 알 필요가 없습니다. t를 읽고 setLocale()을 호출하면 끝입니다.

4단계: 영어 사전을 타입의 기준으로 삼기

번역 사전에서 가장 귀찮은 오류는 키 누락입니다. 영어에는 hero.badge가 있는데 한국어 사전에서 빠지면, 런타임에서 undefined가 화면에 나타날 수 있습니다.

별도 인터페이스를 손으로 관리할 수도 있습니다. 하지만 인터페이스와 실제 사전이 또 따로 놀기 시작하면 관리 대상만 하나 늘어납니다.

이 저장소는 영어 사전 자체에서 타입을 추론합니다.

const en = {
  nav: {
    contact: "Contact",
  },
  hero: {
    badge: "Available for new projects",
  },
};

export type Dictionary = typeof en;

한국어 사전은 현재 코드에서 Dictionary 타입으로 선언됩니다.

import type { Dictionary } from "./en";

const ko: Dictionary = {
  nav: {
    contact: "문의",
  },
  hero: {
    badge: "새 프로젝트 수주 가능",
  },
};

키가 빠지거나 구조가 다르면 TypeScript가 빌드 전에 알려줍니다. 번역 누락을 사용자가 발견하기 전에 컴파일러가 먼저 잔소리하는 구조입니다.

컴파일러의 잔소리는 대체로 무료고 꽤 정확합니다. 사람의 잔소리보다 도입하기도 쉽습니다. ㅎㅎ

새 언어를 추가할 때 놓치기 쉬운 세 곳

이 구조에서 언어 하나를 추가하려면 한 파일만 만들면 끝나지 않습니다.

  1. 새 사전 파일을 만들고 Dictionary 타입을 만족시킵니다.
  2. context.tsxdictionaries 레코드에 등록합니다.
  3. locale-store.tsLocaleresolveLocale()을 확장합니다.

조금 번거롭지만 결합 지점이 명시적이라는 장점도 있습니다. 타입, 런타임 사전, 저장소 정규화가 모두 새 언어를 안다는 사실이 코드에 드러납니다.

이 셋 중 하나를 빠뜨리면 타입 검사나 테스트가 잡도록 만드는 것이 다음 개선 포인트가 될 수 있습니다.

언제 이 방식을 쓰면 안 될까요?

이 글의 결론이 “다국어 라이브러리는 다 무겁고 직접 만들면 최고”는 아닙니다. 그렇게 읽으셨다면 제가 설명을 너무 신나게 한 겁니다. (진정하겠습니다.)

다음 요구가 있다면 검증된 i18n 도구를 먼저 보세요.

  • 복수형과 성별 규칙
  • ICU MessageFormat
  • 번역 파일 지연 로딩
  • 네임스페이스 분리
  • 번역 관리 플랫폼 연동
  • 많은 언어와 번역가 협업
  • 서버 라우팅과 메타데이터의 복잡한 지역화

반대로 작은 사이트에서 언어 두 개, 타입 안전한 사전, 저장 유지, 교차 탭 동기화 정도만 필요하다면 이 구조가 꽤 정직한 기준선이 됩니다.

정리

핵심은 useSyncExternalStore라는 훅 이름을 외우는 것이 아닙니다. 브라우저 저장소를 React 바깥의 상태로 보고, 읽기·쓰기·구독 계약을 명확히 만드는 것입니다.

  • 저장소 값은 resolveLocale()에서 도메인 타입으로 정규화합니다.
  • 같은 탭은 커스텀 이벤트로, 다른 탭은 storage 이벤트로 동기화합니다.
  • 서버 스냅샷은 브라우저 API 없이 반환합니다.
  • 영어 사전에서 Dictionary 타입을 추론해 번역 키 누락을 컴파일 단계에서 잡습니다.

패키지 하나를 덜 설치했다는 사실보다 더 중요한 건, 이 네 가지 책임이 각각 어디에 있는지 코드가 설명한다는 점입니다.

작게 시작하되 경계는 선명하게. 그게 라이브러리를 쓰든 직접 구현하든 오래 버티는 쪽입니다.