목차
Remix i18n: Remix 애플리케이션 국제화 가이드
Remix의 서버 우선 아키텍처는 순수 클라이언트 사이드 프레임워크에 비해 국제화를 강력하고 독특하게 만들어 줍니다. Remix를 사용하면 서버에서 로케일을 감지하고, 서버 사이드에서 번역을 로드하고, 완전히 현지화된 HTML을 클라이언트에 전송할 수 있습니다. 번역되지 않은 콘텐츠가 잠깐 표시되거나 클라이언트 사이드 로케일 감지 지연이 발생하지 않습니다.
이 가이드에서는 라우팅, 로케일 감지, 번역 로딩, 그리고 Remix에서 가장 많이 사용되는 i18n 라이브러리인 remix-i18next와의 통합을 포함하여 Remix에서 i18n을 처음부터 구현하는 방법을 다룹니다.
Remix i18n이 다른 이유
Remix는 loader와 action에서 서버 코드를 실행하고, 서버와 클라이언트 양쪽에서 컴포넌트를 렌더링합니다. 이로 인해 i18n에서 중요한 차이가 발생합니다.
서버 사이드 번역 로딩: 클라이언트 사이드 React 앱에서는 번역이 브라우저에 의해 로드됩니다. Remix에서는 loader 함수에서 번역을 로드하여 컴포넌트에 전달할 수 있으므로, 서버를 떠나는 HTML이 이미 번역된 상태입니다.
URL 기반 라우팅: Remix의 파일 기반 라우팅은 /en/products나 /fr/products와 같은 로케일 기반 URL 패턴에 자연스럽게 매핑됩니다.
점진적 향상: Remix는 JavaScript 없이도 작동하도록 설계되었습니다. 현지화된 라우트는 클라이언트 사이드 하이드레이션 없이도 올바르게 렌더링되어야 합니다.
중첩 라우팅: Remix의 중첩 레이아웃은 로케일 감지와 번역 컨텍스트를 라우트 트리 상단에서 설정하고 하위 라우트에 상속할 수 있습니다.
remix-i18next 설정
remix-i18next는 i18next 위에 구축되어 서버 사이드 및 클라이언트 사이드 i18n을 위한 Remix 전용 유틸리티를 제공합니다.
설치
npm install remix-i18next i18next react-i18next i18next-browser-languagedetector i18next-http-backend
프로젝트 구조
app/ ├── locales/ │ ├── en/ │ │ ├── common.json │ │ └── home.json │ └── fr/ │ ├── common.json │ └── home.json ├── i18n.server.ts # 서버 사이드 i18n 설정 ├── i18n.client.ts # 클라이언트 사이드 i18n 설정 ├── i18next.server.ts # remix-i18next 인스턴스 └── root.tsx
서버 사이드 설정
// app/i18n.server.ts
import { RemixI18Next } from 'remix-i18next';
import i18n from './i18n';
import Backend from 'i18next-fs-backend';
import { resolve } from 'node:path';
const i18next = new RemixI18Next({
detection: {
supportedLanguages: i18n.supportedLngs,
fallbackLanguage: i18n.fallbackLng,
},
i18next: {
...i18n,
backend: {
loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
},
},
plugins: [Backend],
});
export default i18next;
// app/i18n.ts - 공유 설정
export default {
supportedLngs: ['en', 'fr', 'de', 'ja', 'ar'],
fallbackLng: 'en',
defaultNS: 'common',
react: { useSuspense: false },
};
루트 라우트 설정
// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import {
Links, Meta, Outlet, Scripts, ScrollRestoration, useLoaderData,
} from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import { useChangeLanguage } from 'remix-i18next';
import i18next from '~/i18next.server';
import i18n from '~/i18n';
export async function loader({ request }: LoaderFunctionArgs) {
const locale = await i18next.getLocale(request);
return json({ locale });
}
export let handle = { i18n: 'common' };
export default function App() {
const { locale } = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
useChangeLanguage(locale);
return (
<html lang={locale} dir={i18n.dir()}>
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
클라이언트 사이드 초기화
// app/entry.client.tsx
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { RemixBrowser } from '@remix-run/react';
import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
import { getInitialNamespaces } from 'remix-i18next';
import i18n from '~/i18n';
async function hydrate() {
await i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(Backend)
.init({
...i18n,
ns: getInitialNamespaces(),
backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
detection: {
order: ['htmlTag'],
caches: [],
},
});
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});
}
hydrate();
서버 사이드 엔트리
// app/entry.server.tsx
import { createInstance } from 'i18next';
import { initReactI18next } from 'react-i18next';
import Backend from 'i18next-fs-backend';
import { renderToReadableStream } from 'react-dom/server';
import { RemixServer } from '@remix-run/react';
import { resolve } from 'node:path';
import i18n from '~/i18n';
import i18next from '~/i18next.server';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const instance = createInstance();
const lng = await i18next.getLocale(request);
const ns = i18next.getRouteNamespaces(remixContext);
await instance
.use(initReactI18next)
.use(Backend)
.init({
...i18n,
lng,
ns,
backend: {
loadPath: resolve('./public/locales/{{lng}}/{{ns}}.json'),
},
});
const body = await renderToReadableStream(
<I18nextProvider i18n={instance}>
<RemixServer context={remixContext} url={request.url} />
</I18nextProvider>,
{
onError(error) { responseStatusCode = 500; },
}
);
responseHeaders.set('Content-Type', 'text/html');
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
URL 기반 로케일 라우팅
일반적인 패턴은 URL에 로케일을 인코딩하는 것입니다: /en/products, /fr/produits. Remix에서는 레이아웃 라우트를 사용하여 이를 구현합니다.
app/routes/ ├── ($locale).tsx # 로케일 접두사를 처리하는 레이아웃 라우트 ├── ($locale).index.tsx # 홈 페이지 ├── ($locale).products.tsx # 제품 페이지 └── ($locale).blog.$slug.tsx # 블로그 포스트
// app/routes/($locale).tsx
import { json, redirect, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'ja', 'ar'];
export async function loader({ params, request }: LoaderFunctionArgs) {
const { locale } = params;
// 로케일 접두사 없음: 감지 후 리디렉션
if (!locale) {
const acceptLanguage = request.headers.get('Accept-Language') ?? 'en';
const preferredLocale = parseAcceptLanguage(acceptLanguage, SUPPORTED_LOCALES);
return redirect(`/${preferredLocale}`);
}
// 유효하지 않은 로케일: 404
if (!SUPPORTED_LOCALES.includes(locale)) {
throw new Response('Not Found', { status: 404 });
}
return json({ locale });
}
function parseAcceptLanguage(header: string, supported: string[]): string {
// Accept-Language 헤더를 파싱하여 최적의 일치 항목 반환
const languages = header
.split(',')
.map(lang => lang.trim().split(';')[0].trim().split('-')[0].toLowerCase());
for (const lang of languages) {
if (supported.includes(lang)) return lang;
}
return supported[0];
}
export default function LocaleLayout() {
return <Outlet />;
}
라우트 컴포넌트에서 번역 사용
// app/routes/($locale).products.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useTranslation } from 'react-i18next';
import i18next from '~/i18next.server';
export let handle = { i18n: ['common', 'products'] };
export async function loader({ request }: LoaderFunctionArgs) {
const t = await i18next.getFixedT(request, 'products');
// SEO에 중요한 콘텐츠에 서버 사이드 t() 사용
const title = t('page.title');
const description = t('page.description');
return json({ title, description });
}
export function meta({ data }: MetaArgs) {
return [
{ title: data?.title },
{ name: 'description', content: data?.description },
];
}
export default function ProductsPage() {
const { t } = useTranslation('products');
const { t: tc } = useTranslation('common');
return (
<main>
<h1>{t('page.title')}</h1>
<p>{t('page.description')}</p>
<a href="/">{tc('nav.home')}</a>
</main>
);
}
언어 전환기 컴포넌트
// app/components/LanguageSwitcher.tsx
import { Link, useLocation, useParams } from '@remix-run/react';
const LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
{ code: 'ja', label: '日本語' },
{ code: 'ar', label: 'العربية' },
];
export function LanguageSwitcher() {
const { locale = 'en' } = useParams();
const { pathname } = useLocation();
// 현재 경로에서 로케일 교체
const getLocalePath = (newLocale: string) => {
return pathname.replace(`/${locale}`, `/${newLocale}`);
};
return (
<nav aria-label="Language selection">
{LANGUAGES.map(({ code, label }) => (
<Link
key={code}
to={getLocalePath(code)}
aria-current={code === locale ? 'page' : undefined}
hrefLang={code}
>
{label}
</Link>
))}
</nav>
);
}
RTL 지원
아랍어와 히브리어의 경우 <html> 요소에 dir 속성을 설정합니다.
// app/root.tsx
const RTL_LOCALES = ['ar', 'he', 'fa', 'ur'];
export default function App() {
const { locale } = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
const dir = RTL_LOCALES.includes(locale) ? 'rtl' : 'ltr';
return (
<html lang={locale} dir={dir}>
{/* ... */}
</html>
);
}
CSS 및 React에서의 RTL 지원에 대한 자세한 내용은 CSS와 React에서의 RTL 지원을 참조하시기 바랍니다.
SEO 고려사항
다국어 Remix 앱에서 SEO를 위해 다음이 필요합니다.
<head>에 대체 hreflang 태그 추가:
export function meta({ data, location }: MetaArgs) {
const baseUrl = 'https://example.com';
const pathWithoutLocale = location.pathname.replace(/^\/(en|fr|de|ja|ar)/, '');
return [
{ title: data?.title },
// hreflang 대체 링크
{ tagName: 'link', rel: 'alternate', hrefLang: 'en', href: `${baseUrl}/en${pathWithoutLocale}` },
{ tagName: 'link', rel: 'alternate', hrefLang: 'fr', href: `${baseUrl}/fr${pathWithoutLocale}` },
{ tagName: 'link', rel: 'alternate', hrefLang: 'x-default', href: `${baseUrl}/en${pathWithoutLocale}` },
];
}
종합적인 현지화 SEO 전략에 대해서는 현지화 SEO를 참조하시기 바랍니다.
Remix에서의 복수형 처리
i18next는 _one, _other, _zero 키 접미사를 통해 복수형을 처리합니다.
// public/locales/en/common.json
{
"items_one": "{{count}} item",
"items_other": "{{count}} items",
"items_zero": "No items"
}
const { t } = useTranslation();
t('items', { count: 0 }); // "No items"
t('items', { count: 1 }); // "1 item"
t('items', { count: 42 }); // "42 items"
복잡한 복수형 규칙을 가진 언어(러시아어는 4가지 형태, 아랍어는 6가지 형태)의 경우 i18next는 CLDR 복수형 규칙을 자동으로 사용합니다. 자세한 내용은 언어별 복수형 규칙을 참조하시기 바랍니다.
성능 최적화
네임스페이스 분할: handle.i18n 내보내기를 사용하여 라우트별로 필요한 네임스페이스만 로드합니다. 번역 번들이 크면 초기 로딩이 느려집니다.
정적 생성: 콘텐츠가 많은 사이트의 경우 Remix의 정적 생성 또는 Cloudflare Pages를 사용하여 사전 렌더링된 현지화 페이지를 제공합니다.
번역 캐싱: 프로덕션 환경에서는 CDN 레벨에서 번역 파일을 캐시합니다. 번역 파일은 거의 변경되지 않으므로 적극적인 캐싱이 적합합니다.
네임스페이스 지연 로딩: 화면 상단에 표시되지 않는 콘텐츠는 클라이언트에서 보조 네임스페이스를 지연 로딩합니다.
better-i18n으로 앱을 글로벌하게 확장하세요
better-i18n은 AI 기반 번역, git 네이티브 워크플로우, 글로벌 CDN 배포를 개발자 친화적인 하나의 플랫폼으로 결합합니다. 스프레드시트 관리를 그만하고 모든 언어로 서비스를 제공하세요.
무료로 시작하기 → · 기능 살펴보기 · 문서 읽기