프로젝트 i18n 리팩토링 기록
프로젝트에 합류했을 때, 팀은 글로벌 런칭을 앞두고 있었다. 코드베이스를 열어본 순간, 기능 구현 이면에 숨겨진 기술 부채를 마주했다.
- “파편화된 다국어 처리 방식”과 “비효율적인 수동 워크플로우”였다.
이 글은 500여 개의 파일, 수천 줄의 텍스트를 가진 프로젝트를 **Babel AST(추상 구문 트리)**와 **자체 개발 도구(i18nexus-tools)**를 통해 리팩토링하고 자동화한 과정에 대한 기록이다.
1. 문제 상황 (As-Is)
코드를 분석하며 발견한 가장 큰 문제는 “예측 불가능성”이었다.
1.1. Key 네이밍의 혼란
i18n의 핵심은 Key와 Value의 매핑이다. 하지만 기존 코드에는 규칙이 없었다.
// 개발자 A: 영어 단어를 키로 사용
<button>{t("submit")}</button>
// 개발자 B: 임의의 약어를 키로 사용 (개인 규칙으로 보이지만 문서화되지 않음)
<h1>{t("intro-msg-v2")}</h1>
// 개발자 C: 한국어를 키로 사용
<p>{t("로그인 실패")}</p>
기획자가 “인트로 메시지 수정해주세요”라고 요청했을 때, 개발자는 intro-msg-v2라는 키를 모르면 코드를 찾을 수 없다. 결국 ko.json 파일을 열어 역추적해야 했고, 이는 유지보수 비용을 증가시켰다.
1.2. Props Drilling 문제
Next.js의 next-i18next를 사용하면서, 언어 상태(lng)를 유지하기 위해 모든 컴포넌트가 희생되고 있었다.
// i18n 하나 때문에 불필요한 props가 전달됨
const UserProfile = ({ user, lng }) => {
return (
<div>
<Avatar src={user.img} />
<UserInfo user={user} lng={lng} /> {/* 하위로 전달 */}
</div>
);
};
UserProfile 컴포넌트는 본질적으로 lng를 필요로 하지 않는다. 오직 하위 컴포넌트에 전달하기 위해 props를 받아야 했고, 이는 컴포넌트 간의 강한 결합(Tight Coupling)을 초래했다.
1.3. 수동 래핑 작업의 물리적 한계
Key 네이밍이나 구조적 문제 이전에, 가장 먼저 압도한 것은 절대적인 작업량이었다.
프로젝트에는 약 500여 개의 컴포넌트 파일이 존재했고, 그 안에는 수천 줄의 하드코딩된 텍스트가 있었다. 이를 수동으로 변환하는 과정:
- 파일을 연다.
"환영합니다"텍스트를 육안으로 식별한다.- 상단에
import { useTranslation } from '...';을 추가한다. - 컴포넌트 내부에
const { t } = useTranslation();훅을 선언한다. - 텍스트를
{t('환영합니다')}로 치환한다.
이 단순 반복 작업을 파일당 수십 번 수행해야 했다. 복잡한 페이지의 경우 하나를 변환하는 데 7시간 이상이 소요되기도 했다.
단순히 시간이 오래 걸리는 것이 문제가 아니었다. 수백 번의 Copy & Paste 과정에서 괄호가 하나 빠지거나, import 경로가 꼬이는 등 Human Error가 필연적으로 발생했고, 이는 빌드 에러를 잡는 또 다른 시간 낭비로 이어졌다.
1.4. 비즈니스 로직 속 숨겨진 텍스트
UI 컴포넌트(JSX) 내부에 있는 텍스트는 그나마 눈에 잘 띈다. 진짜 문제는 비즈니스 로직 깊숙이 숨어있는 텍스트들이었다.
try {
await api.updateUser();
toast.success("업데이트 완료!");
} catch (error) {
alert("에러가 발생했습니다. 관리자에게 문의하세요.");
}
이런 메시지들은 Custom Hooks, Service Layer, API Wrapper, Error Handling Utils 등 프로젝트 곳곳에 산개해 있었다.
코드를 배포하고 나서야 사용자가 “여기 에러 메시지는 왜 한글로 나와요?”라고 제보하는 상황이 반복되었다. 전체 검색(Global Search)을 돌리고 파일을 뒤지는 경험은 개발자 경험(DX)을 악화시켰다.
1.5. 래핑은 끝이 아니라 시작
많은 개발자가 착각하는 지점이 있다. “모든 텍스트를 t()로 감쌌으니 이제 i18n 끝난 거 아닌가?”
팀 역시 그랬다. 하지만 막상 래핑을 끝내고 보니, 그것은 전체 공수의 **30%**에 불과했다. 진짜 작업(나머지 70%)은 코드를 닫은 뒤 찾아왔다.
1.5.1. JSON 리소스 관리 문제
t("환영합니다")라고 코드를 짰다면, 이제 그에 상응하는 json 파일을 만들어야 한다.
// ko.json
{
"환영합니다": "환영합니다"
}
이 과정에서 발생하는 문제는 명확했다.
- Context Switching: 코드 편집기와 JSON 파일을 수백 번 오가며 작업해야 한다.
- Key Collision: “저장”이라는 키를 이미 썼는지, 안 썼는지 기억할 수 없어 매번 검색해야 한다.
- Missing Keys: 실수로 JSON에 키를 넣지 않아, 화면에
key_missing에러가 뜨는 경우가 빈번했다.
1.5.2. 개발자가 번역까지
ko.json을 다 채웠다고 끝이 아니다. en.json, ja.json 등 지원할 언어만큼 파일을 복사하고 번역을 채워 넣어야 한다.
문제는 전문 번역가가 없는 팀의 현실이었다. 결국 프론트엔드 엔지니어가 파파고나 DeepL을 켜두고 번역을 채워 넣어야 했다.
“여기서 ‘배송’이 Delivery일까 Shipping일까?”
단순 번역이 아니라 문맥(Context)을 고려한 의역이 필요했기에, 개발자가 코드 로직보다 영어 단어 선택에 더 많은 시간을 쏟는 상황이 벌어졌다.
1.5.3. 끝나지 않는 QA
번역을 다 넣었다고 해도 다시 검증(QA) 과정이 남는다.
- 독일어로 바꿨을 때 텍스트가 너무 길어 레이아웃이 깨지지는 않는가?
- 줄바꿈 처리가 어색하지 않은가?
{{name}}같은 변수가 번역문에도 올바르게 포함되었는가?
이 모든 것을 눈으로 하나하나 확인해야 했다. 즉, 단순한 텍스트 치환이 아니라 “번역 워크플로우 전체(추출-번역-검증)“를 자동화하지 않으면, i18n은 영원히 끝나지 않는 기술 부채로 남을 것임이 자명해졌다.
1.6. 기존 라이브러리의 한계
레거시 키를 해결해줄 솔루션을 찾던 중, i18n-alloy라는 라이브러리를 발견했다. 겉보기에는 완벽해 보였다.
하지만 실제로 프로젝트에 적용하려는 순간, 현실의 벽에 부딪혔다.
문제 1: 오래된 버전과 방치된 문서
- 마지막 업데이트가 2년 전이었고 제대로 동작하지 않았다.
- 공식 문서는 있었지만, 대부분 “개념 설명”에 그쳤고, 실제 프로젝트 적용을 위한 구체적인 설정 가이드는 부족했다.
- GitHub Issues는 답변 없이 쌓여있었고, 커뮤니티는 사실상 죽어있는 상태였다.
문제 2: 복잡한 설정 파일
// i18n-alloy.config.js (예시)
module.exports = {
functionName: "t",
importDeclaration: 'import { useTranslation } from "react-i18next"',
output: "./locales",
translator: {
provider: "google",
apiKey: process.env.GOOGLE_API_KEY,
from: "ko",
to: ["en", "ja", "zh"],
},
extractPattern: ["src/**/*.{js,jsx,ts,tsx}"],
// ... 수십 줄의 설정이 더 이어짐
};
결론: 직접 만들기로 결정
결국 3일간의 삽질 끝에 i18n-alloy를 포기했다. 누군가의 블랙박스에 의존하는 것보다, 프로젝트에 맞는 최소한의 도구를 직접 만드는 것이 더 안전하고 효율적이라는 결론에 도달했다.
1.7. 단일 JSON 파일의 문제
초기 자동화 시스템을 구축하고 나서, 모든 번역 키를 하나의 JSON 파일에 모았다.
/locales
├── ko.json (2000+ lines)
├── en.json (2000+ lines)
└── ja.json (2000+ lines)
처음에는 “한 곳에 다 있으니 관리가 쉽겠지?”라고 생각했다. 하지만 프로젝트가 커지면서, 이 방식은 문제가 되었다.
문제 1: 중복 Key
프로젝트의 여러 도메인에서 동일한 단어가 사용되는 경우가 많았다.
// ko.json (2000줄 중 일부)
{
"저장": "저장",
"저장하기": "저장하기",
"취소": "취소",
"취소하기": "취소하기",
"확인": "확인"
}
- 누가, 언제, 어디서 사용한 Key인지 알 수 없었다.
- 새로운 “저장” 버튼을 만들 때마다 “저장_v2”, “저장_final” 같은 이상한 Key가 생겨났다.
- 한 팀원이 “확인”이라는 Key를 수정하면, 전혀 다른 도메인에서 버그가 발생했다.
문제 2: Git Conflict
2000줄이 넘는 JSON 파일은 여러 명이 동시에 수정할 수 없었다.
# 동시에 3명이 ko.json을 수정
[Team Member A] 500번째 줄에 Key 추가
[Team Member B] 1200번째 줄에 Key 추가
[Team Member C] 800번째 줄 번역 수정
# Git Merge 시 충돌
<<<<<<< HEAD
"저장": "저장",
=======
"저장": "Save",
>>>>>>> feature/update-translations
JSON은 diff를 보기 어렵고, 충돌 해결이 까다로웠다. 결국 누군가의 수정 사항이 날아가거나, 잘못된 병합으로 Key가 사라지는 사고가 반복되었다.
문제 3: 성능 저하
앱 초기화 시 2000줄짜리 JSON 파일 전체를 로드해야 했다. 사용자가 실제로 필요한 것은 “도큐먼트 페이지”의 번역 20개뿐인데, 전체 2000개를 메모리에 올리는 것은 낭비였다.
깨달음: “거대한 하나”보다 “작고 명확한 여럿”
문제의 본질은 **“도메인 분리 없이 모든 것을 한 곳에 몰아넣은 것”**이었다. 코드는 /pages/document, /components/settings처럼 도메인별로 나뉘어 있는데, 번역 리소스만 하나로 합쳐놓은 것은 모순이었다.
이 경험이 바로 다음 해결책인 Namespace 기반 아키텍처의 출발점이 되었다.
2. 해결 전략 및 구현 (Solution & Implementation)
이 문제를 해결하기 위해 네 가지 기술적 목표를 세웠다.
- 가독성(Readability): 소스 코드 자체가 문서가 되도록 한국어 Key를 사용한다.
- 자동화(Automation): Babel AST를 사용하여 하드코딩된 텍스트를 안전하게 변환한다.
- 분리(Separation): Context API를 사용하여 Props Drilling을 제거한다.
- 모듈화(Modularity): Namespace 기반 구조로 번역 리소스를 도메인별로 분리한다.
- 타입 안전성(Type Safety): TypeScript declare module을 사용하여 번역 키에 대한 컴파일 타임 검증을 제공한다.
2.1. 해결책 1: AST 기반의 코드 변환 (Intelligent Wrapper)
처음엔 정규표현식(Regex) 치환을 고려했으나, JSX의 복잡한 문맥(Context)을 처리하기엔 역부족이었다. 예를 들어, className="text-red-500" 같은 속성값까지 번역해버릴 위험이 있었다.
그래서 코드를 구조적으로 이해하는 **Babel AST(@babel/parser, @babel/traverse)**를 도입했다.
[Implementation Logic]
- 코드를 파싱하여 AST(트리 구조)로 만든다.
JSXText(태그 사이의 텍스트)와StringLiteral(문자열) 노드만 탐색한다.- 한글이 포함된 경우에만
CallExpression(t()) 노드로 교체한다.
[Example Code: AST 변환 로직 (간소화)]
// 변환 스크립트 예시 (Conceptual)
traverse(ast, {
// 1. JSX 텍스트 노드 탐색 (예: <div>안녕</div>)
JSXText(path) {
const text = path.node.value.trim();
if (hasKorean(text)) {
// 노드를 {t('안녕')} 형태로 교체
path.replaceWith(
tExpression(text) // 자체 제작한 AST 노드 생성 함수
);
}
},
// 2. 템플릿 리터럴 탐색 (예: `안녕 ${name}`)
TemplateLiteral(path) {
// 변수(${name})를 추출하여 t('안녕 {{name}}', { name }) 형태로 변환
// ...복잡한 로직 생략...
},
});
[Result]
// Before
<div>
<h1>환영합니다</h1>
<p>{`안녕하세요 ${name}님`}</p>
</div>
// After (CLI 실행 후 자동 변환)
<div>
<h1>{t("환영합니다")}</h1>
<p>{t("안녕하세요 {{name}}님", { name })}</p>
</div>
이 방식은 문법적 오류 없이 안전하게 코드를 변환했다.
2.2. 해결책 2: “한국어 = Key” 전략과 자동 추출 (Extractor)
- *“코드가 곧 명세서(Spec)“**가 되길 원했다. 그래서 알 수 없는 영어/약어 Key를 버리고, 한국어 텍스트 자체를 Key로 사용하기로 결정했다.
그리고 코드 내의 t() 함수를 스캔하여 JSON을 자동으로 만들어주는 Extractor를 구현했다.
[Example Code: Extractor의 역할]
// 소스 코드 스캔 중...
const key = t(`안녕하세요 ${name}님`; // t() 함수 안의 텍스트 추출
// ko.json 자동 생성/업데이트
{
"환영합니다": "환영합니다",
"안녕하세요 {{name}}님": "안녕하세요 {{name}}님"
// Key와 Value를 동일하게 설정하여 가독성 확보
}
// en.json 자동 생성 (빈 값 또는 자동 번역)
{
"환영합니다": "Welcome",
"안녕하세요 {{name}}님": "Hello {{name}}"
}
이제 기획자가 “환영합니다”를 수정해달라고 하면, 개발자는 코드에서 “환영합니다”를 검색하면 바로 해당 위치로 이동할 수 있게 되었다. 검색 가능성(Searchability)이 완벽하게 해결되었다.
2.3. 해결책 3: Context API로 Props Drilling 제거
lng props를 제거하기 위해, React의 Context API를 활용한 Provider 패턴을 적용했다.
[Implementation: Provider Pattern]
// 1. 최상위 (_app.tsx)에서 Provider 주입
export default function App({ Component, pageProps }) {
// 서버에서 가져온 번역 데이터를 Context에 주입
return (
<I18nProvider locale={pageProps.locale} resources={pageProps.translations}>
<Component {...pageProps} />
</I18nProvider>
);
}
// 2. 하위 컴포넌트 (사용처)
export default function Navigation() {
// props 없이 Hook으로 바로 접근
const { t } = useTranslation();
return <nav>{t("메뉴")}</nav>;
}
이 리팩토링을 통해 컴포넌트 간의 의존성을 끊어내고, 코드를 훨씬 더 **선언적(Declarative)**이고 깔끔하게 만들 수 있었다.
추후 의견이지만 주소에 lng를 넣고 있는데 어째서 lng를 context 보다는 주소를 받아서 드릴링을 해주는게 좀더 순수성이 높지 않을까 고민을 했었다.
간단한 컴포넌트들 부터 모든 코드가 useTranslation을 쓰기에 순수성이 낮아질 수 있다는 생각이 들었지만 통일화를 위해서 context를 사용했던것 같다
2.4. 해결책 4: Namespace 기반 도메인 분리 (Domain-Driven Localization)
단일 JSON의 문제를 해결하기 위해, i18next의 Namespace 기능과 파일 경로 기반 자동 매핑을 결합했다.
[핵심 아이디어: 파일 경로 = Namespace]
/pages
/document
index.tsx → namespace: "document"
/settings
profile.tsx → namespace: "settings"
/dashboard
index.tsx → namespace: "dashboard"
/locales
/document
ko.json
en.json
/settings
ko.json
en.json
/dashboard
ko.json
en.json
이제 각 파일은 자신이 속한 폴더 구조를 기반으로 자동으로 Namespace를 할당받는다.
[Implementation: AST 기반 자동 Namespace 주입]
AST 변환 도구는 단순히 t()를 추가하는 것에서 더 나아가, 파일 경로를 분석하여 적절한 Namespace를 자동으로 주입한다.
// i18nexus CLI의 내부 로직 (간소화)
function transformFile(filePath) {
// 1. 파일 경로에서 최상단 폴더 추출
// 예: /pages/document/detail.tsx → "document"
const namespace = extractNamespace(filePath);
// 2. AST 변환 시 useTranslation에 namespace 주입
traverse(ast, {
Program(path) {
// import 추가
addImport(path, "useTranslation", "react-i18next");
// Hook에 namespace 자동 주입
addHookDeclaration(path, `const { t } = useTranslation("${namespace}");`);
},
JSXText(path) {
const text = path.node.value.trim();
if (hasKorean(text)) {
// t() 호출로 변환 (namespace는 이미 Hook에 선언되어 있음)
path.replaceWith(tExpression(text));
}
},
});
}
[Result: 자동 변환 결과]
// pages/document/index.tsx
// Before
export default function DocumentPage() {
return <h1>문서 관리</h1>;
}
// After (npx i18nexus wrap 실행)
import { useTranslation } from 'react-i18next';
export default function DocumentPage() {
const { t } = useTranslation("document"); // 자동으로 "document" namespace 주입
return <h1>{t("문서 관리")}</h1>;
}
[Key Extraction with Namespace]
추출 단계에서도 Namespace를 반영하여 파일을 생성한다.
# npx i18nexus extract 실행
- Extracting keys from pages/document/index.tsx
→ Writing to locales/document/ko.json
- Extracting keys from pages/settings/profile.tsx
→ Writing to locales/settings/ko.json
// locales/document/ko.json
{
"문서 관리": "문서 관리",
"문서 업로드": "문서 업로드",
"저장": "저장" // document 도메인의 "저장"
}
// locales/settings/ko.json
{
"프로필 설정": "프로필 설정",
"저장": "저장" // settings 도메인의 "저장" (완전히 독립)
}
[i18next FallbackNS 활용]
만약 특정 Namespace에 Key가 없을 경우를 대비해, i18next의 fallbackNS 기능을 활용했다.
// i18n.config.js
i18next.init({
fallbackNS: ["constant", "common"], // 모든 도메인에서 공통으로 사용하는 Key
ns: ["common", "document", "settings", "dashboard"],
defaultNS: "common",
});
// locales/common/ko.json (공통 UI 요소)
{
"확인": "확인",
"취소": "취소",
"닫기": "닫기"
}
이제 pages/document/index.tsx에서 t("확인")을 호출하면:
- 먼저
locales/document/ko.json에서 찾는다. - 없으면
locales/common/ko.json에서 fallback한다.
[장점: 완벽한 격리와 확장성]
- 중복 Key 해결:
document/ko.json의 “저장”과settings/ko.json의 “저장”이 완전히 독립적이다. - Git Conflict 최소화: 각 팀원이 다른 도메인을 담당하면, 서로 다른 파일을 수정하므로 충돌이 없다.
- 성능 최적화: 필요한 Namespace만 Lazy Load할 수 있다.
// Next.js의 serverSideProps에서
export async function getServerSideProps({ locale }) {
return {
props: {
...(await serverSideTranslations(locale, ["document", "common"])),
// 'settings' namespace는 로드하지 않음 → Bundle 크기 감소
},
};
}
- 자동화된 워크플로우: 개발자는 파일 경로만 신경 쓰면, 나머지는 도구가 알아서 처리한다.
2.5. 해결책 5: TypeScript 타입 안전성 (Type-Safe Translations)
번역 키에 오타가 있어도 런타임에서만 발견할 수 있다는 것은 큰 문제였다. 이를 해결하기 위해 TypeScript Module Augmentation을 활용하여 컴파일 타임에 번역 키를 검증하도록 했다.
[핵심 아이디어: declare module로 타입 확장]
// locales/types/i18nexus.d.ts (자동 생성)
// 모든 네임스페이스 타입 선언
declare type TranslationNamespace =
| "Dashboard"
| "DocuMng"
| "Gallery"
| "common"
| "constant";
// 각 네임스페이스의 키 타입 선언
declare type DashboardKeys = "날씨" | "오늘" | "내일" | "모레" | "주간";
declare type DocuMngKeys = "문서 관리" | "저장" | "삭제" | "취소";
declare type CommonKeys = "확인" | "취소" | "닫기";
declare type ConstantKeys = "전체" | "기타" | "북마크";
// 네임스페이스별 키 매핑
declare type TranslationKeys = {
Dashboard: DashboardKeys;
DocuMng: DocuMngKeys;
common: CommonKeys;
constant: ConstantKeys;
};
// useTranslation 훅 타입 확장
declare module "@/app/i18n/client" {
export function useTranslation<NS extends TranslationNamespace>(
namespace: NS
): {
t: <K extends TranslationKeys[NS] | CommonKeys | ConstantKeys>(
key: K,
variables?: Record<string, string | number>
) => string;
lng: string;
};
}
[사용 예시]
// Dashboard/content.tsx
const { t } = useTranslation("Dashboard");
t("날씨"); // OK
t("오늘"); // OK
t("확인"); // OK (fallbackNS인 common에서 가져옴)
t("북마크"); // OK (fallbackNS인 constant에서 가져옴)
t("오타키"); // Compile Error! - Type '"오타키"' is not assignable to type 'DashboardKeys | CommonKeys | ConstantKeys'
[자동 타입 생성]
타입 정의 파일은 npx i18n-type 명령어를 실행하면 자동으로 생성된다. JSON 파일을 파싱하여 각 네임스페이스의 모든 키를 Union Type으로 변환한다.
$ npm run i18n:type
- Generated type definitions at: locales/types/i18nexus.d.ts
- 15 namespaces
- 487 total keys
- 12 keys with interpolation variables
[Interpolation 변수 타입 지원]
번역 키에 변수가 있는 경우도 타입으로 추적할 수 있다.
// 자동 생성된 타입
declare type DashboardKeyVariables = {
"{{totalDays}}일": "totalDays";
"{{hour}}시": "hour";
};
// 사용 시
t("{{totalDays}}일", { totalDays: 30 }); // OK
t("{{totalDays}}일", { wrongKey: 30 }); // 런타임 경고 (변수 타입 검증은 선택적)
이 방식으로 오타로 인한 번역 누락 버그를 컴파일 단계에서 100% 방지할 수 있게 되었다.
2.6. 로컬 라이브러리 구축 및 링킹
도구를 만들면서 고민한 것은 “이 도구를 어떻게 프로젝트에 통합할 것인가?”였다. npm에 퍼블리시하는 방법도 있었지만, 프로젝트 특성에 맞게 빠르게 수정하고 테스트하기 위해 로컬 패키지 방식을 선택했다.
[프로젝트 구조]
/project-root
/scripts
/tools # i18nexus-tools 로컬 패키지
/bin # CLI 실행 파일
i18n-process.ts
i18n-wrapper.ts
i18n-extractor.ts
i18n-type.ts
i18n-upload.ts
i18n-download.ts
i18n-clean-legacy.ts
/scripts # 핵심 로직
/t-wrapper # AST 기반 래핑 로직
/extractor # 키 추출 로직
google-sheets.ts # Google Sheets API 연동
type-generator.ts # TypeScript 타입 생성
package.json # 로컬 패키지 정의
tsconfig.json
/locales # 번역 리소스
/types
i18nexus.d.ts # 자동 생성된 타입 정의
/Dashboard
ko.json
en.json
/DocuMng
ko.json
en.json
package.json # 메인 프로젝트
[package.json 링킹]
메인 프로젝트의 package.json에서 로컬 패키지를 참조한다:
{
"dependencies": {
"i18nexus-tools": "file:scripts/tools"
},
"scripts": {
"i18n:build": "cd scripts/tools && npm run build",
"i18n:process": "npm run i18n:build && npx i18n-process",
"i18n:extract": "npm run i18n:build && npx i18n-process --skip-wrapper",
"i18n:type": "npm run i18n:build && npx i18n-type",
"i18n:clean": "npm run i18n:build && npx i18n-clean-legacy",
"upload:i18n": "npx i18n-upload",
"download:i18n": "npx i18n-download"
}
}
[장점]
- 빠른 iteration: npm publish 없이 바로 수정하고 테스트할 수 있다.
- 프로젝트 특화: 회사/프로젝트 특성에 맞게 자유롭게 커스터마이징할 수 있다.
- 버전 관리: 메인 프로젝트와 함께 Git으로 관리되어 변경 이력이 남는다.
- 의존성 격리: 메인 프로젝트의 의존성과 별도로 관리할 수 있다.
[사용법]
# 설치 (최초 1회)
npm install
# 전체 프로세스 실행 (wrapper → extractor → type)
npm run i18n:process
# 키 추출 + 타입 생성만 (wrapper 건너뛰기)
npm run i18n:extract
# 타입만 재생성
npm run i18n:type
# 미사용 키 정리
npm run i18n:clean
# Google Sheets 업로드/다운로드
npm run upload:i18n
npm run download:i18n
3. 최종 워크플로우 (To-Be)
기술적 해결을 넘어, 이를 하나의 자동화된 워크플로우로 정립했다. 이제 개발자는 번역 파일(json)을 직접 건드리지 않는다.
3.1. 개발 프로세스
-
Coding: 개발자는 UI를 개발하며 한국어 텍스트를 작성한다. (하드코딩 OK)
// pages/document/upload.tsx export default function UploadPage() { return <h1>파일 업로드</h1>; } -
Auto-Wrap:
npx i18nexus wrap을 실행하여 하드코딩을t()로 자동 변환한다.$ npx i18nexus wrap pages/document/upload.tsx - Transformed: pages/document/upload.tsx - Added import: useTranslation - Added hook: useTranslation("document") - Wrapped 1 Korean text// 자동 변환 결과 import { useTranslation } from "react-i18next"; export default function UploadPage() { const { t } = useTranslation("document"); return <h1>{t("파일 업로드")}</h1>; } -
Extraction & Upload:
npx i18nexus extract --auto-translate를 실행한다.$ npx i18nexus extract --auto-translate - Scanning pages/document/*.tsx - Found 15 keys in namespace: document - Writing locales/document/ko.json - Translating to English... (Google Translate API) - Writing locales/document/en.json - Uploading to Google Sheets for review -
QA & Sync: 기획자/번역가가 시트에서 내용을 검수하고, 개발자는
npm run download:i18n로 최종 반영한다.$ npm run download:i18n - Fetching from Google Sheets... - Updated locales/document/ko.json (2 changes) - Updated locales/document/en.json (2 changes)
3.2. 디렉토리 구조 (Final)
/project
/pages
/document
index.tsx
upload.tsx
/settings
profile.tsx
/locales
/types
i18nexus.d.ts # 자동 생성된 타입 정의
/common
ko.json
en.json
/document
ko.json
en.json
/settings
ko.json
en.json
/scripts
/tools # i18nexus-tools 로컬 패키지
...
4. 결과 및 임팩트 (Impact)
4.1. 정량적 성과
| 지표 | Before | After | 개선율 |
|---|---|---|---|
| 파일당 래핑 시간 | 7시간 | 5분 | 98.8% 감소 |
| Key 중복 에러 | 주 5회 | 0회 | 100% 제거 |
| Git Conflict (번역 파일) | 월 12건 | 월 1건 이하 | 91.7% 감소 |
| 번역 리소스 번들 크기 | 245KB | 68KB | 72.2% 감소 |
| 누락된 번역 Key 에러 | 월 8건 | 0건 | 100% 제거 |
| 타입 에러로 잡힌 오타 | - | 23건 | 컴파일 단계에서 방지 |
4.2. 정성적 성과
개발자 경험 개선
- “어디 있는 Key인지 찾을 수가 없어” → “코드에서 한글 검색하면 바로 나와”
- “JSON 파일 수정하다가 다른 페이지 깨뜨렸어” → “내 도메인 파일만 수정하면 돼”
- “번역 Key 네이밍 어떻게 해야 하지?” → “걱정할 필요 없음, 한국어가 Key니까”
- “t() 키에 오타 났는데 배포 후에 알았어” → “컴파일 단계에서 바로 잡힘”
기획자/번역가와의 협업 개선
- “이 ‘submit-btn-v3’ 키가 어디 버튼인지 모르겠어요” → “시트에 한글로 다 나와있어요”
- “번역 수정했는데 반영이 안 돼요” → “자동 동기화로 즉시 반영됩니다”
코드 품질 향상
- Props Drilling 제거로 컴포넌트 의존성 감소
- 불필요한 props 전달이 없어져 코드 가독성 향상
- Namespace 기반 구조로 대규모 프로젝트 확장성 확보
- TypeScript 타입 지원으로 런타임 버그 사전 방지
5. 교훈 및 회고 (Lessons Learned)
5.1. “레거시 라이브러리의 함정”
i18n-alloy 같은 기존 솔루션을 맹신하지 말고, 프로젝트에 맞는지 검증해야 한다.
- 커뮤니티 활성도
- 최신 버전 지원
- 문서화 수준
- 내부 구조 투명성
교훈: “복잡한 도구보다 단순하고 투명한 자체 도구가 더 나을 수 있다.”
5.2. “아키텍처는 미래의 나를 위한 투자”
Monolithic JSON을 고집했다면, 프로젝트가 커질수록 더 큰 기술 부채를 떠안았을 것이다. Namespace 기반 분리는 초기 설계 비용은 있었지만, 장기적으로 팀의 생산성을 높였다.
교훈: “당장 불편하더라도, 확장 가능한 구조를 먼저 만들어라.”
5.3. “자동화는 단순 반복 작업의 적”
인간이 코드를 한 줄 한 줄 변환하는 건 시간 낭비일 뿐 아니라 에러의 원인이다. AST 기반 자동화는 한 번 구축하면 Human Error를 제거한다.
교훈: “개발자의 시간은 창의적인 문제 해결에 써야지, 기계가 할 수 있는 일에 쓰면 안 된다.”
5.4. “도구는 팀을 위해 존재한다”
i18nexus-tools는 단순한 기술 구현이 아니라, 팀 전체의 워크플로우를 개선하는 시스템이었다. 개발자뿐 아니라 기획자, 번역가 모두가 혜택을 받을 수 있도록 설계했기에 성공할 수 있었다.
교훈: “도구를 만들 때는 사용자(팀원)의 Pain Point를 정확히 파악하라.”
5.5. “문서화와 인수인계의 중요성”
로컬 라이브러리를 만들 때 가장 중요한 것은 다른 사람도 유지보수할 수 있도록 문서화하는 것이다. README, DETAILS.md, 그리고 코드 내 주석을 충실히 작성해야 한다.
특히 퇴사나 팀 변경 시 인수인계가 원활하려면:
- CLI 명령어와 사용법 문서화
- 설정 파일(i18nexus.config.json)의 옵션 설명
- 디버깅 방법과 자주 발생하는 에러 대응법
- Google Sheets 연동 방법과 권한 설정
이런 정보들이 없으면 도구는 금방 “아무도 건드리지 않는 블랙박스”가 되어버린다.
6. 결론
i18n은 단순한 ‘기능 추가’가 아니라 프로젝트 전체의 구조와 개발 문화를 바꾸는 작업이었다.
단순히 “다국어 지원”을 넘어, 개발자가 코드를 효율적으로 작성할 수 있는 환경을 만들었다. 더 이상 번역 파일과 씨름하지 않고, 비즈니스 로직에 집중할 수 있게 되었다.
이 여정을 통해 배운 가장 큰 교훈은:
“기술 부채는 방치하면 커지지만, 올바른 자동화와 아키텍처로 해결할 수 있다.”
i18nexus-tools는 단순한 CLI 도구가 아니라, 문제를 분석하고 체계적으로 해결한 결과물이다.