메시지 ID — 메시지를 식별할 수 있게 하고, 그 뒤에 언어를 붙이기
상태: 다 됐습니다. 다섯 언어 모두 347개 — 9절
이 도구가 내는 로그·진단·에러를 영어·한국어·중국어 간체·중국어 번체·일본어로 낼 수 있게 하는 것이 목표입니다. 다만 작업의 대부분은 번역이 아닙니다 — 지금은 어떤 보고인지를 가리킬 방법이 없어서, 번역할 대상을 지목하는 것부터가 없습니다.
1. 지금 없는 것
| 찾아본 것 | 결과 |
|---|---|
.resx · ResourceManager · IStringLocalizer | 없음 |
CultureInfo 사용처 | 전부 InvariantCulture이고, 생성 코드의 숫자 리터럴용입니다. 메시지와 무관합니다 |
| recipe·CLI의 언어 항목 | 없음 |
| 메시지 식별자 | 없음. 보고를 가리키는 유일한 방법이 문구 그 자체입니다 |
메시지는 전부 호출 지점에 박힌 영어 보간 문자열입니다.
2. 표면은 셋이고, 규모가 다릅니다
| 표면 | 자리 | 누가 읽습니까 |
|---|---|---|
| Serilog 로그 라인 | 141 (Information 69 · Warning 30 · Debug 27 · Error 8 · Fatal 7) | 실행을 돌리는 사람과 CI |
진단 보고 (Diagnostics의 Error·Warn·Info) | 49 | 데이터를 가진 쪽 |
TabbitException | 279 — 그중 셀 위치를 든 것 40 (location 31 · callerLocation 6 · null 3) | 섞여 있습니다. 3절 |
합 469곳입니다. 여기에 「문구에 걸린 테스트 단정 약 184곳」이 따라옵니다.
Serilog는 절반이 이미 되어 있습니다. 메시지 템플릿이 「무엇이 일어났는가」와 값을 나눠 두므로, 그 템플릿 문자열 자체가 사실상 ID입니다. 남은 것은 그것을 호출 지점에서 떼어내는 일뿐입니다. 반면 진단과 예외는 값이 문구에 보간되어 있어서 나눌 것이 아직 나뉘어 있지 않습니다.
3. 판단 기준 — 누가 고칠 수 있는가
TabbitException 279곳은 한 종류가 아닙니다. 실제로는 셋입니다.
| 무엇 | 누가 고칩니까 | ID | 번역 |
|---|---|---|---|
| 시트의 데이터 문제 — 셀 위치를 들고 나오는 것 | 데이터를 가진 쪽 | 붙입니다 | 합니다 |
| recipe·설정 문제 — 없는 커넥션 문자열, 한 글자가 아닌 구분자 | recipe를 쓴 쪽 | 붙입니다 | 합니다 |
도구 자신의 결함 — 빠진 임베드 리소스, switch 기본 분기의 「지원하지 않는 타입」, 생성 코드가 컴파일되지 않는 것 | 우리 | 안 붙입니다 | 안 합니다 |
마지막 줄이 이 스펙에서 뒤집는 것입니다. 번역된 버그 리포트는 검색되지 않습니다 — 사용 자가
한국어 문장을 붙여 오면 우리 저장소에서 그 문구를 찾을 수 없고, 스택도 없습니다. 영어로 두는 것이
그 자리에서는 기능입니다. 대략 22곳이고, 그중 하나는 이미 그렇게 적고 있습니다:
「which is this tool's own fault rather than」(CsAssemblyEmitter.cs:90).
그 갈래가 지금 안 보이는 이유는 셋 다 TabbitException 하나이기 때문입니다. 그래서
1단계는 ID가 아니라 예외를 갈라내는 것이고, 그것은 로컬라이제이션과 무관하게 값을 냅니다 —
사용자가 「내 시트 문제」와 「도구 버그」를 구별할 수 있게 됩니다.
4. ID의 형태 — 코어에 목록을 두지 않습니다
가장 쉬운 설계는 코어에 enum MessageId를 두는 것이고, 이 저장소에서는 그것이 금지된
형태입니다. 레이아웃 파서가 자기 메시지를 내려면 코어의 enum을 고쳐야 하고, 그러면
CLAUDE.md의 판단 기준이 깨집니다 — 레이아웃 파일 하나를 지웠을 때 코어에
흔적이 남습니다. SheetPerTableLayoutParser가 8곳, NamedRangeLayoutParser가 9곳에서 보고합니다.
그래서 [TabbitLayout("id")]와 같은 방식을 씁니다. ID는 문자열이고, 접두어를 선언하는 쪽이
가집니다.
| 접두어 | 주인 |
|---|---|
recipe. · cook. · validate. · export. · import. | 코어. 실행 단계와 같은 이름입니다 (LogCategory) |
| 레이아웃 id가 그대로 접두어 | <레이아웃>.duplicate-name |
카탈로그도 같은 선으로 갈라집니다 — 코어 카탈로그 하나, 레이아웃마다 자기 것. 레이아웃 파일 하나와 그 카탈로그 하나를 지우면 끝이고, 코어에는 등록 코드도 목록도 없습니다.
실측했습니다. 다른 레이아웃의 파일 넷 — 파서·메시지 클래스·전용 헬퍼·카탈로그 — 전부 치우고 빌드했더니 경고 0, 오류 0입니다. 이 문단이 주장이 아니라 확인된 것이라는 뜻이고, 앞으로도 같은 방법으로 다시 확인할 수 있습니다. 남는 것은 테스트와 픽스처뿐이고 그것은 CLAUDE.md가 허용하는 자리입니다.
자리표시자는 이름을 가집니다. 순서 있는 params object[]로 두면 번역이 어순을 바꿀 수
없는데, 한국어는 그것이 자주 필요합니다. Serilog가 이미 {Count}를 쓰고 있으므로 표기를
통일합니다.
cook.unrecognized-type = "type `{Type}` is an unrecognized type."
cook.unrecognized-type = "`{Type}`은 알 수 없는 타입입니다."
5. 카탈로그와 언어 선택
대상은 다섯입니다.
| 코드 | 언어 | 비고 |
|---|---|---|
en | 영어 | 기준 카탈로그입니다. 모든 ID가 여기 있어야 하고, 없으면 게이트가 막습니다 (8절) |
ko | 한국어 | 6.1의 조사 문제가 여기서만 나옵니다 |
zh-Hans | 중국어 간체 | zh가 아니라 지역 없는 스크립트 코드로 적습니다 — zh-CN은 지역이고, 갈리는 것은 지역이 아니라 자체입니다 |
zh-Hant | 중국어 번체 | |
ja | 일본어 |
간체와 번체를 한 벌로 만들지 않습니다. 자체 변환은 기계로 되지만 어휘가 갈립니다 —
file이 간체에서는 「文件」이고 번체에서는 「檔案」입니다. 그래서 번체는 간체를 자동 변환한
것에서 시작하고 용어만 손으로 고 칩니다: 처음부터 두 벌을 따로 쓰는 것보다 싸고, 한 벌로
합치는 것보다 정확합니다. 8절의 자리표시자 게이트는 두 벌을 똑같이 검사하므로, 변환이 {Type}을
건드렸으면 그 자리에서 걸립니다.
카탈로그는 언어마다 파일 하나이고 코어에 목록이 없으므로, 여섯 번째 언어를 넣는 것은 파일을 하나 더 놓는 일입니다.
형식은 언어당 JSON 하나이고 임베드된 리소스입니다. .resx를 쓰지 않는 것은 위성 어셈블리가
4절의 「플러그인이 자기 카탈로그를 가진다」와 어긋나기 때문이고, 이 도구는 이미 JSONC recipe와
템플릿 엔진을 들고 있어서 새로 들일 것이 없기 때문입니다.
WithCulture="false"를 빼면 조용히 사라집니다.이름.무엇.확장자는 MSBuild가 컬처별 리소스를 적는 방식이라,AssignCulture가core.en.json의en을 컬처로 읽고 그 파일을 위성 어셈블리en/tabbit.resources.dll로 빼냅니다. 빌드도 실행도 성공하고 카탈로그만 비어 있습니다 —GetManifestResourceNames가 그 자리를 못 보기 때문입니다. 그것이 곧 빌드가 스스로 로컬라이제이션을 해 버리는 것이고, 이 설계가 배제한 딱 그 방식입니다: 아무도 파일을 열어 볼 수 없는 언어 축. 언어 파일을 추가할 때마다 걸릴 자리이고, 게이트가 「선언된 ID에 영어 텍스트가 없다」로 잡아냅니다.
언어는 recipe가 정하지 않습니다. 같은 recipe를 한국어를 쓰는 사람과 영어를 쓰는 사람이 함께
돌립니다 — 언어는 데이터의 속성이 아니라 읽는 사람의 속성입니다. --messages ko 또는
TABBIT_MESSAGES 환경 변수로 받습니다.
기본값은 CurrentUICulture가 아니라 영어입니다. 환경 로케일을 따라가면 같은 recipe의 CI
로그가 러너마다 달라지고, 로그 diff가 매 실행에 변경을 보여 줍니다 — SortByLocation이
막으려던 것과 같은 종류의 일입니다.
빠진 번역은 조용히 넘어가지 않습니다. 없는 키는 영어로 떨어뜨리고, 그런 키가 하나라도 있으면 실행 끝에 개수를 한 줄로 적습니다. 「영어로 나오도록 정한 것」과 「번역이 빠진 것」이 화면에서 구별되어야 합니다.
6. 다섯 벌이 각각 요구하는 것
언어를 정하고 나서야 보이는 것들이고, 셋은 코드를 고쳐야 합니다.
6.1 한국어의 조사 — 한국어에서만 나옵니다
{Type}을 문장에 넣으면 그 뒤의 조사가 값에 따라 달라집니다 — 「bool은」과
「float는」은 다릅 니다. 앞 글자에 종성이 있는지가 정합니다.
자리표시자에 조사 형태를 붙입니다.
cook.unrecognized-type = "`{Type:은}` 알 수 없는 타입입니다."
값을 넣을 때 마지막 글자를 보고 고릅니다. 영어 식별자로 끝나는 값이 대부분이라 자모가 아니라
로마자와 숫자의 종성 규칙까지 함께 필요합니다 — bool은 종성이 있고 int는 없습니다.
int는 「인트」로 읽혀 트로 끝나므로, 철자의 형태는 답을 말해 주지 않습니다. 로마자는
l·m·n·r만 종성이고, 숫자는 읽는 소리가 정합니다 — 0은 「영」이라 은, 2는 「이」라
는입니다. 나머지 네 벌은 이 표기를 쓰지 않으므로 그쪽에는 비용이 없습니다.
장치를 만들어 놓고 안 쓴 자리가 40곳 나왔습니다. 카탈로그를 쓰면서 조사를 그냥 손으로
박았고, 그 값이 아닌 것에도 문장이 맞게 읽혔습니다 — 제 눈앞에 있던 값만 보았으니까요.
--env qa1은 「일」로 읽혀 은이고 dev·stage·alpha는 는인데, 넷 다 은이었습니다.
이것이 잡힐 수 있는 자리는 하나뿐이고 지금 게이트가 있습니다 — 자리표시자 바로 뒤에 맨 조사가 붙은 항목을 거부합니다. id도 맞고 자리표시자도 맞고 문장도 렌더되므로, 이것만 보는 게이트 없이는 어느 게이트도 보지 못합니다. 한국어에서만 가능한 실수고, 게이트 또한 한국어에만 듭니다.
그 40곳 중 둘은 조사가 아니었습니다. {Count}가지의 가는 명사의 첫 글자이고
{Type}이어야의 이는 계사입니다. 형태로 찾으면 말한 것의 초집합이 여실히 나오므로,
게이트는 조사 뒤에 낱말 경계가 오는 자리만 봅니다. 계사는 짝이 맞으니 표에 넣었습니다 —
bool이어야·float여야.
그리고 자리표시자 게이트가 이걸 하나도 보지 못하고 있었습니다. 그 패턴이 닫는 중괄호에서
멈춰서 {Element:가}에는 아무 이름도 들어가지 않았습니다. 사람이 눈으로 못 하는 그 검사가,
정확하게 가장 지켜봐야 할 자리만 못 보고 있던 것입니다.
6.2 복수 — 영어에서만 나오고, 지금 코드에 박혀 있습니다
한국어·중국어·일본어는 수 일치가 없습니다. 영어만 있고, 그 처리가 지금 메시지를 만드는 코드 안의 조건문으로 들어가 있습니다.
| 자리 | 무엇 |
|---|---|
| CookingContext.cs:777 | notice.Count == 1 ? "cell" : "cells" |
| ModelCooker.Naming.cs:492 | count == 1 ? "1 place" : $"{count} places" |
| RuleFolders.cs:312 | stray.Count == 1 ? "it" : "them" |
| SchemaBaseline.cs:139 | way(s) — 조건문을 피한 대신 문장이 어색해진 자리 |
카탈로그 항목은 조건문을 담을 수 없습니다. 복수형 규칙을 카탈로그에 들이는 대신, 영어 문장을 수에 중립적인 형태로 고칩니다 — 「3 cells」가 아니라 「cells: 3」입니다. 나머지 네 벌이 어차피 그 형태를 요구하므로, 번역이 영어 쪽을 더 단순하게 만듭니다. 넷뿐이라 값이 비용보다 큽니다.
(s) 표기 17곳은 번역을 막지 않습니다 — 그래서 안 고칩니다. 처음에는 이것도 같은 문제로
적어 두었는데, 실제로 막고 있던 것은 코드 안의 조건문이었고 그것은 12곳 전부 ID 둘로
갈라졌습니다. column(s)처럼 써 놓은 형태는 CJK 넷에서 「컬럼 {Count}개」로 그냥 나옵니다 —
수 일치가 없는 언어에 영어의 괄호가 따라갈 이유가 없습니다.
남은 것은 영어 문장이 조금 어색하다는 것뿐이고, 그것은 로컬라이제이션과 무관한 별개의 다듬기입니다. 4단계가 끝난 지금은 언제든 비용 없이 할 수 있으므로(테스트가 ID를 보므로) 급할 이유도 없습니다.
영어의 a/an도 값에 따라 갈립니다 — 6.1의 조사와 같은 종류입니다. 여섯 곳에서
관사가 자리표시자 앞에 옵니다. 그중 다섯은 값 집합이 고정이라({Side}는 client·server·both,
{TableGroup}은 이 도구가 요구하는 이름, {Context}는 단계별 타입 이름) 관사를 한 번
고르는 것이 맞습니다. 열려 있는 것은 {Scheme} 하나였고 — 사용자가 oracle:을 쓰면
「a oracle:」이 됩니다 — 관사를 안 쓰는 문장으로 고쳤습니다.
여기서는 영어만 값을 냅니다. CJK 넷에는 관사가 없으므로 그쪽 문구는 손대지 않았습니다. 그리고 이건 게이트를 만들 수 없습니다 — 자동 검사가 「이 자리표시자의 값 집합이 열려 있는가」를 알 방법이 없으므로, 관사를 자리표시자 앞에 새로 쓸 때 사람이 판단하는 자리로 남습니다.
6.3 인코딩 — 됨
Console.OutputEncoding을 어디에서도 설정하지 않고 있었습니다. 한국어
Windows의 콘솔 기본 코드페이지는 949이고, 거기에는 일본어 가나와 중국어 한자를 표현할 자리가
없습니다 — 물음표로 나옵니다. 로그 파일 쪽도
WriteTo.File에 인코딩이 적혀 있지 않습니다.
둘 다 UTF-8로 고정했고, 이것은 번역을 한 줄도 안 해도 먼저 해야 하는 일이었습니다 — 안 하면 5단계에서 「번역이 틀렸다」와 「콘솔이 못 그린다」가 구별되지 않습니다. BOM은 붙이지 않습니다(리다이렉트한 실행의 첫 줄에 마크가 붙습니다). 바이트로 확인했습니다 — 다섯 벌이 쓰는 문자가 전부 UTF-8로 나갑니다.
6.4 정렬 — 카테고리는 영어로 남깁니다
콘솔 템플릿이 {Category,-10:l}로 고정폭 칸을 씁니다. CJK 글자는
폭이 두 칸이라 그 칸에 번역된 이름이 들어가면 정렬이 어긋납니다. 카테고리는 실행 단계의
이름이고 메시지가 아니므로 번역 대상에서 뺍니다 — 그러면 정렬이 그대로 유지되고, 로그를
훑을 때 눈이 잡는 세로선도 유지됩니다.
7. 테스트 — 비용이자 얻는 것
약 184곳이 메시지 문구에 걸려 있습니다.
Assert.Contains("takes only a kind", failure.Message); // 지금
Assert.Equal("cook.asset-extra-name", failure.MessageId); // 뒤
이 교체는 비용이면서 동시에 이 작업의 가장 큰 이득입니다. 지금은 문구를 다듬는 것이 테스트를 깨뜨리므로 문구가 굳어 있습니다. ID로 바꾸면 문구가 자유로워지고, 동시에 ID를 아직 안 붙인 자리는 테스트가 가리킬 수 없게 되어 마이그레이션 진행도가 테스트로 측정됩니다.
8. 게이트
골든은 움직이지 않습니다. 진단 로그를 담은 골든 픽스처가 없습니다 — 확인했고 하나도 없습니다. 그러니 이 작업에서 골든이 흔들리면 그것 자체가 결함 신호입니다. 반대로 골든이 이 작업을 지켜주지도 않으므로, 카탈로그 게이트가 따로 필요합니다. 셋 다 실행 없이 정적으로 확인됩니다.
| 확인 | 왜 |
|---|---|
| 코드의 모든 ID가 영어 카탈로그에 있는가 | 없으면 그 자리는 실행되어야 발견됩니다 |
| 카탈로그에만 있고 코드에 없는 ID | 죽은 항목입니다. 알려진 문제 목록이 낡지 않게 하는 것과 같은 이유입니다 |
| 자리표시자 집합이 언어 간에 일치하는가 | 번역이 {Type}을 빼먹으면 값이 사라진 문장이 나옵니다. 문구가 아니라 구멍이 달라진 것이라 사람 눈에는 안 보입니다 |
9. 단계
순서에 이유가 있 습니다 — 앞의 셋은 번역을 하나도 안 해도 값을 냅니다.
| 순서 | 무엇 | 얻는 것 |
|---|---|---|
| 0 | 됨. 콘솔과 로그 파일의 인코딩을 UTF-8로 고정 (6.3) | 한 줄짜리이고 안 하면 5단계가 진단 불가능해집니다 — 번역 오류와 콘솔이 못 그리는 것이 같은 형태로 보입니다 |
| 1 | 됨. 예외를 갈라냅니다 — TabbitDefectException, 27곳 | 사용자가 자기 문제와 우리 버그를 구별합니다. 갈라내면서 실재하는 결함 하나가 나왔습니다 — 값 파서가 모르는 값 타입에 맨 Exception을 던지고 자기 catch가 그것을 「이 셀을 고치세요」로 바꾸고 있었습니다. 광범위 catch 6곳에 통과 가드 |
| 2 | 됨. ID 자리와 카탈로그 게이트. 영어 카탈로그만 | 문서가 진단을 이름으로 참조할 수 있습니다. [TabbitMessages] 스캔 + 임베드 JSON + 게이트 7개. 증명으로 cook.role-* 3개를 옮겼고 문구는 한 글자도 안 바뀌었습니다 — 기존 단정이 그대로 통과합니다 |
| 3 | 됨. 호출 지점 이동 — Diagnostics 49곳과 예외 227곳 | ID 285개, 문구는 한 글자도 안 바뀌었습니다. 접두어는 실행 단계 그대로 — cook.·recipe.·validate.·export.·import.·record., 거기에 실행 자체를 두는 run.과 보고의 머리말 하나(report.), 그리고 레이아웃 셋이 자기 id로. 카탈로그 파일은 소유 영역마다 하나입니다. 결 함으로 갈라낸 것이 59곳이고, 그중 32곳은 이 단계에서 영역을 제대로 읽고서야 드러났습니다 (3절). 6.2가 예측한 「메시지 속 조건문」이 12곳에서 실제로 걸렸고, 전부 ID 둘로 갈랐습니다 — asset/asset(kind), 행의 값/원소의 값, 하나/여럿, 근사 후보가 있음/없음, 환경 변수 문단이 붙음/안 붙음. 갈라내면서 조각 접합이 온전한 문장이 됐습니다 |
| 4 | 됨. 테스트 단정을 ID로 | 63개 테스트가 ID를 보고, 문구만 확인하던 단정 44개가 사라졌습니다. 어느 ID인지는 추측하지 않았습니다 — 일부러 틀린 ID를 단정해 실패 메시지에서 실제 ID를 뽑았습니다. 그 과정이 이 작업의 범위를 정정했습니다: 네 파일의 단정은 Tabbit이 아니라 생성된 리더의 TcbException 을 읽고 있었고, 그것은 10절이 범위 밖으로 정한 것입니다 |
| 5 | 됨. 다섯 언어 모두 347개입니다. | 여기서 처음으로 로컬라이제이션이 됩니다. 순서는 계획대로 ko → ja → zh-Hans → zh-Hant였고, 한국어를 먼저 한 것이 맞았습니다 — 조사 장치(6.1)가 없다는 것을 첫 카탈로그에서 알았습니다. 자리표시자에 이름을 준 결정은 세 번 확인됐습니다: 이름 보고의 {Owner}·{Kind}·{Subject}를 한국어·일본어·중국어가 각각 영어에 없는 순서로 놓습니다. 번호였다면 네 벌 중 세 벌이 카탈로그 밖에서 조립돼야 했습니다. 번체는 5절이 적어 둔 그대로 나왔습니다 — 간체를 변환하고 어휘만 손으로. 변환기는 저장소에 있습니다(tools/zh-hant/), 없으면 그 파일들이 어떤 규칙으로 나왔는지 아무도 모르기 때문이고, 다시 돌리면 같은 바이트가 나옵니다 — 손으로 고친 것이 전부 규칙으로 들어갔기 때문입니다. 두 벌로 나뉘는 이유가 있습니다: 글자 표는 文件을 판단할 수 없고(파일을 뜻할 때만 檔案입니다), 어휘 표는 只·里를 담을 수 없습니다(모든 문장이 같이 씁니다). 간체가 두 번체를 겸하는 아홉 글자는 이 카탈로그가 뜻을 정해 주기 때문에만 표에 있습니다 — 흔한 쪽을 고른 것이 아니라 나온 자리를 다 읽었습니다. 변환 뒤 남은 한자를 전부 찍는 것이 감사 전부이고, 표가 끝났다고 생각한 뒤에 丢·余·征·批注를 그것이 잡았습니다. 작업이 테스트 하나를 정정했습니다 — 대체 언어 게이트가 「아직 번역 안 된 id」를 실례로 들고 있어서, 그 항목을 번역하자 게이트가 깨졌습니다. 없는 언어(qq-Fake)를 묻게 고쳤습니다: 테스트가 카탈로그 항목을 못 채우는 이유가 되어선 안 됩니다 |
| 6 | 부분적으로 됨 — 그리고 그 갈래가 이 단계의 결론입니다. 경고·에러 38곳은 ID를 받았고, 진행 서술 123곳은 영어로 남깁니다 | 로그를 열어 보니 구조화 속성을 쓰는 자리가 하나도 없었습니다 — 전부 보간 문자열이라 미리 렌더해도 잃는 것이 없습니다. 그리고 로그가 한 종류가 아니었습니다: **「이걸 고치십시오」와 「지금 이걸 하고 있습니다」**입니다. 앞의 것은 사람이 지금 조치할 것이라 번역하고, 뒤의 것은 실행의 속기록입니다 — 붙여넣어지고, CI에서 검색되고, 실행 간에 diff됩니다. 3절이 결함 보고를 영어로 두는 것과 같은 논거이고, 그것이 123곳을 번역해서 얻는 것보다 큽니다. 결함 보고의 틀(Callstack:· [ 1] …)과 이미 만들어진 보고를 그대로 찍는 중계 13곳도 같은 이유로 그대로입니다 |
3단계에 남는 것은 위치 없는 예외 239곳이고, 그중 22곳쯤은 1단계에서 이미 빠져 있습니다.
10. 하지 않는 것
- 생성 코드와 템플릿의 메시지.
src/templates/와 업데이터의 문자열은 사용자 게임에 박히는 것입니다. 도구를 한국어로 돌렸다고 남의 게임 런타임 메시지가 한국어가 되어서는 안 되고, 그것을 건드리면 골든이 흔들립니다. 그쪽에 언어가 필요해지면 소비자가 정하는 별개의 축입니다. - 도구 자신의 결함 메시지. 3절.
- 기본값을 환경 로케일로 두는 것. 5절.
ToString()의 문화권. 숫자·날짜는 계속InvariantCulture입니다. 메시지의 언어와 값의 표기는 다른 문제이고, 값의 표기가 로케일을 따라가면 생성 산출물이 머신마다 달라집니다.