

1/1/1970
3일 만에 20개 언어 i18n을 구현한 방법
안녕하세요! 방금 Foony를 20개 언어로 번역하는 거대한 작업을 끝냈어요. 코드베이스의 거의 모든 파일을 손대야 하는 엄청난 작업이었지만, 단 3일 만에 모두 끝낼 수 있었습니다.
아래에서 어떻게 해냈는지, 변경 사항의 구체적인 수치, 그리고 업계 표준을 쓰지 않고 (또 다시) 직접 번역 라이브러리를 만든 이유를 풀어볼게요.
왜 i18next가 아닐까?
처음 번역을 추가하려고 했을 때는 업계 표준인 i18next와 react-i18next를 고려했어요.
대신, 저는 AI에 의한 유지보수성에 최적화하기로 결정했습니다. i18next는 강력하지만, API의 다양성 때문에 LLM이 환각을 일으키거나 일관성 없는 코드를 작성할 수 있어요. 라이브러리를 단순한 t()와 interpolate()로 제한함으로써, 10개 이상의 병렬 에이전트가 사람의 개입 거의 없이 100% 타입 안전한 코드를 작성할 수 있도록 했습니다.
또한 나중에 호환성을 깨는 변경 사항이 생길지도 모르는 거대한 생태계에 발을 들이는 게 꺼려졌어요. React Router v5나 MUI v4 → v5 같은 고통스러운 마이그레이션에 데인 적이 있는 만큼, JavaScript 세상에서 하위 호환성이 빠르게 깨지는 일이 너무 흔하다는 걸 잘 알고 있죠. 나중에 복수형 기능을 추가하는 비용이 지금 13만 9천 줄의 코드를 수동으로 마이그레이션하는 비용보다 적습니다.
저는 정말 단순하고, 매우 가볍고, 우리 팀의 필요에 정확히 맞춰진 무언가를 원했어요.
그래서 직접 만들었습니다.
높은 정확도의 자율 AI 리팩터링을 가능하게 하도록 설계된 3 KB짜리 제한된 서브셋을 만들었어요. 덕분에 5명의 팀이 3주에 걸쳐 할 일을 혼자서 단 3일 만에 해낼 수 있었습니다.
직접 만든 구현체
gzip 압축 시 약 3 KB 정도의 미니멀한 i18n 라이브러리를 떠올렸어요. 두 가지 주요 함수를 노출합니다: 비 React 컨텍스트용 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;
// Check if key contains '/' - this indicates a namespace
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();
// Subscribe to locale loading events to trigger re-renders when translations are loaded
const version = useSyncExternalStore(
(callback) => LocaleQueryer.onLoad(callback),
() => LocaleQueryer.getVersion(),
() => LocaleQueryer.getVersion()
);
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}). - 모든 게 제대로 연결되어 있는지 확인할 수 있도록 원시 키를 보여주는 "debug" 로케일.
시스템을 유지보수하기 쉽게 만들기 위해, shared/src/i18n/README.md에 파일 구조부터 클라이언트와 서버 모두를 위한 사용 예시까지 다루는 포괄적인 문서도 추가했습니다. 표준 라이브러리를 쓰지 않기 때문에, 새로운 팀원을 온보딩할 때 (또는 미래의 저나 LLM에게 작동 방식을 상기시킬 때) 이 레퍼런스가 매우 중요해요.
숫자로 보기
이번 업데이트의 규모를 가늠할 수 있도록, 코드베이스에서 변경된 내용은 다음과 같습니다:
- 지원 언어 20개 (개발용 debug 로케일 포함).
- 생성된 로케일 파일 360개.
- 번역 코드 139,031줄.
- 클라이언트 전반에 걸쳐 추가된
t()호출 3,938개. - 수정된 소스 파일 728개.
- 진실의 원천 역할을 하는 영어 소스 파일 18개 (게임 16개 + common + misc).
에이전트로 오케스트레이션하기
이걸 수동으로 하려고 했다면 정신이 멍해지는 기계적인 작업으로 몇 달이 걸렸을 거예요. 대신, 저는 십수 개의 Cursor 에이전트를 동시에 오케스트레이션해서 무거운 작업을 맡겼습니다.
먼저 코드베이스를 폴더 기반의 "섹션"으로 나누는 것부터 시작했어요. Foony의 각 게임은 자체 폴더와 자체 번역 네임스페이스를 갖게 됐습니다. 이렇게 하면 플레이하는 게임의 번역만 로드하면 되니까 초기 로드 사이즈가 작게 유지돼요.
여러 Cursor 에이전트를 동시에 돌렸어요. 각 에이전트에게 "체스 게임을 번역을 사용하도록 변환하기" 같은 특정 섹션을 할당하면, 파일을 하나씩 살펴보면서 사용자에게 보이는 문자열을 찾아 t('games/chess/some.key')로 교체했습니다.
그런 다음 에이전트는 해당 키를 적절한 영어 로케일 파일에 추가하면서 그 문자열의 "무엇"과 "어디"를 설명하는 JSDoc 주석을 달아요. 이 컨텍스트는 다른 언어의 번역을 생성할 때 중요한데, "Save"가 "게임 설정 저장"인지 "Draw & Guess 그림 저장"인지 LLM이 이해하는 데 도움이 됩니다.
품질 관리
생성된 모든 코드를 빠르게 리뷰했어요. 에이전트들은 놀라울 정도로 잘 했지만, 가끔 실수도 했어요. 예를 들면 useTranslation 훅을 이른 return 문 뒤에 두는 식이죠.
강타입 번역이 엄청난 도움이 됐습니다. 덕분에 각 로케일의 모든 번역이 올바른 키를 모두 갖고(잘못된 키는 하나도 없도록) 있다는 걸 보장할 수 있었어요. 또한 t()와 interpolate() 호출이 실제로 존재하는 번역 문자열을 사용하도록 했습니다.
타입 시스템은 영어 소스 파일에서 가능한 모든 번역 키를 추출해요:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {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>}`
// ... and so on for all games
이를 통해 완벽한 TypeScript 자동완성을 얻을 수 있고, 번역 키의 오타는 컴파일 타임에 모두 잡혀요. 에이전트들은 t('games/ches/name') 같은 실수를 할 수 없습니다. TypeScript가 즉시 표시해 주거든요.
현지화
영어 변환이 끝난 후, 남은 로케일 작업을 나눴습니다. 각 에이전트가 영어 로케일 파일 하나를 지정된 언어로 변환하는 책임을 맡도록 했어요.
예를 들어, 에이전트들에게 다음과 같은 프롬프트를 줬습니다:
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.
Cursor가 이 파일들을 LLM에 넣어 자동 생성하는 스크립트를 만들도록 시키는 것도 고려했지만, LLM 비용을 조금 아끼고 싶었어요. 누락된 번역만 업데이트하는 스크립트를 쓰는 게 더 나은 접근이었고, 앞으로도 비슷한 솔루션을 쓸 것 같습니다. 어떤 문자열이 업데이트나 번역이 필요한지 추적하고 싶지만, 단순함은 유지하고 싶어요. 번역 작업을 데이터베이스 같은 곳으로 옮길지도 모르겠네요.
또한 개발 환경에서만 사용할 수 있는 "debug" 로케일도 추가했습니다. 이걸로 모든 치환된 문자열을 확인해서 잘 작동하는지 검증할 수 있어요(게다가 멋지기도 하고요). debug 로케일을 사용하면 t()가 키를 괄호로 감싸서 반환합니다:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
그래서 "Welcome to Foony!" 대신 ⟦welcome⟧을 보게 되는데, 누락된 번역을 쉽게 찾아낼 수 있어요.
마지막으로, 다른 에이전트가 /{locale}/** 라우팅을 구현해서 /ja/games/chess 같은 경로가 올바른 언어(이 경우 일본어)로 라우팅되도록 했습니다.
블로그 번역하기
UI 문자열 번역은 한 가지 일이지만, 블로그 글들은요? 모든 블로그 글을 번역하기 위해 더 많은 에이전트를 띄우고 관리하고 싶지는 않았어요.
이 문제는 에이전트가 전체 프로세스를 자동화하는 스크립트(scripts/src/generateBlogTranslations.ts)를 만들도록 해서 해결했습니다.
작동 방식은 다음과 같아요:
client/src/posts/en디렉터리에서 영어 MDX 파일을 스캔합니다.- 다른 로케일 폴더(예:
posts/ja,posts/es)에서 누락된 번역을 확인합니다. - 번역이 누락되어 있으면 영어 콘텐츠를 읽어서 Gemini 3 Pro Preview에 특정 프롬프트와 함께 넣어, Markdown 포맷팅을 보존하면서 콘텐츠를 번역합니다.
- 새 파일을 올바른 위치에 저장합니다.
프론트엔드에서는 import.meta.glob을 사용해 이 모든 MDX 파일을 동적으로 임포트해요. 그러면 PostPage 컴포넌트가 그저 사용자의 현재 로케일을 확인해서 올바른 MDX 파일을 지연 로딩합니다. 번역이 누락된 경우(아직 스크립트를 실행하지 않아서) 영어로 우아하게 폴백해요.
4일차: 자동화된 번역 생성
원래 솔루션이 확장성을 가지지 못할 거란 걸 알고 있었어요. 그래서 i18n이 출시된 지금, 데이터베이스 기반 접근 방식으로 조금 더 견고하게 만들 차례였습니다.
요약하자면: 영어 텍스트나 JSDoc 주석이 변경되면 번역도 다시 생성되어야 했어요. 무엇을 업데이트해야 하는지 수동으로 추적하는 건 오류가 나기 쉽고 개발자 시간을 낭비하는 일이 됐을 거예요.
그래서 원래 계획했던 솔루션을 만들었습니다: PostgreSQL 기반의 번역 생성 시스템이요.
데이터베이스 스키마
PostgreSQL 데이터베이스에 다음과 같은 구조의 translations 테이블을 추가했어요:
key: "슬래시-점" 표기법의 번역 키 (예:"games/yacht/nested.name","config.timeLimit.label").en_value: 영어 원본 값target_locale: 대상 로케일 코드 (예:"es","fr","zh")target_value: 번역된 값context: 이 키와 모든 상위 키에 대한 JSDoc을 담는 JSONB 필드created_at과updated_at: 추적용 타임스탬프
고유 인덱스는 (key, target_locale, en_value, context)에 걸려 있어요. 이게 핵심입니다: context를 고유 제약 조건에 포함시킴으로써, JSDoc 주석이 변경되었을 때 자동으로 감지하고 번역을 다시 생성할 수 있어요. 옛날 번역들은 역사적 참조를 위해 보존됩니다.
생성 스크립트
전체 번역 워크플로우를 자동화하는 scripts/src/generateLocalizations.ts를 만들었습니다:
- 영어 키 추출: AST 파싱(ts-morph)을 사용해
shared/src/i18n/locales/en/**파일에서 모든 번역 키를 추출하고, default export만 처리합니다 - JSDoc 컨텍스트 추출: 각 키와 모든 상위 키(부모 객체)의 JSDoc 주석을 파싱해 풍부한 컨텍스트를 제공합니다
- 데이터베이스 쿼리: PostgreSQL의 기존 번역을 확인하고
key,target_locale,en_value,context로 매칭합니다. 이 중 어느 하나라도 변경되면 번역이 다시 생성돼요. - 누락/변경된 키 식별: 번역이 필요하거나 영어 값/주석이 변경된 키를 찾습니다
- 번역 배치 처리: 더 효율적인 LLM 호출(번역 속도도 더 빨라짐)을 위해 로케일과 네임스페이스 접두사로 그룹화합니다. 하지만 배치가 너무 크면 번역 품질이 나빠져요.
- 번역 생성: GPT 5.1을 포괄적인 컨텍스트(JSDoc, 언어+지역, 톤, 용어집, 예시)와 함께 사용합니다. 5.1이 글쓰기에서 5.2보다 낫다고(밋밋하게 들리지 않는다고) 읽었지만, 직접 확인은 안 했어요.
- QA 체크: 플레이스홀더 보존(예:
{{name}}), 키 무결성, JSON 포맷을 검증합니다 - 데이터베이스 저장: 전체 컨텍스트(JSDoc + 상위 JSDoc)와 함께 번역을 저장합니다
- 로케일 파일 생성: 데이터베이스에서 읽어
RecursivePartial타입을 가진 적절히 포맷된 TypeScript 로케일 파일을 작성합니다
핵심 이점
이 접근 방식은 여러 DevEx 개선 사항을 가져다 줍니다:
- 자동 재생성: 영어 텍스트나 JSDoc 주석이 변경되면 번역이 자동으로 다시 생성됩니다. 그래서 누군가 번역이 별로라고 하면, 주석으로 더 많은 컨텍스트를 제공해서 번역을 다시 만드는 게 정말 쉬워요.
- 풍부한 컨텍스트: JSDoc 주석이 번역 컨텍스트를 제공해서 (예: "플레이어에게 보이는 오류 메시지, 최대 15자") LLM이 더 정확한 번역을 만들도록 도와요
- 상위 컨텍스트: 부모 객체의 JSDoc이 네임스페이스 컨텍스트를 제공합니다 (예: "모든 알이 파괴된 게임에 있던 것에 대한 업적"), 좀 더 명확함을 제공해요
- 이력 추적: 옛날 번역들이 데이터베이스에 보존됩니다. 공간을 많이 차지하지 않아서 지금은 굳이 지울 이유를 못 느끼고, 이력을 보는 것도 멋지죠.
기술적 디테일
이 구현은 신뢰성과 효율성을 보장하기 위해 여러 기법을 사용해요:
- 올바른 주석을 가져오기 위한 AST 기반 추출
- 동시 배치 번역을 위한 Semaphore 기반 병렬 처리
- API 실패에 대한 지수 백오프 재시도 로직. LLM 호출은 악명 높을 정도로 불안정하거든요.
스크립트는 scripts 디렉터리에서 npm run generate-localizations로 실행할 수 있어요. 실행하면 PostgreSQL에 연결해서 지원되는 모든 로케일에 대해 누락되거나 변경된 번역을 모두 처리합니다.
결론
여기까지 와서, 20개 로케일로 완전히 번역된 사이트가 완성됐어요!
미친 3일이었지만, 결과는 전 세계 사용자에게 (대부분) 네이티브하게 느껴지는 완전히 현지화된 사이트입니다. 가벼운 커스텀 라이브러리를 만들고 지루한 리팩터링 작업에 AI 에이전트를 활용함으로써, 1년 전이라면 불가능했을 일을 해냈어요: 엔지니어 1명이 복잡한 웹사이트를 3일 만에 완전히 i18n 처리하는 것. 프로그래밍의 미래는 코드를 빠르게 작성하는 것이 아닙니다. AI 에이전트를 오케스트레이션하고, 그 결과물을 검증할 수 있는 깊은 도메인 전문성을 갖추는 것이 핵심이에요.