

1/1/1970
3일 만에 i18n을 20개 언어에 적용한 방법
안녕하세요! 방금 Foony를 20개 언어로 번역하는 거대한 작업을 끝냈어요. 코드베이스 안에 있는 거의 모든 파일을 건드려야 했던 엄청난 일인데, 이걸 딱 3일 만에 끝냈습니다.
아래에서는 어떻게 진행했는지, 구체적인 숫자들은 어떤지, 그리고 왜 업계 표준 대신 (또다시) 직접 번역 라이브러리를 만들기로 했는지 이야기해볼게요.
Why not i18next?
처음에 번역 기능을 붙이려 했을 때는 업계 표준인 i18next와 react-i18next를 당연히 생각했어요.
하지만 저는 AI가 유지보수하기 좋은 구조에 초점을 맞추기로 했습니다. i18next는 강력하지만, API 종류가 너무 다양해서 LLM이 헛소리를 하거나 코드 스타일을 제멋대로 섞어버리기 쉽거든요. 그래서 라이브러리를 아주 단순한 t()와 interpolate() 두 가지로만 제한해서, 10개가 넘는 병렬 에이전트가 거의 사람 손 안 거치고도 100% 타입 세이프한 코드를 작성할 수 있도록 만들었습니다.
또 거대한 생태계에 묶여버리는 것도 살짝 걱정됐어요. React Router v5나 MUI v4 → v5 같은 고통스러운 마이그레이션을 겪어본 입장이라, 자바스크립트 세상에서 빠르게 하위 호환을 깨버리는 일이 얼마나 흔한지 너무 잘 압니다. 복수형 처리 같은 기능을 나중에 직접 추가하는 비용보다, 지금 13만 9천 줄짜리 코드를 손으로 마이그레이션하는 비용이 훨씬 더 크다고 봤어요.
그래서 정말 단순하고, 엄청 가볍고, 우리 팀에 딱 맞게 튜닝된 무언가가 필요했습니다.
그래서 그냥 직접 만들었어요.
고도로 제한된 3 KB짜리 서브셋을 만들었고, 이건 고정확도, 자율 AI 리팩터링을 위해 설계됐습니다. 덕분에 저는 혼자서 5인 팀이 3주 동안 할 일을 3일 만에 해낸 셈이 됐죠.
The Custom Implementation
제가 만든 i18n 라이브러리는 gzip 기준 약 3 KB 정도 되는 미니멀 버전이에요. 노출되는 메인 함수는 두 개뿐입니다. 리액트 밖에서 쓰는 getTranslation()과 컴포넌트에서 쓰는 useTranslation() 훅이죠.
이 함수들이 t()를 반환해서 단순한 문자열 교체를 처리하고, 번역 문자열 속에 링크나 아이콘 같은 React 컴포넌트를 끼워 넣어야 할 때는 interpolate()를 씁니다. 둘 다 변수 치환을 지원해요. 예를 들어 "Hello {{thing}}", {thing: 'World'} 같은 형태죠.
핵심 t() 함수는 이렇게 생겼습니다:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// 키에 '/'가 들어 있으면 네임스페이스가 있다는 뜻
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
그리고 React 훅은 이렇게 되어 있어요:
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
라이브러리 전체 핵심은 약 580줄 정도에 불과합니다. 이 코드가 처리하는 건:
- 번역 파일을 지연 로딩해서, 20개 언어 전체를 모든 유저에게 한 번에 보내지 않도록 함
- 네임스페이스별 코드 스플리팅 (예:
common,misc,games/{gameId}) - "디버그" 로케일을 제공해서, 실제 문자열 대신 키가 그대로 보이도록 만들어 배선이 제대로 되었는지 확인
유지보수가 계속 쉬워지도록 shared/src/i18n/README.md에 문서를 잔뜩 적어뒀어요. 파일 구조부터 클라이언트/서버 양쪽에서의 사용 예시까지 다 들어 있습니다. 표준 라이브러리를 쓰는 게 아니기 때문에, 이런 레퍼런스는 새 팀원이 들어오거나, 미래의 저 자신, 혹은 LLM에게 이 시스템이 어떻게 돌아가는지 알려주는 데 필수입니다.
By the Numbers
이번 업데이트 규모가 어느 정도인지 감을 잡을 수 있도록, 코드베이스에서 실제로 바뀐 걸 숫자로 정리해볼게요:
- 지원 언어: 20개 (개발용 디버그 로케일 1개 추가)
- 새로 만든 로케일 파일: 360개
- 번역 코드 줄 수: 139,031줄
- 클라이언트 전체에 추가된
t()호출: 3,938개 - 수정된 소스 파일: 728개
- "진실의 원본" 역할을 하는 영어 소스 파일: 18개 (게임 16개 + common + misc)
Orchestrating with Agents
이걸 전부 손으로 했다면, 몇 달 동안 정신이 멍해질 정도로 반복 작업만 했을 거예요. 대신 저는 Cursor 에이전트들을 한 번에 여러 개 돌리면서, 힘든 부분을 전부 맡겼습니다.
먼저 코드베이스를 폴더 기준으로 "섹션"으로 쪼갰어요. Foony의 각 게임은 자기만의 폴더와 자기만의 번역 네임스페이스를 갖도록요. 이렇게 하면 사용자가 플레이하는 게임에 필요한 번역만 로드하면 되니까, 초기 로딩 사이즈를 꽤 줄일 수 있습니다.
그 다음 Cursor 에이전트를 여러 개 동시에 돌렸어요. 예를 들어 한 에이전트에게는 "Chess 게임을 번역 시스템으로 전환하기" 같은 미션을 주고, 이 에이전트는 파일을 하나씩 보면서 유저에게 보이는 문자열을 찾아 t('games/chess/some.key') 같은 호출로 바꿉니다.
그 다음 에이전트는 그 키를 적절한 영어 로케일 파일에 추가하고, JSDoc 주석으로 이 문자열이 "무엇"이고 "어디에서" 쓰이는지 설명을 붙여요. 이 컨텍스트는 다른 언어로 번역을 생성할 때 정말 중요합니다. "Save"가 "게임 설정 저장"인지 "그림 맞추기에서 그린 그림 저장"인지 같은 걸 구분해야 하거든요.
Quality Control
생성된 코드는 전부 빠르게 눈으로 검토했습니다. 에이전트들이 생각보다 잘했지만, 가끔 return 위에 useTranslation 훅을 넣는 식의 실수도 했어요.
강력한 타입 기반 번역 구조가 여기에 큰 도움이 됐습니다. 각 로케일별로 필요한 키가 전부 들어 있고 (그리고 들어가면 안 되는 키는 없는지) 타입이 전부 확인해주거든요. 또 t()와 interpolate()를 호출할 때, 진짜 존재하는 번역 문자열만 쓰도록 강제해줍니다.
타입 시스템은 영어 소스 파일에서 모든 가능한 번역 키를 뽑아냅니다:
/**
* 중첩된 객체 타입에서 가능한 모든 경로를 추출해 점 표기 키를 만들어 줍니다.
* 예시: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... 나머지 모든 게임도 동일
이 덕분에 TypeScript 자동완성도 완벽하게 동작하고, 번역 키에 오타가 나면 컴파일 단계에서 바로 잡아줍니다. 에이전트가 t('games/ches/name') 같은 걸 쓰려고 해도, TypeScript가 바로 오류를 내주기 때문에 그런 실수를 할 수가 없어요.
Localization
영어 쪽 변환을 전부 끝낸 다음에는, 남은 로케일 작업을 다시 쪼갰습니다. 각 에이전트가 영어 로케일 파일 하나를 특정 언어로 변환하는 책임을 지도록 했어요.
예를 들어 에이전트에게는 이런 프롬프트를 줬습니다:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
각 파일을 LLM에 집어넣고 한 번에 번역하도록 Cursor에게 스크립트를 짜게 할까도 고민했지만, LLM 비용을 조금이라도 아끼고 싶었어요. 실제로는 "비어 있는 번역만 업데이트하는 스크립트"를 쓰는 게 더 좋은 접근이었고, 아마 다음에도 비슷한 방식을 쓸 것 같습니다. 어떤 문자열이 새로 번역이 필요한지 추적하는 시스템도 만들고 싶지만, 지금은 일단 단순하게 가고 싶어요. 나중에는 번역 데이터를 DB 같은 데로 옮길 수도 있겠죠.
개발 환경에서만 쓸 수 있는 "디버그" 로케일도 하나 추가했습니다. 이걸로 모든 치환된 문자열을 눈으로 확인할 수 있어요 (게다가 그냥 보는 것만으로도 꽤 재밌습니다). 디버그 로케일을 쓰면 t()가 실제 번역 대신 키를 대괄호로 감싼 문자열을 반환합니다:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
그래서 "Welcome to Foony!" 대신 ⟦welcome⟧처럼 보이고, 빠진 번역이 있는지 찾기가 아주 쉬워집니다.
마지막으로, 또 다른 에이전트에게 /{locale}/** 라우팅을 구현하게 해서 /ja/games/chess 같은 주소가 올바른 언어(이 경우엔 일본어) 페이지로 라우팅되도록 했어요.
Translating the Blog
UI 문자열을 번역하는 건 그렇다 치고, 그럼 블로그 글은 어떻게 할까요? 블로그 글까지 전부 번역하려고 또 다른 에이전트들을 잔뜩 띄우는 건 별로 내키지 않았어요.
그래서 아예 에이전트에게 스크립트 하나를 만들게 했습니다. 블로그 번역 전체를 자동화하는 scripts/src/generateBlogTranslations.ts라는 스크립트예요.
동작 방식은 이렇습니다:
client/src/posts/en디렉터리를 스캔해서 영어 MDX 파일을 찾습니다.- 다른 로케일 폴더들(예:
posts/ja,posts/es)에서 빠져 있는 번역 파일이 있는지 확인합니다. - 번역이 없으면, 영어 내용을 읽어서 Gemini 3 Pro Preview에 "마크다운 포맷을 유지하면서 번역해 달라"는 전용 프롬프트와 함께 넣습니다.
- 생성된 번역 파일을 알맞은 위치에 저장합니다.
프론트엔드에서는 import.meta.glob을 써서 이런 MDX 파일들을 동적으로 import합니다. PostPage 컴포넌트는 유저의 현재 로케일을 보고, 거기에 맞는 MDX 파일을 lazy-load 합니다. 아직 스크립트를 안 돌려서 번역이 없는 경우에는, 자연스럽게 영어 버전으로 폴백돼요.
Conclusion
이렇게 해서, 20개 로케일 전체를 지원하는 완전한 사이트가 탄생했습니다!
정말 정신없는 3일이었지만, 그 덕분에 전 세계 유저가 거의 자기 언어처럼 느끼면서 쓸 수 있는 사이트가 완성됐어요. 직접 만든 가벼운 커스텀 라이브러리와, 지루한 리팩터링 작업을 대신 해준 AI 에이전트 덕에 1년 전만 해도 거의 불가능해 보였던 일을 해낸 셈이죠. 복잡한 웹사이트에 대해, 단 한 명의 엔지니어가 3일 만에 풀 i18n을 끝내버린 겁니다.
프로그래밍의 미래는 "얼마나 빠르게 코드를 치느냐"가 아니라, AI 에이전트들을 얼마나 잘 오케스트레이션하고, 그들의 결과물을 검증할 만큼 깊이 있는 도메인 지식을 갖추느냐에 달려 있는 것 같아요.