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

다국어 지원을 붙여야 한다고 하면 보통 패키지부터 찾습니다.
next-intl, react-i18next, i18next 같은 이름들이 줄줄이 나오죠.
당연합니다. 복수형, ICU MessageFormat, 번역 파일 분할, 지연 로딩, 번역 플랫폼 연동까지 생각하면 검증된 라이브러리를 쓰는 게 맞습니다.
그런데 영문과 한글만 있는 작은 단일 페이지라면 어떨까요? 필요한 건 사실 네 가지뿐일 수 있습니다.
- 현재 언어를 읽는다.
- 언어를 바꾸면 React 컴포넌트가 다시 렌더링된다.
- 새로고침해도 선택이 남는다.
- 서버 렌더링 중에는
window를 찾다가 쓰러지지 않는다.
localizations 저장소에서는 이 문제를 useSyncExternalStore와 localStorage로 풀었습니다.
거창한 프레임워크를 새로 만든 건 아닙니다.
파일 세 개와 사전 두 개로 필요한 만큼만 구현했습니다.
(“필요한 만큼만”은 개발자가 가장 자주 실패하는 단위이기도 합니다.)
먼저 구조부터 봅시다
역할은 세 층으로 나뉩니다.
locale-store.ts -> 브라우저 저장소 읽기·쓰기·구독
context.tsx -> React Provider와 useI18n 훅
en.ts / ko.ts -> 타입이 연결된 번역 사전
핵심은 React 상태와 브라우저 저장소를 한 파일에 욱여넣지 않는 것입니다.
locale-store.ts는 React를 모릅니다.
context.tsx는 localStorage의 세부 동작을 직접 다루지 않습니다.
사전 파일은 상태 변경 방법을 모릅니다.
이렇게 나누면 각 층이 정말 작은 책임만 갖습니다. 그리고 작은 책임은 테스트하기 쉽습니다. 네, 결국 또 테스트 이야기입니다. 피할 수 있을 줄 알았죠?
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에 제공해야 하는 것은 크게 두 가지입니다.
- 지금 값을 읽는 스냅샷 함수
- 값이 바뀌었을 때 알려주는 구독 함수
현재 값은 이렇게 읽습니다.
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가 빌드 전에 알려줍니다. 번역 누락을 사용자가 발견하기 전에 컴파일러가 먼저 잔소리하는 구조입니다.
컴파일러의 잔소리는 대체로 무료고 꽤 정확합니다. 사람의 잔소리보다 도입하기도 쉽습니다. ㅎㅎ
새 언어를 추가할 때 놓치기 쉬운 세 곳
이 구조에서 언어 하나를 추가하려면 한 파일만 만들면 끝나지 않습니다.
- 새 사전 파일을 만들고
Dictionary타입을 만족시킵니다. context.tsx의dictionaries레코드에 등록합니다.locale-store.ts의Locale과resolveLocale()을 확장합니다.
조금 번거롭지만 결합 지점이 명시적이라는 장점도 있습니다. 타입, 런타임 사전, 저장소 정규화가 모두 새 언어를 안다는 사실이 코드에 드러납니다.
이 셋 중 하나를 빠뜨리면 타입 검사나 테스트가 잡도록 만드는 것이 다음 개선 포인트가 될 수 있습니다.
언제 이 방식을 쓰면 안 될까요?
이 글의 결론이 “다국어 라이브러리는 다 무겁고 직접 만들면 최고”는 아닙니다. 그렇게 읽으셨다면 제가 설명을 너무 신나게 한 겁니다. (진정하겠습니다.)
다음 요구가 있다면 검증된 i18n 도구를 먼저 보세요.
- 복수형과 성별 규칙
- ICU MessageFormat
- 번역 파일 지연 로딩
- 네임스페이스 분리
- 번역 관리 플랫폼 연동
- 많은 언어와 번역가 협업
- 서버 라우팅과 메타데이터의 복잡한 지역화
반대로 작은 사이트에서 언어 두 개, 타입 안전한 사전, 저장 유지, 교차 탭 동기화 정도만 필요하다면 이 구조가 꽤 정직한 기준선이 됩니다.
정리
핵심은 useSyncExternalStore라는 훅 이름을 외우는 것이 아닙니다.
브라우저 저장소를 React 바깥의 상태로 보고, 읽기·쓰기·구독 계약을 명확히 만드는 것입니다.
- 저장소 값은
resolveLocale()에서 도메인 타입으로 정규화합니다. - 같은 탭은 커스텀 이벤트로, 다른 탭은
storage이벤트로 동기화합니다. - 서버 스냅샷은 브라우저 API 없이 반환합니다.
- 영어 사전에서
Dictionary타입을 추론해 번역 키 누락을 컴파일 단계에서 잡습니다.
패키지 하나를 덜 설치했다는 사실보다 더 중요한 건, 이 네 가지 책임이 각각 어디에 있는지 코드가 설명한다는 점입니다.
작게 시작하되 경계는 선명하게. 그게 라이브러리를 쓰든 직접 구현하든 오래 버티는 쪽입니다.
다음 글도 받아보세요.
글을 끝까지 읽으셨다면, 다음 글은 받은편지함이나 RSS 리더에서 만나보세요.