2편에서 I18N_KEYS 패턴이 공식 추천 플러그인과 충돌한다는 이야기를 했어요.
그래서 실제로 얼마나 문제가 되는지 확인할 차례였습니다.
기존 도구부터 살펴본 이유
미사용 키를 자동으로 찾으려면 추출 → 비교 → 삭제 파이프라인이 필요합니다.
직접 만들기 전에 기존 도구부터 살펴보는 게 당연했어요.
오래된 선택지인 i18next-parser는 이미 아카이브된 상태였습니다.
공식 저장소에서 후속 도구로 안내하는 게 **i18next-cli**였고, SWC 기반에 extract, status, sync 같은 명령을 갖추고 있었어요.
그래서 먼저 이 플러그인부터 돌려봤습니다.
후보 다섯 개를 나란히 놓고 비교했다
후보를 다섯 가지로 추렸어요. 각각을 저희 레포 기준으로 평가한 결과입니다.
| 방향 | 한 줄 평가 | 채택 여부 |
|---|---|---|
| i18next-cli | 공식 문서 추천, SWC 기반, extract / status / sync 제공. 그러나 I18N_KEYS 간접 참조에서 네임스페이스 전체를 미사용 판정하는 오탐 실측 | 검토 후 제외 |
| i18next-scanner | removeUnusedKeys 등 카탈로그 정리에 강점. AST 해석 한계는 동일 | 제외 |
| i18next-parser | 아카이브 상태. 신규 의존으로 추천되지 않음 | 제외 |
| ts-morph 커스텀 스크립트 | I18N_KEYS를 직접 해석. 진실원을 우리가 관리. 유지 비용은 있지만 오탐 없음 | 채택 |
| ESLint 전용 플러그인 | 누락 키 역방향 검증에 유용 | 보조 |
표만 보면 결론이 뻔해 보이는데요.
실제로 플러그인을 돌려보기 전까지는 저도 “공식 문서가 추천하는 도구니까 되겠지”라고 생각했습니다.
그 기대가 무너진 순간이 있었어요.
결정을 뒤집은 실측
i18next-cli extract를 저희 레포에 돌렸을 때 벌어진 일입니다.
재현 시나리오
저희 코드에는 이런 컴포넌트가 있어요.
const { t } = useTranslation(I18N_KEYS.FEATURE_DETAIL);
사람 눈에는 I18N_KEYS.FEATURE_DETAIL이 'feature-detail'이라는 게 바로 보여요.
그런데 플러그인 입장에서는 이 코드가 useTranslation(someIdentifier.SOMETHING)으로 보입니다.
AST 노드 타입으로 말하면 **MemberExpression**이에요.
플러그인이 “이 식별자가 곧 'feature-detail'이다”를 알려면, I18N_KEYS가 정의된 다른 파일을 따라가서 값을 꺼내야 합니다.
i18next-cli는 이 크로스 파일 해석을 지원하지 않았어요.
그래서 무슨 일이 벌어졌는가
extract 결과에서 feature-detail 네임스페이스의 사용처가 0건으로 잡혔습니다.
실제로는 수십 곳에서 쓰이고 있었어요.
플러그인이 MemberExpression을 해석하지 못하니, 해당 네임스페이스를 아예 발견하지 못한 거예요.
여기에 removeUnusedKeys에 준하는 sync를 걸면 어떻게 될까요.
diff를 확인했더니 feature-detail 네임스페이스의 JSON 블록이 통째로 삭제 대상이었습니다.
리프 키 하나하나가 아니라, 네임스페이스 전체가요.
- "feature-detail": {
- "title": "기능 상세",
- "description": "이 기능은...",
- "field": {
- "name": "필드 이름",
- "type": "필드 타입",
- "required": "필수 여부"
- }
- }
중요한 건, 단순히 “키를 못 찾았다”가 아니라는 점이었습니다.
플러그인이 불확실한 결과를 경고 없이 확정 결과처럼 내놓았거든요.
“이 네임스페이스는 어디서도 쓰이지 않는다”라고 자신 있게 잘못된 방향으로 동작한 거예요.
만약 “해석 불가”라는 경고가 나왔다면, 그건 도구의 한계이지 결함은 아닐 수 있습니다.
그런데 경고 없이 전체 삭제를 제안한다면, 자동화 파이프라인에 넣을 수가 없어요.
한 번이라도 사람이 놓치면 프로덕션에서 번역이 깨지니까요.
관련 이슈: i18next-cli #188 - namespace/keyPrefix 스코프 해석 문제
다른 후보도 같은 벽에 부딪혔다
i18next-cli가 안 된다면 다른 도구는 어떨까요.
i18next-scanner도 살펴봤습니다.
removeUnusedKeys 옵션이 있어서 카탈로그 정리에는 강점이 있었어요.
그런데 핵심 문제가 같았어요.
AST에서 MemberExpression을 만나면 값을 해석하지 못하는 건 동일했습니다.
플러그인의 입력 가정이 문자열 리터럴이라는 점에서 i18next-cli와 근본적으로 같은 한계를 공유하고 있었어요.
ESLint 플러그인은 방향이 달랐습니다.
“코드에서 쓰이는 키가 JSON에 있는지” 역방향으로 검증하는 데는 유용하지만, “JSON에 있는데 코드에서 안 쓰이는 키”를 찾아서 제거하는 워크플로에는 맞지 않았어요.
자동화가 안정된 뒤 보조 검증으로 붙이면 좋겠다는 판단이었습니다.
결국 검토한 도구 중 저희 패턴에서 안전하게 동작하는 건 없었어요.
커스텀 스크립트로 간 이유
도구들을 돌려보고 나니 문제의 본질이 선명해졌습니다.
플러그인이 우리 패턴을 모른다는 거였어요.
그런데 I18N_KEYS는 외부 라이브러리가 아니라 저희 코드베이스 안에 있습니다.
useTranslation(I18N_KEYS.X) 호출을 AST로 파싱하면, X가 어떤 값인지 같은 프로세스 안에서 조회할 수 있어요.
그래서 직접 만들기로 했습니다.
I18N_KEYS 정의 파일을 읽어서 값을 해석하고, useTranslation 호출에서 실제 네임스페이스를 추출해야 했어요.
ts-morph를 선택한 건 TypeScript AST를 프로그래밍 방식으로 탐색할 수 있기 때문이었습니다.
I18N_KEYS 구조가 바뀌면 스크립트도 고쳐야 한다는 유지 비용이 생긴다는 건 인지하고 있었어요.
그런데 공식 도구가 자신 있게 잘못된 결과를 내놓는 것보다는, 우리가 직접 관리하되 오탐이 없는 쪽이 자동화 파이프라인에 넣기에 훨씬 안전했습니다.
공식 도구를 버린 판단 근거
- 공식 추천 플러그인
i18next-cli는MemberExpression을 해석하지 못해, 네임스페이스 전체를 경고 없이 삭제 대상으로 잡았어요 i18next-scanner도 같은 AST 한계를 공유했어요- “플러그인이 우리 패턴을 모른다면, 우리가 직접 해석하면 된다”가 ts-morph를 택한 이유였어요
그래서 ts-morph로 I18N_KEYS를 직접 읽는 스크립트를 만들기 시작했습니다.
--apply 모드와 30% 안전 가드까지 붙여서요.