Hook
t('ui.a_065'). 이게 무슨 뜻인지 아시겠습니까? 코드를 읽는 개발자도, 번역하는 사람도, AI 에이전트도 이 키가 어떤 화면의 어떤 문구인지 알 수 없습니다. 매번 키-값 매핑 파일을 뒤져야 합니다.
이 글에서는 키 발명 단계를 없앤 GNU gettext 원문 키 방식을 소개하고, 기존 구조적 키 296건을 0건으로 마이그레이션한 과정을 공유합니다.
TL;DR
- JSON 키 방식의 핵심 문제는 “키 발명”입니다: 매 문자열마다 고유 키를 만들고 유지하고 매핑하는 순수 오버헤드
- gettext 원문 키 방식: 텍스트 자체가 키.
t('운동이 저장되었습니다') - 듀얼 모드로 기존 구조적 키와 gettext를 공존시켜 점진적 마이그레이션
- CI/CD가 모든 사용자 문자열의 gettext 래핑을 강제합니다
Background: 두 가지 i18n 방식 비교
JSON 키 방식 (기각)
// 매번 키를 발명해야 함
t('workout.save.success') // ← 이 키를 누가 만들었는가?
t('workout.delete.confirm')
t('ui.a_065') // ← 이건 뭘까요?
| 문제 | 설명 |
|---|---|
| 키 발명 오버헤드 | 매 문자열마다 고유 키 생성 |
| 키 드리프트 | 리팩터 시 키가 실제 문구와 불일치 |
| 미사용 키 감사 | 사용하지 않는 키를 수동으로 찾아야 함 |
| 가독성 최악 | t('ui.a_065')가 뭔지 알 수 없음 |
| 번역자 경험 | 키-값 테이블만 보고 번역해야 함 |
gettext 원문 키 방식 (채택)
// 키 발명 불필요 — 텍스트 자체가 키
t('운동이 저장되었습니다') // 한국어 원문 = 키
t('Exercise saved') // 영어 원문 = 키
| 장점 | 설명 |
|---|---|
| 키 발명 불필요 | 텍스트 자체가 키 |
| 키 드리프트 불가 | 텍스트가 바뀌면 자동으로 새 키 |
| 자동 미사용 감지 | xgettext가 소스에서 사용된 키만 추출 |
| 가독성 | 코드에서 바로 의미 파악 |
| 복수형/컨텍스트 내장 | ngettext, pgettext 지원 |
Solution: gettext 핵심 규칙
규칙 1: 소스 언어가 키
프로젝트에 따라 소스 언어가 다릅니다:
| 프로젝트 유형 | 소스 언어 | 예시 |
|---|---|---|
| 글로벌 SaaS | 영어 | t('Exercise saved') |
| 한국어 우선 서비스 | 한국어 | t('운동이 저장되었습니다') |
규칙 2: 모든 사용자 대면 문자열에 gettext 필수
| 필수 | 제외 |
|---|---|
| UI 라벨/버튼/헤딩 | 내부 로그 |
| 폼 검증 메시지 | API 에러 code |
| 사용자 에러 메시지 | 기술 식별자 |
| 토스트 알림 | |
| 이메일 내용 |
규칙 3: 문자열 결합 금지
// ✗ 금지: 변수를 키에 결합
t(`${count}세트`) // count마다 다른 키 → 번역 누락
// ✓ 올바름: placeholder 보간
t('{count} sets', { count: count })
규칙 4: 복수형은 ngettext
// 영어: 1형/2형 (singular/plural)
// 한국어: 1형 (단일 형태)
// 아랍어: 6형 (0, 1, 2, 3-10, 11-99, 100+)
ngettext('{count} set', '{count} sets', count)
규칙 5: 컨텍스트는 pgettext
// 같은 단어, 다른 의미
pgettext('verb', 'Open') // 동사: 열기
pgettext('adj', 'Open') // 형용사: 열린
듀얼 모드 설계
기존 구조적 키를 사용하던 프로젝트에서 gettext로 전환할 때, 하나의 t() 함수로 두 방식을 공존시킵니다:
t('ui.a_065') → 점이 있음 → 구조적 키 조회
t('운동이 저장되었습니다') → 점이 없음 → gettext 원문 키
t() 듀얼 모드 분기 로직
function t(path) {
// 점 포함 → 구조적 키 (기존 방식)
if (path.includes('.')) {
return lookupStructuralKey(path);
}
// 점 없음 → gettext 원문 키
if (locale === 'ko') {
return path; // 원문 그대로 반환 (원문 = 키 = 한국어)
}
// 다른 언어 → 번역 조회, 없으면 원문 fallback
return translations[locale]._gettext[path] || path;
}
올바른 사용:
showToast(t('운동이 저장되었습니다'))
button.innerHTML = t('확인')
금지:
showToast('운동이 저장되었습니다') // 하드코딩
t('ui.a_001') // 의미 없는 키
t(`${count}세트`) // 변수를 키에
CI/CD 강제 규칙
| 위반 | 결과 |
|---|---|
| 사용자 문자열 gettext 래핑 안 함 | PR 차단 |
| .po 구문 오류 | 커밋 차단 |
| .pot 미갱신 | 머지 차단 |
| fuzzy 항목 | 경고 (머지 허용) |
| 컴파일 실패 | 배포 차단 |
지원 언어 및 폴백
지원: en(소스), ko, ja, zh-CN, zh-TW, es, fr, de, pt-BR, ar(RTL)
폴백 체인:
ja-JP → ja → en → raw msgid
모든 서비스는 영어 번역 필수 출시입니다.
Result: 마이그레이션 결과
피트니스 PWA에서 구조적 키를 gettext 원문 키로 전환한 결과:
| 단계 | 구조적 키 잔여 |
|---|---|
| 마이그레이션 전 | 296건 |
| A-1~A-7 자동화 스크립트 실행 후 | 0건 (주석 3건만 잔여, 화면 미노출) |
자동화 스크립트(migrate-i18n-*.js, convert-gettext.js)로 413건을 일괄 교체했습니다. 이후 번역은 en.js의 _gettext 객체만 편집하면 됩니다.
마치며
국제화(i18n)를 처음 도입했을 때, 당연히 JSON 키-값 방식을 선택했습니다. t('workout.save.success')처럼 구조적이고 체계적인 키를 만드는 것이 “올바른” 방법이라고 믿었기 때문입니다. 하지만 프로젝트가 커지면서 이 키들을 유지하는 것 자체가 엄청난 부담이 되었습니다. 296개의 구조적 키를 점검하다 보니, “이 키가 무슨 문구인가?”를 매번 매핑 파일에서 찾아야 했고, 코드를 읽는 AI 에이전트조차 t('ui.a_065')가 무엇을 의미하는지 알 수 없었습니다. 키를 발명하고 유지하는 일이 정작 중요한 메시지 자체에 대한 집중을 빼앗고 있었습니다.
GNU gettext 방식을 도입하면서 가장 놀라웠던 것은, 40년 전부터 존재하던 해법이 현대의 JSON 키 방식보다 이 문제를 훨씬 우아하게 풀고 있었다는 사실입니다. 텍스트 자체를 키로 사용하면 t('운동이 저장되었습니다')처럼 자명해집니다. 개발자는 메시지에만 집중하고, 번역자는 실제 문구를 보고 번역하며, AI 에이전트는 컨텍스트를 적게 소비하면서도 더 정확한 코드를 생성할 수 있습니다. “키 발명”이라는 순수 오버헤드를 제거하니 모든 참여자가 이득을 보았습니다.
듀얼 모드를 설계해서 점진적으로 전환한 것도 중요한 결정이었습니다. 한 번에 296건을 바꾸는 것은 위험하지만, 점(.) 유무로 구조적 키와 gettext를 분기하면 새 코드는 gettext를 쓰면서 기존 코드를 안전하게 전환할 수 있었습니다. 며칠 만에 자동화 스크립트로 구조적 키를 0건으로 만들었습니다. 앞으로 AI 에이전트가 더 많은 코드를 생성할수록, 인간과 AI 모두에게 자명한 구조를 선택하는 일은 단순한 취향이 아니라 생산성의 기반이 될 것입니다.
| ← 이전 | 시리즈: AI 주도 개발 거버넌스 | 다음 → |
|---|---|---|
| (3) 모놀리식 HTML 분할 | (4) gettext 원문 키 | (5) 레이어드 테스팅 |
게이트웨이 On-promise 제품 팀에서 시스템 모니터링 및 관리를 쉽게 다가갈 수 있도록 하기 위한 업무를 하고 있습니다.
Contact: lhjnano@gmail.com