ts-morph와 GitHub Actions로 번역 키 자동 정리 파이프라인 만들기

April 13, 2026

i18n 미사용 키 정리
  1. 1. 공식 i18next-cli가 네임스페이스를 통째로 지우려 했다
  2. 2. ts-morph와 GitHub Actions로 번역 키 자동 정리 파이프라인 만들기

공식 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가 못 하는 바로 그 한 가지를 해결하는 셈이었습니다.

앞선 글에서 정한 “안전한 삭제” 세 기준이 스크립트 설계 전반에 걸쳐 대응됩니다.

”안전한 삭제” 기준스크립트에서의 대응
로케일 쌍 유지--applyko.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를 붙이면 세 단계로 동작합니다.

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

첫 번째 단계에서 양쪽을 동시에 삭제하는 건, “로케일 쌍 유지” 기준에 대응합니다. 한쪽만 지우면 다른 로케일에 유령 키가 남아서, ko.jsonen.json의 키 트리가 어긋나거든요.

그리고 빼놓을 수 없는 장치가 하나 더 있습니다. 미사용으로 판정된 키가 전체 카탈로그의 30%를 넘으면, 스크립트가 중단합니다.

unused.size > catalog.size * 0.3  →  abort

정상적으로 동작할 때 전체의 30% 이상이 한꺼번에 미사용이 되는 상황은 현실적이지 않습니다. I18N_KEYS 파일 경로가 변경됐거나, 화이트리스트 파일이 빠져 있거나, ts-morph 프로젝트 로드 범위가 좁아서 소스가 누락된 상황이 훨씬 가능성이 높습니다.

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

주간 cron이 PR을 올리는 구조

로컬에서 잘 돌아가는 스크립트가 완성됐지만, 한 가지가 걸렸습니다. 사람이 주기적으로 돌려야 한다는 거였습니다.

한 달쯤 지나니까 정확히 그렇게 됐습니다. 스크립트는 있는데 아무도 안 돌리는 상태.

그래서 GitHub Actions 주간 cron에 물렸습니다. 매주 일요일 02:00 KST에 돌아가고, 미사용 키가 있으면 자동으로 PR이 올라옵니다.

전체 파이프라인의 흐름입니다.

  1. 스케줄 트리거 — 매주 일요일 02:00 KST
  2. 환경 준비 — checkout, pnpm setup, install
  3. 리포트 생성--json으로 미사용 키 목록
  4. 삭제 적용--apply로 실제 JSON에서 키 제거
  5. 포맷 정리pnpm lint --fix
  6. 변경 감지 — diff가 없으면 PR 없이 종료
  7. PR 본문 생성 — 현황 표·체크리스트·스냅샷을 마크다운으로 조립
  8. 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 파일 열어서 키 하나하나 추적하던 때를 생각하면, 충분히 나아졌습니다.

On this page