배경 및 요구사항
다음 제약 조건 하에 다국어 지원을 구현하고자 합니다:
- Next.js 15 App Router + TypeScript
- Static Export (
output: 'export') — S3 + CloudFront를 통한 배포 - 서버 사이드 렌더링 없음 — 동적 API 및 미들웨어 사용 불가
- SEO 필요 — 검색 엔진에 적절한 언어 콘텐츠 제공 필요
아키텍처 개요
1. 라우팅 설계
/ # 루트 페이지 (기본값: en)
/en/ # 영어 페이지
/ja/ # 일본어 페이지
동적 세그먼트 [locale]을 사용해 정적 생성 시 각 언어에 대한 페이지를 미리 렌더링합니다.
2. 번역 데이터 관리
번역 데이터는 JSON 형식으로 관리합니다:
src/locales/en.json
{
"title": "title"
}
src/locales/ja.json
{
"title": "タイトル"
}
3. React Context API를 이용한 상태 관리
src/i18n/context.tsx
interface I18nContextType {
messages: Record<string, any>;
locale: string;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
export function I18nProvider({
children,
messages,
locale
}: I18nProviderProps) {
return (
<I18nContext.Provider value={{ messages, locale }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslations() {
const context = useContext(I18nContext);
if (!context) {
throw new Error('useTranslations must be used within I18nProvider');
}
const t = (key: string): string => {
// 중첩 키 지원
const keys = key.split('.');
let value: any = context.messages;
for (const k of keys) {
value = value?.[k];
}
return typeof value === 'string' ? value : key;
};
return {
t,
locale: context.locale,
messages: context.messages,
isLoaded: true
};
}
4. 레이아웃 계층에서의 Context 제공
src/app/layout.tsx
export default async function RootLayout({ children }: Props) {
const locale = defaultLocale;
const messages = await getMessages(locale);
return (
<html lang={locale}>
<body>
<I18nProvider messages={messages} locale={locale}>
<LanguageSwitcherClient />
{children}
</I18nProvider>
</body>
</html>
);
}
src/app/[locale]/layout.tsx
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
const messages = await getMessages(locale);
return (
<div lang={locale}>
<I18nProvider messages={messages} locale={locale}>
{children}
</I18nProvider>
</div>
);
}
기본 언어와 클라이언트 사이드 리다이렉트
서버 사이드 (정적 생성 중)
루트 페이지(/)는 항상 **영어(en)**로 생성됩니다:
src/app/page.tsx
export default async function RootPage() {
const locale = defaultLocale; // 'en'
const messages = await getMessages(locale);
return (
<I18nProvider messages={messages} locale={locale}>
<ClientLocaleRedirect />
<PageContent />
</I18nProvider>
);
}
이유:
- SEO 전략: 검색 엔진 크롤러에 안정적인 콘텐츠 제공
- 정적 내보내기 제약: 서버 사이드에서 사용자 언어를 결정할 수 없음
클라이언트 사이드 (브라우저)
첫 접속 시 브라우저 언어 설정을 감지해 자동으로 리다이렉트합니다:
src/components/ClientLocaleRedirect.tsx
'use client';
export default function ClientLocaleRedirect() {
useEffect(() => {
// 리다이렉트가 이미 수행되었는지 확인
if (typeof window === 'undefined' ||
sessionStorage.getItem('localeRedirectDone')) {
return;
}
const supportedLocales = ['en', 'ja'];
const pathname = window.location.pathname;
// 루트 경로에서만 리다이렉트
if (pathname === '/') {
// 브라우저 언어 설정 가져오기
const browserLocales = navigator.languages || [navigator.language];
// 지원되는 언어 찾기
const preferredLocale = browserLocales
.map(lang => lang.split('-')[0]) // 'ja-JP' -> 'ja'
.find(lang => supportedLocales.includes(lang));
// 기본값과 다를 경우 리다이렉트
if (preferredLocale && preferredLocale !== defaultLocale) {
sessionStorage.setItem('localeRedirectDone', 'true');
window.location.replace(`/${preferredLocale}`);
}
}
}, []);
return null;
}
동작 시나리오
| 상황 | SSG 중 | 클라이언트 동작 |
|---|---|---|
| 검색 엔진 크롤러 | /를 영어로 생성 |
리다이렉트 없음 |
| 일본어 브라우저 사용자 | /를 영어로 생성 |
/ja로 리다이렉트 |
| 영어 브라우저 사용자 | /를 영어로 생성 |
리다이렉트 없음 (그대로 표시) |
| 지원하지 않는 언어 사용자 | /를 영어로 생성 |
리다이렉트 없음 (영어로 표시) |
컴포넌트에서의 사용
'use client';
function NetworkInfoClient() {
const { t } = useTranslations();
return (
<div>
<h2>{t('title')}</h2>
</div>
);
}
기술적 장점
- 타입 안전성: TypeScript + Context API로 번역 키 존재 확인 가능
- 명확한 의존성:
useTranslations를 사용하는 컴포넌트는 Provider 내부에 배치해야 하므로 설계가 명확함 - 성능: 정적 생성을 통한 빠른 페이지 전달
- SEO: 각 언어 페이지가 독립적인 URL을 가져 검색 엔진에 최적화
- UX: 브라우저 언어 설정을 기반으로 자동 리다이렉트로 사용자 노력 감소
정리
Next.js App Router 정적 내보내기 환경에서 React Context API를 사용한 다국어 지원을 구현했습니다. 서버 사이드 제약은 클라이언트 사이드 로직으로 보완해 UX를 달성합니다.
기본 언어 변경은 config.ts의 defaultLocale만 수정하면 되어 확장성도 확보되어 있습니다.
