Next.js App Router 환경에서의 i18n Provider 구현: 깜빡임과 Race Condition 해결
Server Component 패턴으로 UX 개선과 동시성 이슈를 해결하는 방법을 인터랙티브 데모로 설명합니다.
Next.js App Router 환경에서 i18n Provider를 구현할 때 발생할 수 있는 두 가지 주요 문제와 해결 방법을 다룹니다.
문제점
- 클라이언트 사이드 언어 감지 → 화면 깜빡임, Hydration 불일치
- 서버 사이드 싱글톤 → Race Condition (동시성 이슈)
해결책
- Server Component에서 언어 결정 → Client에 Props 전달
- 요청별 인스턴스 격리 → 동시 접속 사용자 간 데이터 격리 보장
아래 인터랙티브 데모로 각 패턴의 동작을 직접 확인할 수 있습니다.
배경: 기존 아키텍처 분석
Next.js App Router로 마이그레이션하면서 기존 CSR 기반 i18n 구현을 검토하던 중, 두 가지 구조적 문제를 발견했습니다.
기존 구현 방식
// Client Component에서 언어 감지
'use client';
const TranslationProvider = ({ children }) => {
const [locale, setLocale] = useState('en');
useEffect(() => {
const subdomain = window.location.hostname.split('.')[0];
const detectedLocale = i18nConfig.locales.includes(subdomain)
? subdomain
: i18nConfig.defaultLocale;
setLocale(detectedLocale);
}, []);
return <I18nextProvider i18n={i18nInstance}>{children}</I18nextProvider>;
};발견된 문제
-
클라이언트 사이드 언어 감지
- 서버 렌더링과 클라이언트 초기 렌더링 간 불일치
- useEffect 실행 후 추가 리렌더링 발생
- Hydration Mismatch 경고
-
서버 사이드 싱글톤 패턴
- 모듈 레벨에서 i18n 인스턴스 생성
- 모든 요청이 동일한 인스턴스 공유
- 동시 요청 시 데이터 오염 가능성 (Race Condition)
이 문제들은 특히 트래픽이 증가할 때 두드러지는 특징 이 있습니다.
로컬 개발 환경에서는 단일 사용자만 테스트하기 때문에 발견하기 어렵습니다.
Server Components 패턴 이해하기
문제를 파헤치기 전에, Next.js App Router의 핵심 개념인 Server Components를 먼저 이해해야 합니다.
Client vs Server: 누가 무엇을 알 수 있나?
| 구분 | Client Component | Server Component |
|---|---|---|
| 실행 환경 | 브라우저 | Node.js 서버 |
| 접근 가능 정보 | window, localStorage, DOM | 요청 헤더, 쿠키, 파일 시스템, DB |
| 렌더링 시점 | Hydration 후 | 요청 시점 |
| 사용 예시 | 인터랙션, 애니메이션 | 데이터 페칭, 초기 설정 |
// ✅ Server Component에서 결정 → Client에 전달
// app/layout.tsx (Server Component)
const RootLayout = async ({ children }) => {
// 서버에서 요청 헤더를 보고 언어 결정
const lang = await getLanguageFromHeaders();
return (
<html lang={lang}>
<body>
{/* Client Component에 Props로 전달 */}
<I18nProvider initialLocale={lang}>
{children}
</I18nProvider>
</body>
</html>
);
};// ❌ Client Component에서 결정
// app/provider/TranslationProvider.tsx (Client Component)
'use client';
const TranslationProvider = ({ children }) => {
// 클라이언트에서 브라우저 정보를 보고 언어 결정
useEffect(() => {
const lang = window.location.hostname.split('.')[0];
setLocale(lang); // 문제 발생!
}, []);
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
};두 번째 패턴의 문제점은 다음 섹션에서 인터랙티브 데모와 함께 설명합니다.
🎮 체험해보기: 깜빡임 데모
아래 데모에서 useEffect 방식과 Server Props 방식의 렌더링 차이를 확인할 수 있습니다.
문제 1: useEffect 기반 언어 감지의 UX 문제
위 데모에서 확인했듯이, useEffect 방식은 다음과 같은 문제를 일으킵니다.
실행 순서 분석
1. 서버: 기본 언어('en')로 HTML 생성
2. 클라이언트: HTML 수신 및 화면 표시 ('en' 콘텐츠 보임)
3. React Hydration 시작
4. useEffect 실행 (호스트네임 파싱)
5. 언어 변경 감지 ('en' → 'ko')
6. setLocale('ko') 호출
7. 리렌더링 → 화면 깜빡임! ⚠️사용자가 보는 것
처음 화면: "Welcome to our site" (영어)
↓ (0.1초 후)
깜빡임 후: "사이트에 오신 것을 환영합니다" (한국어)추가 문제들
1. Hydration Mismatch 경고
Warning: Text content did not match.
Server: "Welcome" Client: "환영합니다"서버가 생성한 HTML과 클라이언트의 초기 렌더링 결과가 달라 React가 경고를 출력합니다.
2. 로직 중복
// 서버 측: app/[locale]/layout.tsx
const serverLocale = await getLanguage();
// 클라이언트 측: TranslationProvider.tsx
useEffect(() => {
const clientLocale = location.hostname?.split('.')[0];
// 같은 로직이 두 곳에 존재!
}, []);해결 1: Server-to-Client 데이터 전달 패턴
핵심 아이디어
“서버가 알아서 결정하고, 클라이언트는 받아서 쓰기만 한다”
✅ 개선된 코드
Step 1: 서버에서 언어 결정
// app/layout.tsx (Server Component)
import { getLanguage } from '@/shared/i18n';
import TranslationProvider from '@/app/provider/TranslationProvider';
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
// 서버에서 언어 미리 가져오기 (요청 헤더 기반)
const lang = await getLanguage();
return (
<html lang={lang}>
<body>
{/* Client Component에 props로 전달 */}
<TranslationProvider initialLocale={lang}>
{children}
</TranslationProvider>
</body>
</html>
);
};
export default RootLayout;Step 2: 클라이언트는 받아서 사용
// app/provider/TranslationProvider.tsx (Client Component)
'use client';
import { useRef } from 'react';
import { I18nextProvider } from 'react-i18next';
import initTranslations, { createTranslationInstance } from '@/shared/i18n/lib/initTranslation';
const instance = createTranslationInstance();
type TranslationProviderProps = {
children: React.ReactNode;
initialLocale: string; // 서버로부터 전달받음
};
const TranslationProvider = ({ children, initialLocale }: TranslationProviderProps) => {
const isInitialized = useRef(false);
// 렌더링 중 초기화 (useEffect 제거)
if (!isInitialized.current) {
initTranslations(instance, {
locale: initialLocale,
namespaces: ['common'],
// ...
});
isInitialized.current = true;
}
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
};
export default TranslationProvider;개선 효과
| 항목 | AS-IS | TO-BE |
|---|---|---|
| 초기 렌더링 | 영어 → 깜빡임 → 한국어 | 처음부터 한국어 |
| Hydration | Mismatch 경고 발생 | 일치 |
| 리렌더링 | 2번 (초기 + useEffect 후) | 1번 |
| 로직 위치 | 서버 + 클라이언트 중복 | 서버만 |
서버 사이드의 특수성: 메모리는 공유된다
클라이언트 측 문제를 해결했으니 끝일까요? 아닙니다. 서버 사이드 렌더링(SSR)을 위한 코드를 리뷰하던 중, 훨씬 더 치명적인 문제를 발견했습니다.
중요한 차이: Client vs Server의 메모리 모델
// ✅ Client Component (안전)
const instance = createTranslationInstance(); // 브라우저마다 독립
const TranslationProvider = ({ children }) => {
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
};// ❌ Server Function (위험)
const instance = createTranslationInstance(); // 모든 요청이 공유
export async function getServerTranslation() {
// 모든 사용자가 같은 instance를 사용!
}왜 다를까요?
| 환경 | 실행 컨텍스트 | 모듈 레벨 변수 |
|---|---|---|
| Browser | 각 사용자의 브라우저 | 독립 (각 브라우저마다 새로 생성) |
| Node.js Server | 단일 Node.js 프로세스 | 공유 (모든 요청이 함께 사용) |
브라우저 환경:
─────────────────────────────────────
미국 유저의 Chrome 한국 유저의 Safari
↓ ↓
instance_A instance_B
(독립) (독립)
서버 환경:
─────────────────────────────────────
Node.js 프로세스 (단 하나!)
↓
instance (공유!)
↙ ↘
미국 유저 한국 유저
(충돌!)🎮 체험해보기: Race Condition 시뮬레이터
아래 데모에서 싱글톤 패턴과 격리 패턴의 동작 차이를 시각적으로 확인할 수 있습니다.
“싱글톤 (위험)” 모드로 시뮬레이션을 실행하면 동시성 이슈가 발생하는 과정을 타임라인으로 확인할 수 있습니다.
Race Condition 시뮬레이터
두 사용자가 동시에 접속할 때 싱글톤 vs 격리 패턴의 차이를 확인할 수 있습니다.
현재 모드: 싱글톤 (모든 요청이 하나의 인스턴스 공유)
문제 2: 서버 사이드 싱글톤의 동시성 이슈
위 데모에서 확인했듯이, 서버 사이드 싱글톤은 Race Condition을 일으킵니다.
// shared/i18n/server/getServerTranslation.ts
// 🔴 파일 최상단 (Global Scope)
const i18n = createTranslationInstance(); // 모든 요청이 이 인스턴스를 공유!
const getServerTranslation = async () => {
const locale = await getLanguage();
// 🔴 전역 인스턴스의 언어를 계속 변경함
await initTranslations(i18n, {
locale,
namespaces: ['common'],
// ...
});
return {
i18n,
t: i18n.getFixedT(locale),
// ...
};
};버그 시나리오 (위 데모와 동일)
타임라인:
────────────────────────────────────────────────
t=0ms | 미국 유저(A) 요청 시작
| → i18n.changeLanguage('en') 호출
|
t=10ms | 한국 유저(B) 요청 시작
| → i18n.changeLanguage('ko') 호출
| → 🔴 i18n 인스턴스가 'ko'로 덮어씌워짐!
|
t=15ms | A의 렌더링 완료 → 반환
| → 🔴 미국 유저에게 한국어 콘텐츠 전달! 💥
|
t=20ms | B의 렌더링 완료 → 반환
| → 한국 유저에게 한국어 전달 (정상)예상되는 증상
- 간헐적인 언어 오류: 특정 요청에서 잘못된 언어 데이터 반환
- 재현 어려움: 동시 요청이 없는 환경(로컬 개발)에서는 발생하지 않음
- 트래픽 의존성: 동시 접속 사용자가 많을수록 발생 확률 증가
이러한 특성 때문에 프로덕션 환경에서 디버깅이 매우 어렵습니다.
해결 2: 요청별 인스턴스 격리
// shared/i18n/server/getServerTranslation.ts
// 🟢 모듈 레벨에서는 인스턴스를 생성하지 않음!
const getServerTranslation = async () => {
const locale = await getLanguage();
// 🟢 요청마다 새로운 인스턴스 생성
const i18n = createTranslationInstance();
// 이 인스턴스는 현재 요청에만 속함
await initTranslations(i18n, {
locale,
namespaces: ['common'],
// ...
});
return {
i18n,
t: i18n.getFixedT(locale),
resources: i18n.services.resourceStore.data,
};
};
export default getServerTranslation;개선 후 시나리오
타임라인:
────────────────────────────────────────────────
t=0ms | 미국 유저(A) 요청 시작
| → i18n_A = createTranslationInstance()
| → i18n_A.changeLanguage('en')
|
t=10ms | 한국 유저(B) 요청 시작
| → i18n_B = createTranslationInstance()
| → i18n_B.changeLanguage('ko')
| → ✅ A와 B는 완전히 독립된 인스턴스!
|
t=15ms | A의 렌더링 완료 → i18n_A 사용
| → ✅ 미국 유저에게 영어 전달
|
t=20ms | B의 렌더링 완료 → i18n_B 사용
| → ✅ 한국 유저에게 한국어 전달Provider에 적용하기
앞서 설명한 두 가지 해결책을 실제 i18n Provider 구현에 적용합니다.
최종 아키텍처
app/
├── layout.tsx # Server Component (언어 결정)
└── provider/
└── TranslationProvider.tsx # Client Component (인스턴스 초기화)
shared/
└── i18n/
├── server/
│ ├── getLanguage.ts # 서버 전용: 언어 감지
│ └── getServerTranslation.ts # 서버 전용: SSR 번역
└── lib/
└── initTranslation.ts # 초기화 함수전체 코드
1. Server Component - 언어 결정
// app/layout.tsx
import { getLanguage } from '@/shared/i18n/server/getLanguage';
import TranslationProvider from '@/app/provider/TranslationProvider';
const RootLayout = async ({ children }: { children: React.ReactNode }) => {
const lang = await getLanguage();
return (
<html lang={lang}>
<body>
<TranslationProvider initialLocale={lang}>
{children}
</TranslationProvider>
</body>
</html>
);
};
export default RootLayout;2. 언어 감지 로직 (서버 전용)
// shared/i18n/server/getLanguage.ts
import { headers } from 'next/headers';
import { i18nConfig, type Locales } from '@/app/i18n/config';
export const getLanguage = async (): Promise<Locales> => {
const headersList = await headers();
const host = headersList.get('host') || '';
// Subdomain에서 언어 추출 (예: ko.example.com → ko)
const subdomain = host.split('.')[0];
const resolvedLocale = i18nConfig.locales.includes(subdomain as Locales)
? (subdomain as Locales)
: i18nConfig.defaultLocale;
return resolvedLocale;
};3. Client Provider - 초기화
// app/provider/TranslationProvider.tsx
'use client';
import { useRef } from 'react';
import { I18nextProvider } from 'react-i18next';
import initTranslations, { createTranslationInstance } from '@/shared/i18n/lib/initTranslation';
const instance = createTranslationInstance();
type TranslationProviderProps = {
children: React.ReactNode;
initialLocale: string;
};
const TranslationProvider = ({ children, initialLocale }: TranslationProviderProps) => {
const isInitialized = useRef(false);
if (!isInitialized.current) {
initTranslations(instance, {
locale: initialLocale,
namespaces: ['common'],
// ...
});
isInitialized.current = true;
}
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
};
export default TranslationProvider;4. 서버 사이드 번역 (SSR)
// shared/i18n/server/getServerTranslation.ts
import { getLanguage } from './getLanguage';
import initTranslations, { createTranslationInstance } from '../lib/initTranslation';
const getServerTranslation = async () => {
const locale = await getLanguage();
// 🟢 요청마다 새 인스턴스 생성
const i18n = createTranslationInstance();
const result = await initTranslations(i18n, {
locale,
namespaces: ['common'],
// ...
});
return result;
};
export default getServerTranslation;Before/After 비교표
| 구분 | Before | After |
|---|---|---|
| 언어 결정 위치 | Client (useEffect) | Server (layout.tsx) |
| 초기 렌더링 | 영어 → 깜빡임 → 한국어 | 처음부터 올바른 언어 |
| Hydration | Mismatch 발생 | 일치 |
| 서버 인스턴스 | 싱글톤 (공유) | 요청별 생성 |
| 동시성 안정성 | Race Condition | 격리 보장 |
| 코드 중복 | 서버 + 클라이언트 중복 | 서버만 |
정리
핵심 원칙
Next.js App Router 환경에서 i18n Provider를 구현할 때 다음 원칙을 지켜야 합니다.
-
Server Components 우선 설계
- 요청 컨텍스트(헤더, 쿠키 등)는 서버에서 처리
- 클라이언트는 서버로부터 전달받은 데이터 사용
- useEffect 기반 초기화 지양
-
서버 사이드 격리 보장
- 모듈 레벨 상태 변수 사용 금지
- 요청마다 독립된 인스턴스 생성
- Node.js의 싱글 프로세스 특성 고려
-
타입 안정성 확보
- Props를 통한 명시적 데이터 전달
- 서버와 클라이언트 간 계약 명확화
Before/After 요약
| 항목 | Before | After |
|---|---|---|
| 언어 결정 | Client (useEffect) | Server (layout.tsx) |
| 렌더링 | 2회 (초기 + 재렌더링) | 1회 |
| Hydration | Mismatch 발생 | 일치 |
| 서버 인스턴스 | 싱글톤 (공유) | 요청별 생성 |
| 동시성 | Race Condition 가능 | 격리 보장 |