왜 직접 만들었나
다국어 지원을 위해 처음 떠올린 건 next-intl이었다. Next.js 생태계에서 가장 많이 쓰이고, 공식 문서에서도 추천하는 라이브러리다.
그런데 요구사항을 정리해보니 의문이 생겼다.
- 한국어, 영어 2개 언어만 지원
- URL에 locale 포함 불필요 (
/ko/dashboard같은 구조 X) - 서버 컴포넌트에서 번역 불필요 (클라이언트에서만 전환)
- 이미 Zustand로 전역 상태 관리 중
next-intl의 핵심 기능인 서버 컴포넌트 번역, 미들웨어 기반 라우팅, locale별 URL 구조를 전혀 쓰지 않을 예정이었다.
선택지 분석
| 방식 | 장점 | 한계 | 적합한 상황 |
|---|---|---|---|
| next-intl | 풍부한 기능, SSR 지원, 커뮤니티 검증 | 설정 복잡, 라우팅 구조 강제, 번들 크기 증가 | locale별 URL 필요, 서버 번역 필요 |
| react-i18next | 범용성, 플러그인 생태계 | Next.js 특화 아님, 설정 복잡 | 대규모 다국어, 복잡한 포맷팅 |
| 직접 구현 | 최소 번들, 완전한 제어, 기존 상태 관리와 통합 | 직접 유지보수 필요 | 단순한 요구사항, 특정 상태 관리 도구 사용 중 |
선택 근거
핵심 제약은 두 가지였다.
- 이미 Zustand를 전역 상태 관리로 사용 중 - locale도 같은 방식으로 관리하면 일관성 유지
- 클라이언트 사이드 전환만 필요 - 서버 컴포넌트 번역 기능이 필요 없음
next-intl을 쓰면 locale 상태가 두 곳에서 관리된다. 미들웨어와 Zustand. 이 이중 관리가 복잡도를 높인다.
직접 만들면 ~100줄 코드로 필요한 기능을 모두 커버할 수 있었다.
구현
구조 설계
lib/i18n/
├── index.ts # useTranslation 훅
├── types.ts # 타입 정의
└── locales/
├── ko/
│ ├── index.ts # 한국어 번역 통합
│ ├── common.json
│ ├── auth.json
│ └── ...
└── en/
├── index.ts # 영어 번역 통합
├── common.json
├── auth.json
└── ...
네임스페이스별로 JSON 파일을 분리했다. common, auth, dashboard 등. 파일이 커지는 걸 방지하고, 관련 번역끼리 모아두기 위함이다.
핵심 1: Zustand로 locale 관리
// localeStore.ts
export const useLocaleStore = create<LocaleState & LocaleActions>()(
persist(
(set) => ({
locale: 'ko',
setLocale: (locale) => set({ locale }),
}),
{
name: 'pixeldiff-locale',
partialize: (state) => ({ locale: state.locale }),
}
)
);
persist 미들웨어로 localStorage에 저장한다. 새로고침해도 선택한 언어가 유지된다.
핵심 2: 중첩 경로 접근
번역 키를 'button.save', 'toast.error.network'처럼 dot notation으로 접근할 수 있어야 했다.
function getNestedValue(obj: unknown, path: string): string | undefined {
const keys = path.split('.');
let value: unknown = obj;
for (const key of keys) {
if (value === null || value === undefined) return undefined;
if (typeof value !== 'object') return undefined;
value = (value as Record<string, unknown>)[key];
}
return typeof value === 'string' ? value : undefined;
}
lodash.get을 쓸 수도 있지만, 번역 전용이라 문자열만 반환하면 되므로 직접 구현했다.
핵심 3: 템플릿 보간
function interpolate(
template: string,
params?: Record<string, string | number>
): string {
if (!params) return template;
return template.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
const trimmedKey = key.trim();
return params[trimmedKey]?.toString() ?? `{{${key}}}`;
});
}
{{variable}} 형식으로 동적 값을 넣을 수 있다.
{
"header": {
"expiresIn": "{{days}}일 남음"
}
}
t('header.expiresIn', { days: 7 }) // "7일 남음"
핵심 4: Hydration 불일치 방지
export function useTranslation<N extends Namespace>(namespace: N) {
const locale = useLocaleStore((s) => s.locale);
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
// ...
return { t, locale, isHydrated };
}
Zustand persist는 localStorage에서 값을 읽어오기 때문에, 서버 렌더링 시점과 클라이언트 hydration 시점에 locale 값이 다를 수 있다.
isHydrated 플래그를 노출해서, 필요한 경우 hydration 완료 후에만 언어 관련 UI를 렌더링할 수 있게 했다.
핵심 5: 비훅 버전
export function getTranslation<N extends Namespace>(namespace: N) {
const state = useLocaleStore.getState();
const locale = state.locale;
return (key: string, params?: Record<string, string | number>): string => {
const translation = getNestedValue(translations[locale][namespace], key);
// ...
};
}
React 컴포넌트 외부에서도 번역이 필요한 경우가 있다. 유틸 함수, API 응답 처리 등. getState()로 현재 locale을 가져와서 처리한다.
사용 예시
// 컴포넌트 내부
const { t } = useTranslation('common');
return (
<Button onClick={handleSave}>
{t('button.save')}
</Button>
);
// 컴포넌트 외부
const t = getTranslation('common');
showToast(t('toast.success.saved'));
트레이드오프
직접 구현의 대가도 있다.
- 타입 안전성 없음:
t('buton.save')오타가 나도 컴파일 에러가 안 난다. 런타임에 콘솔 경고만 뜬다. - 자동완성 없음: IDE에서 어떤 키가 있는지 제안해주지 않는다. JSON 파일을 직접 열어봐야 한다.
- 번역 누락 감지 어려움: 새 키를 추가할 때 다른 언어 파일에 빼먹으면 배포 후에야 발견하게 된다.
next-intl은 TypeScript 플러그인으로 이 문제들을 해결해준다. 규모가 커지면 이런 안전장치가 점점 중요해진다.
next-intl은 좋은 라이브러리지만, 서버 컴포넌트 번역과 locale 기반 라우팅이 핵심 가치다.
이 기능이 필요 없다면, 오히려 설정 복잡도와 번들 크기만 늘어난다.
직접 만든 i18n 시스템:
1. Zustand persist로 locale 저장 - 기존 상태 관리와 일관성
2. 중첩 경로 + 템플릿 보간 - 대부분의 번역 요구사항 커버
3. hydration 불일치 대응 - SSR 환경 필수
~100줄로 필요한 기능을 모두 구현했다. 의존성 하나 줄이고, 디버깅도 쉬워졌다. 다만 복수형 처리(1 item vs 2 items)나 날짜/숫자 포맷팅이 필요해지면 그때 라이브러리를 고려할 예정이다.
프런트엔드 엔지니어, QA 엔지니어 그리고 디자이너를 위한
" ALL IN ONE " QA 서비스
https://pixeldiff.turtle-tail.com
'FrontEnd' 카테고리의 다른 글
| OffscreenCanvas로 Service Worker에서 이미지 처리하기 (0) | 2026.02.10 |
|---|---|
| iframe 키 이벤트 재합성으로 Cross-Origin 우회하기 (0) | 2026.02.05 |
| Sharp + WebP로 이미지 30-50% 용량 절감하기 (0) | 2026.01.27 |
| S3 Presigned URL로 클라이언트 직접 업로드 (0) | 2026.01.22 |
| 풀페이지 스크린샷 타일 스티칭 구현 (0) | 2026.01.20 |
