Next.js App Router 환경에서의 i18n Provider 구현: 깜빡임과 Race Condition 해결

Server Component 패턴으로 UX 개선과 동시성 이슈를 해결하는 방법을 인터랙티브 데모로 설명합니다.

Frontend
Next.js, i18n, Server Components, Concurrency

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>;
};

발견된 문제

  1. 클라이언트 사이드 언어 감지

    • 서버 렌더링과 클라이언트 초기 렌더링 간 불일치
    • useEffect 실행 후 추가 리렌더링 발생
    • Hydration Mismatch 경고
  2. 서버 사이드 싱글톤 패턴

    • 모듈 레벨에서 i18n 인스턴스 생성
    • 모든 요청이 동일한 인스턴스 공유
    • 동시 요청 시 데이터 오염 가능성 (Race Condition)

이 문제들은 특히 트래픽이 증가할 때 두드러지는 특징 이 있습니다.
로컬 개발 환경에서는 단일 사용자만 테스트하기 때문에 발견하기 어렵습니다.


Server Components 패턴 이해하기

문제를 파헤치기 전에, Next.js App Router의 핵심 개념인 Server Components를 먼저 이해해야 합니다.

Client vs Server: 누가 무엇을 알 수 있나?

구분Client ComponentServer 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-ISTO-BE
초기 렌더링영어 → 깜빡임 → 한국어처음부터 한국어
HydrationMismatch 경고 발생일치
리렌더링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의 렌더링 완료 → 반환
         | → 한국 유저에게 한국어 전달 (정상)

예상되는 증상

  1. 간헐적인 언어 오류: 특정 요청에서 잘못된 언어 데이터 반환
  2. 재현 어려움: 동시 요청이 없는 환경(로컬 개발)에서는 발생하지 않음
  3. 트래픽 의존성: 동시 접속 사용자가 많을수록 발생 확률 증가

이러한 특성 때문에 프로덕션 환경에서 디버깅이 매우 어렵습니다.


해결 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 비교표

구분BeforeAfter
언어 결정 위치Client (useEffect)Server (layout.tsx)
초기 렌더링영어 → 깜빡임 → 한국어처음부터 올바른 언어
HydrationMismatch 발생일치
서버 인스턴스싱글톤 (공유)요청별 생성
동시성 안정성Race Condition격리 보장
코드 중복서버 + 클라이언트 중복서버만

정리

핵심 원칙

Next.js App Router 환경에서 i18n Provider를 구현할 때 다음 원칙을 지켜야 합니다.

  1. Server Components 우선 설계

    • 요청 컨텍스트(헤더, 쿠키 등)는 서버에서 처리
    • 클라이언트는 서버로부터 전달받은 데이터 사용
    • useEffect 기반 초기화 지양
  2. 서버 사이드 격리 보장

    • 모듈 레벨 상태 변수 사용 금지
    • 요청마다 독립된 인스턴스 생성
    • Node.js의 싱글 프로세스 특성 고려
  3. 타입 안정성 확보

    • Props를 통한 명시적 데이터 전달
    • 서버와 클라이언트 간 계약 명확화

Before/After 요약

항목BeforeAfter
언어 결정Client (useEffect)Server (layout.tsx)
렌더링2회 (초기 + 재렌더링)1회
HydrationMismatch 발생일치
서버 인스턴스싱글톤 (공유)요청별 생성
동시성Race Condition 가능격리 보장

참고 자료

관련 글

Next.js 공식 문서

i18next 문서

Node.js 동시성

관련 글