TypeScript 11분 읽기

번역 키를 컴파일 타임에 잡기 #2 — 왜 조용히 터지나, 그리고 왜 개발 단계에서 잡아야 하나

번역 키 t('user.profile.title'), 딱 한 글자 잘못 쳤을 뿐인데 컴파일도 CI도 다 통과했다. 그런데 프랑스어 화면엔 영어가 떠 있다. 왜 이런 버그는 런타임에야 조용히 드러날까 — 그리고 나는 이걸 어디서 잡았어야 했을까.

번역 키 t('user.profile.title'), 딱 한 글자 잘못 쳤을 뿐인데 컴파일도 CI도 다 통과했다. 그런데 프랑스어 화면엔 영어가 그럴싸하게 떠 있다. 왜 이런 버그는 런타임에야, 그것도 조용히 드러날까 — 그리고 나는 이걸 어디서 잡았어야 했을까.

1편에서 “TypeScript의 모든 비용과 안전장치는 빌드 시점에 있고, 실행 시점엔 타입이 사라진다”를 봤다. 이번 글은 그 컴파일 타임 검사에 번역 키를 끼워 넣기 전에 두 가지를 정리한다. (1) 지금은 왜 안 잡히는가, (2) 왜 최대한 일찍(왼쪽) 잡아야 하는가. 실제 타입을 조립하는 방법은 다음 글(#3)이다.


1. 번역 키는 결국 그냥 문자열이다

번역을 쓰는 코드는 대개 이렇게 생겼다.

t('user.profile.title')

그런데 이 'user.profile.title'은 타입 관점에서 그냥 string 이다. 그래서 다음 세 가지가 전부 컴파일도 통과하고, 대부분의 테스트도 안 걸린다.

실패런타임 증상
오타t('user.profile.titel')화면에 원시 키가 그대로 노출
네임스페이스 누락t('profile.title') (실제 키는 user 네임스페이스)전 언어가 영어로 고정 (제일 교묘)
미정의 키t('user.gone')원시 키 노출 / fallback

왜 조용히 터지나

1편의 결론과 정확히 이어진다. 번역 함수의 시그니처가 보통 이렇기 때문이다.

function t(key: string): string

keystring이면 어떤 문자열이든 통과한다. 컴파일러 눈에는 유효한 키와 오타 키를 구분할 방법이 없다. 타입은 런타임에 소거되니(1편), 실행 중에 “이 키가 실제로 있나”를 타입이 지켜줄 수도 없다. 그래서 이 버그들은 작성 시점에도, 컴파일에도, 흔히 CI에도 안 걸리고 런타임에야 조용히 드러난다.

제일 위험한 건 “돌아가는 것처럼 보이는” 실패

세 번째 열의 “네임스페이스 누락 → 영어 고정”이 특히 고약하다. 많은 i18n 라이브러리는 키를 못 찾으면 fallback 언어(보통 영어)로 채운다. 게다가 호출부에 이런 기본값을 흔히 붙인다.

t('profile.title', 'My Profile')   // 두 번째 인자 = fallback 문자열

그러면 번역이 깨졌는데도 화면엔 영어가 그럴싸하게 뜬다. 아무 에러도 안 나서 QA도 지나친다. 프랑스어 화면에 영어가 섞여 있어도 “동작은 하니까” 릴리스에 새어 나간다.

실제로 한 팀에서 이런 유의 번역 버그를 오래 추적해 전수조사해 봤더니, “화면에 원시 키가 뜬다”는 하나의 증상이 실제로는 최소 6갈래 원인(하드코딩 / 언어 코드 불일치 / 오역·미번역 / 로케일 파일 키 누락 / 복수형 접미사 누락 / 코드가 존재하지 않는 키 호출)에서 나왔다. 그리고 같은 버그가 여러 배포에 걸쳐 반복 재발하고 있었다. 겉보기 증상만으로는 원인이 뭉개져 보이지만, 이 중 오타·미정의·네임스페이스 오용(코드↔키 정합성) 은 타입만으로 완전히 막을 수 있는 부류다.

2. Shift-Left — 결함을 최대한 왼쪽에서 잡아라

나는 이런 버그를 작성하는 순간에 잡고 싶었다. 여기서 shift-left라는 원칙이 나온다.

Shift-Left는 말 그대로 결함을 발견하는 지점을 최대한 왼쪽, 즉 개발 단계로 당기는 것이다. 널리 인용되는 IBM 추정치는 결함을 릴리스 후에 고치는 비용이 설계 단계 대비 수십~100배라고 말한다. (이 “100배” 수치는 원 출처 추적성에 논쟁이 있으니 단정은 피하는 게 맞지만, “늦게 잡을수록 비싸다”는 방향성 자체는 여러 연구로 재현된다.)

발견 단계의 사다리

같은 버그라도 어디서 잡히느냐에 따라 고치는 난이도가 확 달라진다. 왼쪽에서 오른쪽으로:

IDE 실시간 검사  →  컴파일(작업 후)  →  CI(PR·머지)  →  QA(배포와 엮임)
  ← 싸고 빠름                                            비싸고 느림 →
  • IDE 실시간 검사 — 타이핑하는 즉시 빨간 줄. 가장 싸다. 내가 아직 작업 중일 때 잡힌다.
  • 컴파일 — 내 작업이 끝났다고 생각한 시점에 tsc로 한 번. 조금 늦지만 여전히 내 손 안, 로컬이다.
  • CI — PR을 올려 머지하려는 시점. 여기엔 컴파일·린트뿐 아니라 테스트 실행도 들어간다. 이미 “다 됐다”고 생각한 뒤라 더 늦다.
  • QA — 여기서 걸리면 배포와 엮인다. 작은 버그 하나도 QA 라인을 다시 타야 “해결됐다”가 확인되니, 앞 단계들보다 수정이 훨씬 까다로워진다.

Shift Left 발견 단계 사다리 — IDE 실시간(타이핑 즉시, 내 손 안, 가장 쌈) → 컴파일 tsc --noEmit(작업 후, 로컬) → CI(PR·머지, 린트+컴파일+테스트) → QA(배포와 엮임, 가장 비쌈)로 갈수록 계단이 올라가며 수정 비용·난이도가 커진다

그래서 QA 이전 단계에서 최대한 잡아내는 것이 이득이다. 그리고 앞서 본 번역 키 버그들 중 코드↔키 정합성 부류는 QA는커녕 IDE에서 잡을 수 있는 것들이었다.

AI 시대의 shift-left — 실시간 검사에서 “명시적 컴파일”로

원래 개발 단계 최전선은 IDE의 실시간 타입 검사였다. (1편에서 본 tsserver — 계속 살아서 빨간 줄을 그어주는 그 프로세스다.) 예전엔 저장할 때 린트·프리터를 돌리도록 IDE를 설정해서, 내 작업이 끝나기 전에 컨벤션 위반을 걸러냈다. 타입도 마찬가지로 실시간 검사를 적극 활용하면 굳이 컴파일을 명시적으로 안 돌려도 됐다.

그런데 요즘은 AI가 코드를 많이 쓴다. 사람이 IDE의 빨간 줄을 실시간으로 보지 않는 상황이 많아졌다. 그래서 작업이 끝난 뒤 PR 전에 명시적 컴파일(tsc --noEmit)을 필수로 돌리는 것이 그 실시간 루프를 대체한다. (1편에서 본 “검사와 변환은 분리 가능하다 — tsc --noEmit은 검사만 한다”가 바로 이 자리다.) 실시간보다 약간 오른쪽이지만, 여전히 CI 이전, 내 손 안의 안전망이라는 점에서 같은 계층이다. 린트도 같은 역할을 한다.

직교하는 축 하나 — “무엇을 잡나” (검증 4층위)

지금까지는 “언제 잡나”(시점)의 축이었다. 여기 직교하는 “무엇을 잡나”의 축이 하나 더 있다.

층위잡는 것i18n 예시
타입 (TS)계약 위반 — 타입이 불가능하게 만든 사용존재하지 않는 키 = 컴파일 에러 + 자동완성
린트 (ESLint)안티패턴 — 유효하지만 버그 소지하드코딩된 영어 문자열(번역 누락)
테스트동작 — 입력 → 출력복수형·보간 렌더 결과
런타임실제 데이터 — 정적으로 알 수 없는 실입력동적 키·미로드 리소스

검증 4층위 — 세로축은 정적(타입·린트)에서 동적(테스트·런타임)으로, 각 층위가 잡는 것: 타입=계약 위반(존재하지 않는 키), 린트=안티패턴(하드코딩 문자열), 테스트=동작(복수형·보간 렌더), 런타임=실제 데이터(동적 키). 번역 키 존재 여부는 가능/불가능 문제라 타입의 영역

ESLint 공식 문서의 표현이 이 구분을 깔끔하게 요약한다.

“TypeScript는 무엇을 할 수 있는가(can)를, ESLint는 무엇을 해야 하는가(should)를 강제한다.”

그래서 “타입과 린트는 비슷한 역할”이라는 말은 절반만 맞다. 시점은 비슷(둘 다 정적)하지만 잡는 대상이 다르다 — 타입은 불가능한 것, 린트는 하면 안 되는 것. 번역 키가 실제로 존재하는지는 “가능/불가능”의 문제라 타입의 영역이다.

3. 타입이 실제로 잡는 것

번역 함수의 key 타입을 string이 아니라 “실제 존재하는 키들의 집합” 으로 좁히면 (어떻게 좁히는지는 #3), 앞의 세 실패가 전부 컴파일 에러가 된다.

t('user.profile.title')   // ✅ 통과
t('user.profile.titel')   // ❌ 오타 → 컴파일 에러
t('user.gone')            // ❌ 미정의 → 컴파일 에러
t('profile.title')        // ❌ 네임스페이스 누락 → 컴파일 에러

여기서 타입이 다른 층위보다 강한 결정적 지점이 있다. 린트·테스트·QA는 전부 “틀린 뒤에 발견”이다. 그런데 타입은 유효한 키만 자동완성 후보로 띄워서 오타를 애초에 못 내게 예방한다. shift-left의 극단은 결함을 “일찍 발견”하는 걸 넘어 “발생 자체를 막는 것” 이고, 타입이 정확히 거기 있다. IDE 실시간 검사가 “가장 효과적”인 진짜 이유다.

4. 타입이 못 잡는 것 (정직하게)

물론 타입이 만능은 아니다. 앞의 6갈래 중 타입 밖인 것들:

  • 로케일 파일 간 키 누락 (영어엔 있는 키가 한국어 파일엔 없음) — 타입은 기준 언어 하나만 본다. 이건 여러 파일의 키 집합을 대조하는 별도 스크립트의 몫이다.
  • 오역·미번역·언어 코드 불일치 — 값의 문제라 타입 범위 밖(사람 검수/기능/런타임).
  • 동적 키 (t(변수)) — 서버 응답 등으로 런타임에 정해지는 키. 흥미롭게도 타입은 이걸 “조용히 통과”시키는 게 아니라 오히려 과하게 막는다(string이 유효 키 집합보다 넓어서). 해법은 그 변수를 union/enum으로 좁혀 안전하게 만드는 것 — 이 반전은 별도 글(#4)에서 코드와 함께 다룬다.

정리 — 그리고 다음 글

  • 번역 키는 결국 string이라 오타·미정의·네임스페이스 오용이 런타임에야 조용히 터진다. 특히 영어 fallback은 “돌아가는 척”해서 QA도 지나친다.
  • Shift-Left: 결함은 왼쪽(IDE→컴파일→CI→QA)에서 잡을수록 싸다. AI 시대엔 실시간 검사 대신 명시적 컴파일 게이트가 그 최전선을 맡는다.
  • 그중 코드↔키 정합성(오타/미정의/wrong-ns)은 타입만으로 컴파일 타임에 막히고, 자동완성으로 아예 오타를 못 내게 예방까지 된다.

그럼 어떻게 번역 함수의 key를 “실제 존재하는 키들의 집합”으로 좁힐까? 다음 글에서는 JSON 번역 파일을 그대로 타입으로 만들어, 외부 도구 없이 declare module 하나로 키를 잠그는 방법을 조립한다.