v0에서 v1으로 넘어가면서 번역 키가 눈에 띄게 불어났습니다.
화면은 바뀌는데 JSON 리소스에 남은 옛 키는 아무도 안 지우니까요.
자동으로 정리하려고 공식 추천 CLI인 i18next-cli를 돌렸는데, 수십 곳에서 쓰이는 네임스페이스를 통째로 삭제 대상으로 잡았습니다. 경고 하나 없이요.
이 글에서는 왜 그런 일이 벌어졌는지, 그리고 공식 도구를 버리고 커스텀 스크립트를 택한 과정을 정리합니다.
미사용 키가 쌓인 배경
저희 프로젝트는 react-i18next와 JSON 리소스 구조로 번역을 관리합니다. 네임스페이스 18개, ko.json 기준 리프 키 1,000개 이상 규모입니다.
v0→v1 전환에서 기능과 라우트가 한꺼번에 빠지면서, 지워야 할 키와 넣어야 할 키가 동시에 불어났습니다. 한두 개씩 손으로 지우는 걸로는 감당이 안 됐습니다.
자동 삭제를 논의하면서 팀에서 가장 먼저 합의한 건, “안전한 삭제”의 기준이었습니다.
| 기준 | 의미 |
|---|---|
| 런타임 안전 | 사용자에게 번역 키 문자열이 그대로 노출되지 않는다 |
| 로케일 쌍 유지 | ko.json과 en.json에서 같은 키 트리를 유지한다 |
| 동적 키 예외 | t(`prefix.${variable}`) 같은 패턴은 일괄 삭제하지 않는다 |
이 세 기준이 이후 도구 선택과 스크립트 설계의 판단 근거가 되었습니다.
I18N_KEYS라는 상수 객체
저희 레포에서는 네임스페이스 문자열을 I18N_KEYS라는 상수 객체로 모아두고, 컴포넌트에서는 useTranslation(I18N_KEYS.FEATURE) 형태로 씁니다.
export const I18N_KEYS = {
COMMON: 'common',
DASHBOARD: 'dashboard',
PROJECT: 'project',
// ... 18개 네임스페이스
} as const;에디터 자동완성, 심볼 리네임, 참조 검색에서 문자열 리터럴보다 확실히 편했습니다.
그런데 이 패턴에는 트레이드오프가 있었습니다. useTranslation(I18N_KEYS.COMMON)은 AST에서 MemberExpression이라는 노드 타입으로 보입니다. “이게 곧 'common'이다”를 알려면 I18N_KEYS가 정의된 다른 파일까지 따라가야 하는데, 대부분의 i18next 플러그인이 이 크로스 파일 추적을 지원하지 않습니다.
extract 결과, 사용처 0건
i18next-parser가 아카이브된 이후 공식 저장소에서 후속 도구로 안내하는 게 i18next-cli였습니다. SWC 기반에 extract, status, sync 명령을 갖추고 있어서, 먼저 이 도구부터 돌려봤습니다.
저희 코드에는 이런 컴포넌트가 있습니다.
const { t } = useTranslation(I18N_KEYS.FEATURE_DETAIL);사람 눈에는 I18N_KEYS.FEATURE_DETAIL이 'feature-detail'이라는 게 바로 보입니다.
그런데 플러그인 입장에서는 이 코드가 useTranslation(someIdentifier.SOMETHING)으로 읽힙니다. AST 노드 타입으로 말하면 MemberExpression이고, 플러그인이 값을 알려면 다른 파일을 따라가야 합니다. i18next-cli는 이 크로스 파일 해석을 지원하지 않았습니다.
extract 결과에서 feature-detail 네임스페이스의 사용처가 0건으로 잡혔습니다. 실제로는 수십 곳에서 쓰이고 있었습니다.
여기에 sync를 걸면 어떻게 될까요. diff를 확인했더니 feature-detail 네임스페이스의 JSON 블록이 통째로 삭제 대상이었습니다.
- "feature-detail": {
- "title": "기능 상세",
- "description": "이 기능은...",
- "field": {
- "name": "필드 이름",
- "type": "필드 타입",
- "required": "필수 여부"
- }
- }단순히 “키를 못 찾았다”가 아니었습니다. 플러그인이 불확실한 결과를 경고 없이 확정 결과처럼 내놓았습니다. “이 네임스페이스는 어디서도 쓰이지 않는다”라고 자신 있게 잘못된 방향으로 동작한 겁니다.
만약 “해석 불가”라는 경고가 나왔다면, 그건 도구의 한계이지 결함은 아닐 수 있습니다. 그런데 경고 없이 전체 삭제를 제안한다면, 자동화 파이프라인에 넣을 수가 없습니다. 한 번이라도 사람이 놓치면 프로덕션에서 번역이 깨지니까요.
관련 이슈: i18next-cli #188 — namespace/keyPrefix 스코프 해석 문제
다른 후보도 같은 벽에 부딪혔다
i18next-scanner도 살펴봤습니다. removeUnusedKeys 옵션이 있어서 카탈로그 정리에는 강점이 있었지만, MemberExpression을 만나면 값을 해석하지 못하는 건 동일했습니다. 플러그인의 입력 가정이 문자열 리터럴이라는 점에서 i18next-cli와 근본적으로 같은 한계를 공유하고 있었습니다.
ESLint 플러그인은 방향이 달랐습니다. “코드에서 쓰이는 키가 JSON에 있는지” 역방향으로 검증하는 데는 유용하지만, “JSON에 있는데 코드에서 안 쓰이는 키”를 찾아서 제거하는 워크플로에는 맞지 않았습니다.
| 도구 | 평가 | 결과 |
|---|---|---|
| i18next-cli | I18N_KEYS 간접 참조에서 네임스페이스 전체를 미사용 판정 | 검토 후 제외 |
| i18next-scanner | 같은 AST 한계 공유 | 제외 |
| i18next-parser | 아카이브 상태 | 제외 |
| ESLint 플러그인 | 역방향 검증에만 유용 | 보조 |
| ts-morph 커스텀 스크립트 | I18N_KEYS를 직접 해석 가능 | 채택 |
플러그인이 우리 패턴을 모른다면
도구들을 돌려보고 나니 문제의 본질이 선명해졌습니다. 플러그인이 저희 패턴을 모른다는 것이었습니다.
그런데 I18N_KEYS는 외부 라이브러리가 아니라 저희 코드베이스 안에 있습니다. useTranslation(I18N_KEYS.X) 호출을 AST로 파싱하면, X가 어떤 값인지 같은 프로세스 안에서 조회할 수 있습니다.
ts-morph를 선택한 건 TypeScript AST를 프로그래밍 방식으로 탐색할 수 있기 때문이었습니다. I18N_KEYS 구조가 바뀌면 스크립트도 고쳐야 한다는 유지 비용은 생기지만, 공식 도구가 자신 있게 잘못된 결과를 내놓는 것보다는 직접 관리하되 오탐이 없는 쪽이 자동화 파이프라인에 넣기에 안전하다고 판단했습니다.
- 공식 추천
i18next-cli는MemberExpression을 해석하지 못해, 네임스페이스 전체를 경고 없이 삭제 대상으로 잡았다 - 다른 후보도 같은 AST 한계를 공유했다
I18N_KEYS는 저희 코드베이스 안에 있으니, 직접 해석하면 오탐 없이 추적할 수 있었다
그래서 ts-morph로 I18N_KEYS를 직접 읽는 스크립트를 만들기 시작했습니다. --apply 모드와 30% 안전 가드까지 붙여서요. 다음 글 ts-morph와 GitHub Actions로 번역 키 자동 정리 파이프라인 만들기에서 그 설계와 자동화 과정을 다룹니다.