I18N_KEYS를 직접 읽는 스크립트를 만들었다

April 13, 2026

3편에서 공식 CLI가 저희 패턴을 해석하지 못하는 걸 확인했고, 직접 만들기로 결정했습니다.

이번 편에서는 ts-morph로 만든 미사용 키 탐지 스크립트의 설계를 다뤄요.

namespace 해석부터 키 수집, 화이트리스트, --apply 로직과 30% 안전 가드까지의 과정을 소개합니다.

설계 목표, I18N_KEYS를 진실원으로 삼는다

3편에서 공식 추출기가 useTranslation(I18N_KEYS.X) 패턴을 해석하지 못하는 걸 실측으로 확인했습니다.

그래서 직접 만들기로 했는데, 핵심 목표는 하나였어요.

ts-morph는 TypeScript AST를 직접 읽고 심볼을 따라갈 수 있습니다.

I18N_KEYS.FEATURE_DETAIL = 'feature-detail'처럼 같은 코드베이스에 정의된 값을 런타임 없이 조회할 수 있어요.
공식 CLI가 못 하는 바로 그 한 가지를 해결하는 셈이었습니다.

사실 이건 1편에서 정리한 “안전하게 지운다”의 세 가지 기준과도 맞닿아 있어요.

그 기준이 스크립트 설계 전반에 걸쳐 어떻게 대응되는지, 글을 읽으면서 확인할 수 있습니다.

1편의 “안전한 삭제” 기준스크립트 설계에서의 대응
로케일 쌍 유지--applyko.json/en.json 양쪽 동시 삭제
런타임 안전namespace 해석을 보수적으로 판정
동적 키 예외화이트리스트로 명시적 보존

그래서 스크립트 파일 구성은 이렇게 잡았어요.

fe/scripts/i18n/
  find-unused-i18n.ts   # 메인: AST 수집, diff, --apply
  i18n-preserve.ts      # 화이트리스트: 동적/간접 참조 보존 패턴

package.json에는 두 가지 명령어를 등록했습니다.

{
  "scripts": {
    "i18n:find-unused": "tsx scripts/i18n/find-unused-i18n.ts",
    "i18n:prune": "tsx scripts/i18n/find-unused-i18n.ts --apply"
  }
}

i18n:find-unused는 리포트만 출력하는 dry-run이고, i18n:prune은 실제로 JSON에서 키를 삭제합니다.

이 구분이 왜 필요한지는 뒤에서 다시 짚겠습니다.

namespace 해석 규칙

스크립트에서 가장 먼저 해야 할 일은 namespace를 정확히 판정하는 거예요.

ts-morph로 src/**/*.{ts,tsx}를 프로젝트로 로드한 뒤, useTranslation() 호출마다 다음 규칙을 순서대로 적용했습니다.

호출 형태해석 결과
useTranslation(I18N_KEYS.FEATURE)I18N_KEYS.FEATURE의 값을 추적해서 'feature'로 해석
useTranslation('common')리터럴 그대로 'common'
useTranslation() (인자 없음)defaultNS, 즉 'common'
useTranslation(someRuntimeVar)unknown 처리. 해당 ns 전체 키를 “사용 중”으로 간주

네 번째 행이 핵심이에요.

런타임 변수로 namespace가 결정되는 경우, 스크립트가 값을 확정할 수 없습니다.
이때 “모른다”고 무시하면 해당 namespace의 키가 전부 미사용으로 판정되는데, 이건 3편에서 공식 CLI가 보여준 오탐과 정확히 같은 문제예요.

그래서 보수적으로, 해당 namespace 전체를 “사용 중”으로 간주하기로 했습니다.
오탐보다 미탐이 안전하니까요.

alias 추적

한 가지 더 처리해야 할 패턴이 있었어요.

팀 코드에서 이런 구조가 꽤 자주 등장했습니다.

const { t: tFeature } = useTranslation(I18N_KEYS.FEATURE);
// ...
tFeature('some.key');

ttFeature로 이름을 바꿔 쓰는 거죠.

스크립트는 destructuring의 alias를 추적해서, 같은 스코프 안에서 tFeature('some.key') 호출도 feature namespace의 키로 수집합니다.
이 처리가 빠지면 alias된 모든 호출이 누락되니, 생각보다 영향 범위가 컸어요.

키 수집 규칙

namespace가 결정되면, 이제 해당 스코프의 t() / i18n.t() 호출에서 실제 키를 수집해야 해요.

인자 형태에 따라 처리 방식이 달라집니다.

인자 형태처리 방식
t('a.b.c') StringLiteral정적 키로 수집
t(`literal`) NoSubstitutionTemplate정적 키로 수집
i18n.t('key', { ns: I18N_KEYS.X })ns를 재지정해서 해당 namespace에 수집
t(`prefix.${variable}`)prefix만 추출하고, 화이트리스트로 보존
t(someVar) 변수 키수집 불가. 로그에 경고 출력

처음 세 행은 직관적이에요.

문자열 리터럴이나 치환 없는 템플릿 리터럴은 그대로 정적 키로 수집하면 됩니다.
i18n.tns 옵션이 붙으면 namespace를 재지정해서 올바른 카탈로그에 매핑합니다.

문제는 네 번째와 다섯 번째예요.

t(`permission.${value}`)처럼 템플릿 리터럴에 변수가 섞이면, permission.이라는 접두사까지만 확정할 수 있습니다.

이 경우 접두사를 추출하고, permission.* 패턴으로 화이트리스트에 등록해서 해당 접두사 아래 키들이 삭제되지 않게 보존해요.

t(someVar)처럼 아예 변수로만 된 키는 정적 분석으로 할 수 있는 게 없습니다.

스크립트가 경고 로그를 출력하고, 개발자가 직접 화이트리스트에 추가해야 해요.

Info

동적 키 prefix가 발견될 때마다 스크립트가 목록을 출력합니다. 화이트리스트에서 빠진 패턴이 없는지 확인하는 용도예요.

화이트리스트 설계

화이트리스트는 정적 분석만으로 잡히지 않는 동적/간접 참조를 명시적으로 보존하는 장치예요.

i18n-preserve.ts에 네임스페이스별로 정규식이나 접두사를 배열로 관리했습니다.

실제로 등록한 패턴은 이렇습니다.

동적 호출 패턴보존 대상근거
commonTranslation(status)ACTIVE, COMPLETED 등 상태 키status 변수로 키가 결정되는 공통 번역 함수
tProject(row.project_type)PERSONAL, SHARED프로젝트 타입을 동적으로 참조
t(day)MON, TUE, WED 등 요일 키요일 변수를 직접 키로 사용

공통점이 보이시나요?

전부 런타임에 값이 결정되는 변수를 키로 직접 넘기는 곳이에요.
이런 패턴은 코드를 읽어야만 발견할 수 있어서, 스크립트가 대신 찾아주기를 기대하기 어렵습니다.

전부 자동으로 잡겠다는 욕심을 버리고, 잡히지 않는 건 명시적으로 선언하기로 한 겁니다.
그 대신 화이트리스트에 등록하는 기준은 분명하게 뒀어요.
코드에서 동적 패턴이 확인된 것만 넣고, “혹시 몰라서”는 넣지 않았습니다.

—apply 동작 흐름

여기까지가 수집 단계였습니다.

이제 수집한 결과를 어떻게 삭제에 쓰는지 살펴봅니다.
핵심 공식은 세 줄이에요.

used     = AST 수집 키  ∪  화이트리스트 매치
catalog  = flatten(ko.json[ns])  ∪  flatten(en.json[ns])
unused   = catalog \ used

used는 AST에서 수집한 키와 화이트리스트에 매칭된 키를 합친 것이고, catalog는 실제 JSON 파일에 존재하는 전체 리프 키입니다.

unused는 catalog에는 있지만 used에는 없는 키, 즉 어디에서도 참조되지 않는 키예요.

dry-run 출력

--apply 없이 실행하면 삭제는 일어나지 않습니다.

대신 namespace별로 미사용 키의 수와 목록을 보여줘요.

[feature-detail] 12 unused / 340 total
  - legacy.oldField
  - deprecated.foo
[common] 3 unused / 120 total
  ...
TOTAL: 47 unused leaf keys across 8 namespaces

이 리포트를 먼저 확인하고, 예상과 다른 키가 없는지 훑어본 뒤에 --apply를 붙이는 흐름이에요.

앞서 package.jsoni18n:find-unusedi18n:prune을 분리한 이유가 여기에 있습니다.

실제 삭제 과정

--apply를 붙이면 세 단계로 동작합니다.

  1. ko.jsonen.json 양쪽에서 unused 리프 키를 동시에 삭제한다
  2. 리프 삭제로 비어버린 중간 노드는 함께 정리하되, 최상위 namespace 노드는 유지한다
  3. JSON.stringify(obj, null, 2) + '\n'으로 쓰고, pnpm lint --fix로 포맷을 맞춘다

첫 번째 단계에서 양쪽을 동시에 삭제하는 건, 1편에서 정한 “로케일 쌍 유지” 기준에 대응합니다.

한쪽만 지우면 다른 로케일에 유령 키가 남아서, 나중에 ko.jsonen.json의 키 트리가 어긋나거든요.

30% 안전 가드

--apply 로직에서 빼놓을 수 없는 장치가 하나 더 있습니다.

미사용으로 판정된 키가 전체 카탈로그의 30%를 넘으면, 스크립트가 경고를 출력하고 중단해요.

unused.size > catalog.size * 0.3  →  abort

왜 이런 가드가 필요한지 생각해보면, 스크립트가 정상적으로 동작할 때 전체의 30% 이상이 한꺼번에 미사용이 되는 상황은 현실적이지 않습니다.

그보다는 설정이 잘못된 경우가 훨씬 가능성이 높아요.

  • I18N_KEYS 파일 경로가 변경됐는데 스크립트에 반영하지 않은 경우
  • 화이트리스트 파일이 빠져 있을 때
  • ts-morph 프로젝트 로드 범위가 좁아서 일부 소스가 누락된 상황

이런 상황에서 스크립트가 “전부 안 쓰이네요”라고 판단하고 대량 삭제를 실행하면, 복구하기가 까다롭습니다.

30%라는 수치는 저희 카탈로그 규모에서 경험적으로 잡은 임계값이에요.
절대적인 기준은 아니지만, “뭔가 이상하다”를 감지하는 데는 충분했습니다.

CI에서 쓸 플래그 두 가지

스크립트에 두 가지 플래그를 더 넣었어요.

  • --json 머신 파싱용 JSON 출력. CI 파이프라인에서 PR 본문을 자동 생성하는 데 활용합니다
  • --namespace <name> 특정 namespace만 분석. 새 화이트리스트 패턴을 추가한 뒤 해당 namespace만 빠르게 검증할 때 유용해요

이 두 플래그는 로컬에서 쓸 때보다, 자동화 파이프라인에 물릴 때 진가를 발휘합니다.

그 이야기는 다음 편에서 이어갑니다.

스크립트 설계 요약

  • ts-morph 스크립트의 핵심은 I18N_KEYS.X의 실제 값을 같은 프로세스 안에서 추적하는 것이다
  • namespace 해석은 네 가지 패턴으로 분류하고, 확정할 수 없으면 **보수적으로 “사용 중”**으로 간주해요
  • 정적 분석의 한계는 화이트리스트로 명시적으로 보존하되, “혹시 몰라서”가 아닌 동적 패턴이 확인된 것만 등록한다
  • --applyko.json/en.json 양쪽을 동시에 삭제하고, 30% 안전 가드로 대량 오삭제를 방지합니다

다음 편에서는 이 스크립트를 GitHub Actions 주간 cron에 물려서, 사람이 diff 확인과 스모크 테스트만 하면 되는 자동 PR 파이프라인을 만드는 과정을 다룹니다.

On this page