공식 CLI가 I18N_KEYS 간접 참조를 해석하지 못하는 걸 확인하고 나니, 남은 과제는 하나였습니다.
I18N_KEYS.FEATURE_DETAIL이 어떤 문자열을 가리키는지, 같은 프로세스 안에서 추적하는 것.
이 글에서는 ts-morph로 만든 미사용 키 탐지 스크립트의 설계와, GitHub Actions 주간 cron으로 자동 PR을 올리는 파이프라인까지의 과정을 정리합니다.
I18N_KEYS를 진실원으로 삼는다
ts-morph란?
TypeScript Compiler API를 감싸서 AST 탐색·조작을 간결하게 해주는 라이브러리입니다.
파일 간 심볼 추적, 노드 타입 판별, 코드 수정까지 하나의 API로 처리할 수 있습니다.
ts-morph는 TypeScript AST를 직접 읽고 심볼을 따라갈 수 있습니다. I18N_KEYS.FEATURE_DETAIL = 'feature-detail'처럼 같은 코드베이스에 정의된 값을 런타임 없이 조회할 수 있어서, 공식 CLI가 못 하는 바로 그 한 가지를 해결하는 셈이었습니다.
앞선 글에서 정한 “안전한 삭제” 세 기준이 스크립트 설계 전반에 걸쳐 대응됩니다.
| ”안전한 삭제” 기준 | 스크립트에서의 대응 |
|---|---|
| 로케일 쌍 유지 | --apply 시 ko.json/en.json 양쪽 동시 삭제 |
| 런타임 안전 | 네임스페이스 해석을 보수적으로 판정 |
| 동적 키 예외 | 화이트리스트로 명시적 보존 |
파일 구성은 이렇게 잡았습니다.
fe/scripts/i18n/
find-unused-i18n.ts # 메인: AST 수집, diff, --apply
i18n-preserve.ts # 화이트리스트: 동적/간접 참조 보존 패턴{
"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에서 키를 삭제합니다.
네임스페이스 해석 규칙
스크립트에서 가장 먼저 해야 할 일은 네임스페이스를 정확히 판정하는 것이었습니다.
ts-morph로 src/**/*.{ts,tsx}를 프로젝트로 로드한 뒤, useTranslation() 호출마다 다음 규칙을 순서대로 적용했습니다.
| 호출 형태 | 해석 결과 |
|---|---|
useTranslation(I18N_KEYS.FEATURE) | I18N_KEYS.FEATURE의 값을 추적해서 'feature'로 해석 |
useTranslation('common') | 리터럴 그대로 'common' |
useTranslation() (인자 없음) | defaultNS, 즉 'common' |
useTranslation(someRuntimeVar) | unknown 처리. 해당 네임스페이스 전체 키를 “사용 중”으로 간주 |
네 번째 행이 핵심입니다. 런타임 변수로 네임스페이스가 결정되는 경우, “모른다”고 무시하면 해당 네임스페이스의 키가 전부 미사용으로 판정됩니다. 앞선 글에서 공식 CLI가 보여준 오탐과 정확히 같은 문제입니다. 그래서 보수적으로, 해당 네임스페이스 전체를 “사용 중”으로 간주했습니다.
팀 코드에서는 t를 alias해서 쓰는 패턴도 자주 등장했습니다.
const { t: tFeature } = useTranslation(I18N_KEYS.FEATURE);
tFeature('some.key');스크립트는 destructuring의 alias를 추적해서, 같은 스코프 안에서 tFeature('some.key') 호출도 feature 네임스페이스의 키로 수집합니다. 이 처리가 빠지면 alias된 모든 호출이 누락되니, 생각보다 영향 범위가 컸습니다.
키 수집과 화이트리스트
네임스페이스가 결정되면, t() / i18n.t() 호출에서 실제 키를 수집합니다.
| 인자 형태 | 처리 방식 |
|---|---|
t('a.b.c') StringLiteral | 정적 키로 수집 |
t(`literal`) NoSubstitutionTemplate | 정적 키로 수집 |
i18n.t('key', { ns: I18N_KEYS.X }) | ns를 재지정해서 해당 네임스페이스에 수집 |
t(`prefix.${variable}`) | prefix만 추출, 화이트리스트로 보존 |
t(someVar) 변수 키 | 수집 불가. 로그에 경고 출력 |
네 번째와 다섯 번째가 까다로웠습니다. t(`permission.${value}`)처럼 템플릿 리터럴에 변수가 섞이면 permission.이라는 접두사까지만 확정할 수 있고, t(someVar)처럼 변수로만 된 키는 정적 분석으로 할 수 있는 게 없습니다.
여기서 화이트리스트가 등장합니다. i18n-preserve.ts에 네임스페이스별 정규식이나 접두사를 배열로 관리했습니다.
| 동적 호출 패턴 | 보존 대상 | 근거 |
|---|---|---|
commonTranslation(status) | ACTIVE, COMPLETED 등 상태 키 | status 변수로 키가 결정되는 공통 번역 함수 |
tProject(row.project_type) | PERSONAL, SHARED 등 | 프로젝트 타입을 동적으로 참조 |
t(day) | MON, TUE, WED 등 요일 키 | 요일 변수를 직접 키로 사용 |
전부 런타임에 값이 결정되는 변수를 키로 직접 넘기는 곳입니다.
전부 자동으로 잡겠다는 욕심을 버리고, 잡히지 않는 건 명시적으로 선언하기로 했습니다. 대신 기준은 분명하게 뒀습니다. 코드에서 동적 패턴이 확인된 것만 넣고, “혹시 몰라서”는 넣지 않았습니다.
수집과 보존 로직이 갖춰졌으니, 이제 실제로 키를 지우는 단계로 넘어갑니다.
—apply와 30% 안전 가드
수집이 끝나면 핵심 공식은 세 줄입니다.
used = AST 수집 키 ∪ 화이트리스트 매치
catalog = flatten(ko.json[ns]) ∪ flatten(en.json[ns])
unused = catalog \ used--apply를 붙이면 세 단계로 동작합니다.
ko.json과en.json양쪽에서unused리프 키를 동시에 삭제- 리프 삭제로 비어버린 중간 노드는 함께 정리하되, 최상위 네임스페이스 노드는 유지
JSON.stringify(obj, null, 2) + '\n'으로 쓰고,pnpm lint --fix로 포맷을 맞춤
첫 번째 단계에서 양쪽을 동시에 삭제하는 건, “로케일 쌍 유지” 기준에 대응합니다. 한쪽만 지우면 다른 로케일에 유령 키가 남아서, ko.json과 en.json의 키 트리가 어긋나거든요.
그리고 빼놓을 수 없는 장치가 하나 더 있습니다. 미사용으로 판정된 키가 전체 카탈로그의 30%를 넘으면, 스크립트가 중단합니다.
unused.size > catalog.size * 0.3 → abort정상적으로 동작할 때 전체의 30% 이상이 한꺼번에 미사용이 되는 상황은 현실적이지 않습니다. I18N_KEYS 파일 경로가 변경됐거나, 화이트리스트 파일이 빠져 있거나, ts-morph 프로젝트 로드 범위가 좁아서 소스가 누락된 상황이 훨씬 가능성이 높습니다.
30%라는 수치는 저희 카탈로그 규모에서 경험적으로 잡은 임계값입니다. 절대적인 기준은 아니지만, “뭔가 이상하다”를 감지하는 데는 충분했습니다.
주간 cron이 PR을 올리는 구조
로컬에서 잘 돌아가는 스크립트가 완성됐지만, 한 가지가 걸렸습니다. 사람이 주기적으로 돌려야 한다는 거였습니다.
한 달쯤 지나니까 정확히 그렇게 됐습니다. 스크립트는 있는데 아무도 안 돌리는 상태.
그래서 GitHub Actions 주간 cron에 물렸습니다. 매주 일요일 02:00 KST에 돌아가고, 미사용 키가 있으면 자동으로 PR이 올라옵니다.
전체 파이프라인의 흐름입니다.
- 스케줄 트리거 — 매주 일요일 02:00 KST
- 환경 준비 — checkout, pnpm setup, install
- 리포트 생성 —
--json으로 미사용 키 목록 - 삭제 적용 —
--apply로 실제 JSON에서 키 제거 - 포맷 정리 —
pnpm lint --fix - 변경 감지 — diff가 없으면 PR 없이 종료
- PR 본문 생성 — 현황 표·체크리스트·스냅샷을 마크다운으로 조립
- PR 생성 —
peter-evans/create-pull-request
.github/workflows/i18n-prune.yml 전체 보기
name: i18n prune unused keys
on:
schedule:
- cron: '0 17 * * 6' # 매주 일요일 02:00 KST (UTC 17:00 토요일)
workflow_dispatch:
inputs:
dry_run:
description: '삭제 없이 리포트만 확인'
type: boolean
default: false
force:
description: '30% 안전 가드 무시'
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
prune:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
working-directory: fe
- name: Generate unused keys report
run: pnpm exec tsx scripts/i18n/find-unused-i18n.ts --json > /tmp/i18n-report.json
working-directory: fe
- name: Prune unused keys
if: ${{ !inputs.dry_run }}
run: pnpm exec tsx scripts/i18n/find-unused-i18n.ts --apply ${{ inputs.force && '--force' || '' }}
working-directory: fe
- name: Fix lint
if: ${{ !inputs.dry_run }}
run: pnpm lint --fix
working-directory: fe
- name: Check diff
id: diff
run: |
git diff --quiet && echo "no_changes=true" >> $GITHUB_OUTPUT || true
- name: Build PR body
if: steps.diff.outputs.no_changes != 'true'
run: node .github/scripts/build-i18n-pr-body.cjs /tmp/i18n-report.json > /tmp/pr-body.md
working-directory: fe
- name: Create Pull Request
if: steps.diff.outputs.no_changes != 'true'
uses: peter-evans/create-pull-request@v6
with:
branch: chore/i18n-prune-${{ github.run_id }}
commit-message: 'chore(i18n): remove unused translation keys'
title: 'chore(i18n): remove unused translation keys'
body-path: /tmp/pr-body.md
labels: i18n, automated일요일 새벽에 돌려서 월요일 오전에 PR을 확인하고 머지하면 한 주를 깔끔하게 시작할 수 있게 했습니다. workflow_dispatch로 수동 실행도 열어뒀는데, 화이트리스트 수정 직후 바로 결과를 확인할 수 있어서 유용했습니다.
자동으로 삭제까지는 하지만, 머지는 사람이 합니다. diff를 읽고, 의심 가는 키가 있으면 화이트리스트에 추가한 뒤 다음 주에 다시 돌리면 됩니다.
수동 스모크 체크리스트
화이트리스트로 보존했더라도, 리뷰어가 PR 머지 전에 확인해야 할 화면이 있습니다. 동적 키가 집중된 곳입니다.
- 권한 셀렉트 —
t(`permission.${value}`)패턴을 쓰는 컴포넌트 - 히스토리/피드백 테이블 — 필드명을
FIELD_MAP으로 순회하며t()에 넘기는 컬럼 - 편집 모달 — 모달 제목을
mode(create/edit)로 분기하는 패턴 - 연동 관리 —
i18n.t('key', { ns })간접 호출을 사용하는 화면
이 목록을 PR 템플릿에 체크리스트로 넣어두면 리뷰어가 빠뜨리지 않습니다.
돌아보면 이런 흐름이었습니다.
I18N_KEYS를 진실원으로 삼아 네임스페이스를 정확히 해석했고- 동적 키는 화이트리스트로, 런타임 변수는 보수적 판정으로 오탐을 막았고
--apply와 30% 안전 가드로 삭제 범위를 통제했고- GitHub Actions 주간 cron이 PR을 올려서, 사람이 확인하고 머지하는 구조로 만들었습니다
화이트리스트 관리는 여전히 수동이고, 미번역 비율 모니터링은 아직 손도 못 댔습니다.
그래도 매주 월요일 아침에 PR이 올라와 있으면, diff 확인하고 스모크 테스트 한 번 돌리면 끝입니다. 예전처럼 JSON 파일 열어서 키 하나하나 추적하던 때를 생각하면, 충분히 나아졌습니다.