[TIL] Next.js 15 국제화(i18n), next-intl로 다국어 지원하기
![Cover image for [TIL] Next.js 15 국제화(i18n), next-intl로 다국어 지원하기](/_next/image?url=https%3A%2F%2Fapp.notion.com%2Fimages%2Fpage-cover%2Fmet_the_unicorn_in_captivity.jpg&w=3840&q=75)
웹 애플리케이션을 글로벌 서비스로 확장하려면 국제화(i18n, Internationalization)는 필수입니다.
국제화(i18n)란?
- 정의: 여러 언어와 문화에 맞춘 소프트웨어를 만드는 과정
- 범위: 단순 번역을 넘어 날짜 형식, 숫자 표기법, 문화적 관습 모두 고려
- 중요성: 글로벌 사용자 경험 향상과 시장 진출의 핵심 요소
이번에 마인스위퍼 게임 프로젝트를 완전히 국제화하면서 배운 내용을 정리해보겠습니다. Next.js 15 App Router 환경에서
next-intl을 활용해 한국어/영어 지원을 구현한 실전 경험을 공유합니다.기술 스택과 구현 목표
사용한 기술 스택
- Next.js 15 (App Router)
- next-intl (국제화 라이브러리)
- TypeScript (타입 안전성)
- Tailwind CSS (스타일링)
구현 목표
- 전역 언어 전환: 모든 페이지에서 언어 변경 가능
- 완전한 현지화: 정적 텍스트, 동적 콘텐츠, 에러 메시지 모두 번역
- SEO 최적화: 언어별 메타데이터 및 URL 구조
- 타입 안전성: 번역 키의 컴파일 타임 검증
- 서버/클라이언트 호환: SSR과 클라이언트 렌더링 모두 지원
1. 기초 설정 및 아키텍처
next-intl 선택 이유와 기본 설정
next-intl을 선택한 이유:
- Next.js 네이티브 지원: App Router와 완벽 호환
- 타입 안전성: TypeScript 지원으로 번역 키 검증
- SSR/SSG 지원: 서버 사이드 렌더링에서도 동작
- 성능 최적화: 지연 로딩과 코드 스플릿팅 지원
yarn add next-intl
설정 파일 구조의 역할:
// src/i18n/config.ts import { notFound } from 'next/navigation'; import { getRequestConfig } from 'next-intl/server'; // 지원 언어 정의 (as const로 타입 안전성 보장) export const locales = ['en', 'ko'] as const; export type Locale = (typeof locales)[number]; export default getRequestConfig(async ({ locale }) => { // 비지원 언어 요청 시 404 처리 if (!locales.includes(locale as Locale)) notFound(); return { // 동적 import로 번역 파일 로딩 (성능 최적화) messages: (await import(`../locales/${locale}.json`)).default }; });
Next.js 설정에 플러그인을 추가합니다:
// next.config.ts import createNextIntlPlugin from 'next-intl/plugin'; const withNextIntl = createNextIntlPlugin('./src/i18n/config.ts'); const nextConfig = { // 기타 설정... }; export default withNextIntl(nextConfig);
2. 체계적인 번역 파일 구조
효율적인 번역 관리를 위해 네임스페이스 기반 구조를 설계했습니다:
{ "common": { "username": "Username", "password": "Password" }, "navigation": { "ranking": "Ranking", "profile": "Profile" }, "game": { "difficulty": { "beginner": "Beginner", "intermediate": "Intermediate" }, "ui": { "mines": "Mines", "score": "Score" } }, "profile": { "title": "My Profile", "welcomeBack": "Welcome back, {username}!" // 동적 변수 삽입 } }
네임스페이스 기반 구조의 장점:
- 유지보수성: 기능별 분리로 대규모 프로젝트에서도 관리 용이
- 충돌 방지: 다른 기능에서 동일한 키명 사용 가능
- 청크 로딩: 필요한 네임스페이스만 로딩하여 성능 향상
- 협업 효율성: 번역자와 개발자 간 역할 분담 명확
키 명명 규칙:
camelCase사용 (JavaScript 스타일 일치)
- 동적 변수:
{username},{count}형식
- 계층구조:
.로 연결 (profile.stats.totalGames)
3. Context 기반 로케일 관리
전역 상태로 언어 설정을 관리하는 Context를 구현했습니다.
Context API가 필요한 이유:
- 전역 상태 공유: 모든 컴포넌트에서 현재 언어 접근 가능
- Prop Drilling 방지: 깊이 중첩된 컴포넌트에도 props 전달 불필요
- 일관된 언어 처리: 단일 진실 공급원으로 동기화 이슈 해결
'use client'; import { createContext, useContext, useState } from 'react'; interface LocaleContextType { locale: Locale; setLocale: (locale: Locale) => void; isLoading: boolean; } const LocaleContext = createContext<LocaleContextType | undefined>(undefined); export function LocaleProvider({ children, initialLocale }) { const [locale, setLocaleState] = useState<Locale>(initialLocale); const [isLoading, setIsLoading] = useState(false); const setLocale = async (newLocale: Locale) => { setIsLoading(true); // 쿠키에 언어 설정 저장 및 페이지 새로고침 document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`; window.location.reload(); }; return ( <LocaleContext.Provider value={{ locale, setLocale, isLoading }}> {children} </LocaleContext.Provider> ); } export const useLocale = () => { const context = useContext(LocaleContext); if (!context) throw new Error('useLocale must be used within a LocaleProvider'); return context; };
주요 기술적 결정 사항:
- 쿠키 저장: 새로고침 후에도 언어 설정 유지
- window.location.reload(): next-intl의 서버 사이드 설정 동기화 필요
- 오류 예외 처리: Context 외부에서 사용 시 명확한 오류 메시지
4. 커스텀 번역 훅 시스템
코드의 재사용성과 가독성을 높이기 위해 네임스페이스별 전용 훅을 만들었습니다.
커스텀 훅 시스템의 이점:
- 코드 분리: 각 기능에 맞는 번역만 로드
- 타입 안전성: 잘못된 네임스페이스 사용 시 컴파일 에러
- 개발자 경험: 자동완성과 IDE 지원 강화
// 네임스페이스별 전용 훅 export const useGameTranslation = () => useTranslations('game'); export const useProfileTranslation = () => useTranslations('profile'); // 통합 번역 훅 (여러 네임스페이스 동시 사용) export function useTranslation() { const { locale, setLocale } = useLocale(); return { t: { common: useTranslations('common'), game: useTranslations('game'), auth: useTranslations('auth'), }, locale, setLocale }; }
사용 예시:
function ProfilePage() { const tProfile = useProfileTranslation(); const tGame = useGameTranslation(); return ( <div> <h1>{tProfile('title')}</h1> <p>{tProfile('welcomeBack', { username: 'John' })}</p> <span>{tGame('difficulty.beginner')}</span> </div> ); }
5. Server/Client 컴포넌트 아키텍처
Next.js 15 App Router에서 가장 까다로운 부분은 Server Component와 Client Component의 경계를 올바르게 나누는 것입니다.
Server vs Client Component i18n 처리 차이점:
- Server Component:
getTranslations()함수 사용, 비동기 호출
- Client Component:
useTranslations()훅 사용, 리액트 훅 규칙 준수
- 하이드레이션 이슈: 서버/클라이언트 번역 불일치 방지 필요
문제 상황
// ❌ 문제: Server Component에서 클라이언트 훅 사용 불가 export default async function ProfilePage() { const data = await fetchProfileData(); const t = useProfileTranslation(); // ❌ 리액트 훅 사용 불가! return <div>{t('title')}</div>; }
해결책: Wrapper Pattern
// 1. Server Component (데이터 페칭) export default async function ProfilePage({ searchParams }) { const session = await getSession(); const [profileData, rankingStats] = await Promise.all([ getUserProfile(session.id), getUserRankingStats(session.id) ]); return ( <ProfilePageContent session={session} profileData={profileData} rankingStats={rankingStats} /> ); } // 2. Client Component (번역 처리) 'use client'; export function ProfilePageContent({ session, profileData }) { const tProfile = useProfileTranslation(); return ( <div> <h1>{tProfile('title')}</h1> <h2>{tProfile('welcomeBack', { username: session.username })}</h2> </div> ); }
이 패턴을 통해 서버에서 데이터 페칭, 클라이언트에서 번역 처리를 깔끔하게 분리할 수 있습니다.
6. UI 컴포넌트 현지화
다국어 UI 컴포넌트 예시
// 언어 전환 버튼 export function LanguageSelector() { const { locale, setLocale } = useLocale(); return ( <Button onClick={() => setLocale(locale === 'en' ? 'ko' : 'en')}> {locale === 'en' ? '한글' : 'EN'} </Button> ); } // 다국어 테이블 export function RankingTable({ data }) { const t = useRankingTranslation(); return ( <Table> <TableHead>{t('table.rank')}</TableHead> <TableHead>{t('table.player')}</TableHead> </Table> ); }
7. TypeScript 타입 안전성
번역 키의 오타나 누락을 방지하기 위해 타입 시스템을 적극 활용했습니다:
**타입 안전성 도구:** - **next-intl 타입 지원**: 번역 키 자동완성과 검증 - **TypeScript 연동**: 인터페이스를 통한 강한 타입 채크 - **빌드 타임 검증**: 존재하지 않는 번역 키 사용 시 에러 ```typescript // ✅ 강한 타입 정의 interface ProfilePageProps { session: { id: number; username: string }; profileData: ProfileData | null; rankingStats: UserStats | null; }
## 주요 이슈와 해결책 ### 1. Server/Client Component 경계 문제 **문제:** "async Client Component" 에러 **해결:** 서버에서 데이터 패칭 → props 전달 패턴 사용 ### 2. 번역 키 누락 방지 **전략:** - 네임스페이스별 전용 훅 사용 - 일관된 키 명명 규칙 적용 - 번역 파일 구조 문서화 ### 3. 성능 최적화 **최적화 기법:** - 번역 파일을 네임스페이스별로 분할 (향후 개선 예정) - 언어 변경 시 필요한 부분만 리렌더링 - 서버 사이드에서 초기 로케일 설정 ## 구현 결과 ### 완성된 기능들 1. **✅ 전역 언어 전환** - Navbar와 설정 패널에서 접근 가능 2. **✅ 완전한 페이지 현지화** - Profile, Ranking, Game UI 모두 번역 3. **✅ 동적 콘텐츠 번역** - 사용자명 삽입, 난이도별 텍스트 등 4. **✅ 타입 안전성** - 모든 `any` 타입 제거, 컴파일 타임 검증 ## 향후 개선 방향 ### 1. 성능 최적화 전략 ```typescript // 청크 기반 지연 로딩 const gameTranslations = await import(`../locales/${locale}/game.json`);
2. 고급 국제화 기능
- 복수형 처리: 언어별 복수 규칙 적용
- RTL(Right-to-Left) 지원: 아랍어, 히브리어 등
- 로케일별 포매팅: 숫자, 날짜, 시간 형식 자동 조정
3. 개발자 경험 개선
- 번역 키 자동 생성: VS Code 확장으로 번역 키 자동 추가
- 누락된 번역 감지: 빌드 시 번역 키 검증
- 번역 관리 도구: 번역자와 개발자 간 협업 도구
핵심 학습 포인트
이번 프로젝트를 통해 얻은 주요 인사이트들:
1. 아키텍처 설계의 중요성
- 분리된 관심사: 데이터 페칭과 UI 렌더링 분리
- 재사용 가능한 훅: 네임스페이스별 번역 훅으로 코드 중복 제거
- 타입 안전성: TypeScript를 활용한 번역 키 검증
2. Next.js App Router와 i18n
- Server Component 제약: 클라이언트 훅 사용 불가
- Wrapper Pattern: 서버 데이터 + 클라이언트 UI 조합
- SSR 호환성: 서버 사이드 렌더링에서도 번역 작동
3. 사용자 경험 고려사항
- 일관된 언어 전환: 모든 페이지에서 접근 가능
- 즉시 반영: 언어 변경 시 즉시 UI 업데이트
- 상태 유지: 페이지 새로고침 후에도 언어 설정 유지