홈페이지 다국어 처리 검수 9가지 — 언어팩 키 누락·번역 누락 잡기
홈페이지 다국어 처리는 번역문을 언어팩 파일에 넣었다고 끝나지 않는다. 키가 한쪽에만 있으면 영어판 버튼이 빈 채로 남고, 자리표시자가 어긋나면 이름이 빠진 인사말이 나간다. 여기 아홉 가지 검사를 브라우저 콘솔에 붙여 넣으면 코드 몰라도 3분 검수가 끝난다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 언어팩 두 파일 뼈대
- 02 한쪽 언어에만 있는 키
- 03 {이름} 자리표시자 불일치
- 04 번역 안 된 한글 잔존
- 05 언어팩을 붙이는 t() 함수
- 06 언어팩을 안 타는 하드코딩 글자
- 07 없는 키가 화면에 그대로 노출
- 08 언어별 주소와 hreflang
- 09 날짜·숫자 형식이 언어마다 다를 때
아홉 개는 실제로 두 번째 언어를 붙일 때 겪는 순서대로 놓았다. 언어팩 두 파일의 뼈대(01)를 갖추고 한쪽에만 있는 키(02)를 잡은 다음, 문장 안을 들여다보는 자리표시자 불일치(03)와 번역 안 된 한글 잔존(04)을 확인한다. 화면과 언어팩을 잇는 t() 함수(05)를 살피고 언어팩을 아예 안 타는 하드코딩 글자(06)와 없는 키의 노출(07)까지 막으면, 마지막으로 바깥 세계를 본다 — 언어별 주소와 hreflang(08), 그리고 날짜·숫자 형식(09)이다.
01언어팩 두 파일 뼈대
ko.json 과 en.json 이 같은 층으로 펼쳐지며 서로 짝을 맞추고, 한쪽에만 있는 키는 자리가 빈 채로 남아 곧바로 눈에 잡히는 데모다. 다국어 사이트를 처음 세팅하거나 번역 파일을 새로 추가할 때 제일 먼저 돌려보는 검사다.
const flatten = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object"
? flatten(v, p ? `${p}.${k}` : k)
: [`${p ? p + "." : ""}${k}`]
);
const load = async (l) =>
flatten(await (await fetch(`/locales/${l}.json`)).json());
const ko = await load("ko");
const en = await load("en");
console.log(ko.length, en.length); // 줄 수가 다르면 뼈대부터 확인
02한쪽 언어에만 있는 키
두 열의 키 칸이 순서대로 불이 들어오듯 채워지다가, 없는 쪽 칸은 그대로 어두운 채 붉은 결함 표시가 뜬다. 영어판에서만 버튼이 비어 있거나 새 키를 한국어에만 추가했을 때 이 검사가 답을 준다.
const onlyIn = (a, b, label) => {
const set = new Set(b);
return a.filter((k) => !set.has(k)).map((k) => `${label}: ${k}`);
};
const gaps = [
...onlyIn(ko, en, "ko에만"),
...onlyIn(en, ko, "en에만"),
];
console.log(gaps);
// → ["ko에만: nav.mypage"] 처럼 한쪽에만 있는 키가 줄로 나온다
03{이름} 자리표시자 불일치
문장 속 {name} 칩이 형광펜처럼 강조되며 언어 사이에 같은 칩이 있는지 훑고, 빠진 문장에는 경고 배지가 달라붙는다. 인사말에서 이름이 안 들어가는 언어가 있거나 수량·날짜 변수가 그대로 튀어나올 때 쓴다.
const ph = (s) => [...String(s).matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
const sameSet = (a, b) =>
a.length === b.length && a.every((x) => b.includes(x));
const koOrder = "{name}님, 주문이 {count}건 접수되었습니다";
const enOrder = "{count} orders placed for {name}";
console.log(sameSet(ph(koOrder), ph(enOrder))); // true
const koRefund = "{date}에 환불이 완료되었습니다";
const enRefund = "Refund completed";
console.log(sameSet(ph(koRefund), ph(enRefund))); // false: {date} 누락
04번역 안 된 한글 잔존
스캔 라인이 영어 문장 줄을 위에서 아래로 훑으며 한글 음절만 붉게 물들이고 잔존 개수를 센다. 기계번역을 돌리고 나서, 영어판에서 한국어가 그대로 보일 때 걸리는 문제다.
const hangul = /[가-힣]/;
const enJson = await (await fetch("/locales/en.json")).json();
const residue = Object.entries(enJson).filter(
([k, v]) => typeof v === "string" && hangul.test(v)
);
console.log(residue);
// → [["shipping", "배송이 시작되었습니다."], ["refund", "환불이 처리되었습니다."]]
05언어팩을 붙이는 t() 함수
키 카드가 t() 상자를 지나 현재 언어의 문구로 바뀌어 나오는 흐름을 보여준다. 언어팩 파일을 화면에 연결할 때, 키 오타로 빈 문구가 나올 때 이 t() 함수가 기준이 된다.
const get = async (l) => await (await fetch(`/locales/${l}.json`)).json();
const packs = { ko: await get("ko"), en: await get("en"), ja: await get("ja") };
let lang = "ko";
function t(key, vars = {}) {
let s = packs[lang][key] ?? packs.ko[key] ?? key;
for (const [k, v] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, v);
}
return s;
}
console.log(t("cta.start")); // 시작하기
console.log(t("cta.startX")); // cta.startX 그대로 → 오타 신호
06언어팩을 안 타는 하드코딩 글자
코드 줄을 스캔 라인이 차례로 지나가는데, 태그 사이에 박힌 하드코딩 한글 줄만 붉게 잡혀 굳는다. 언어를 바꿔도 안 바뀌는 글자가 남을 때, AI 가 넣어준 안내문을 점검할 때 돌린다.
// 태그 사이에 박힌 한글 = 언어팩을 안 탄 글자
const between = />\s*[가-힣][^<]*</g;
const hits = [...new Set(
(document.body.innerHTML.match(between) || [])
.map((s) => s.slice(1, -1).trim())
)];
console.log(hits);
// → ["구매하기", "자주 묻는 질문", "배송 안내"]
07없는 키가 화면에 그대로 노출
화면 카드에 welcome.title 원시 키가 노출되었다가 폴백 문구로 교체되길 반복해 두 상태를 나란히 보여준다. 버튼 자리에 header.title 같은 영문이 그대로 보일 때, 폴백 언어 규칙을 정할 때 쓴다.
const keyShape = /^[a-z0-9]+(\.[a-z0-9]+)+$/;
const leaks = [...document.querySelectorAll("button, a, h2, p, span")]
.filter((el) => el.children.length === 0)
.map((el) => el.textContent.trim())
.filter((s) => keyShape.test(s));
console.log(leaks); // → ["welcome.title"] 처럼 키 모양 그대로 노출된 글자
08언어별 주소와 hreflang
/, /en/, /ja/ 세 주소 사이를 링크 신호가 순회하다가 끊긴 방향에서 붉은 물음표가 뜬다. 구글에 영어판만 색인되거나 언어 탭을 눌렀는데 404 가 뜰 때 이 페이지의 link 태그를 점검한다.
const want = ["ko", "en", "ja"];
const have = new Set(
[...document.querySelectorAll('link[rel="alternate"][hreflang]')]
.map((t) => t.getAttribute("hreflang"))
);
const missing = want.filter((l) => !have.has(l));
console.log(missing); // → ["ja"] 면 ja 로 건너갈 다리가 이 페이지에 없다
09날짜·숫자 형식이 언어마다 다를 때
같은 날짜 카드가 ko·en·ja 서식으로 플립처럼 뒤집히며 세 가지 표기를 차례로 보여준다. 주문내역의 날짜가 이상하게 보이거나 가격의 쉼표 자리가 어긋날 때 쓴다. 서식을 손으로 짜지 말고 Intl 에 맡기면 끝난다.
const d = new Date(2026, 8, 11); // 9월 11일 (달은 0부터 센다)
const price = 1234567;
for (const lang of ["ko", "en", "ja"]) {
const date = new Intl.DateTimeFormat(lang, { dateStyle: "long" }).format(d);
const num = new Intl.NumberFormat(lang).format(price);
console.log(lang, date, num);
}
어디서 깨지는가 — 점 찍힌 영어는 다 키로 보인다
07번 검사를 이 사이트에 직접 돌렸을 때 있었던 일이다. 화면에 노출된 원시 키는 잡아냈지만, 링크 글자가 그냥 "godrichstory.com" 인 부분도 같이 걸렸다. 점으로 이어진 소문자라는 키의 생김새가 도메인과 완전히 같기 때문이다. 키 모양만으로 걸면 이런 오탐이 반드시 섞인다. 해법은 걸린 문구를 언어팩 키 목록과 다시 대조하는 것 — 02번의 flatten 결과에 있는 이름이면 진짜 누수이고, 없으면 도메인이나 버전 표기처럼 원래 점 찍힌 영문이다. zip 을 열 때 묻는 비밀번호는 fnn2hd5e 인데, 화면에 보이는 대로 그대로 입력하면 아홉 항목 폴더가 한 번에 풀린다. 08번에도 짝이 있는 함정이 있다 — hreflang 태그가 페이지마다 있는지만 보면 놓치는 게 언어별 주소가 서로를 가리키는지다. 이 페이지에서 ja 로 건너갈 수 있어도 ja 페이지에서 돌아오는 링크가 없으면 검색엔진은 그 관계를 쌍으로 보지 않는다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce 에서 animation: none 으로 떨어지되, 멈춘 자리에서도 뜻이 읽히도록 정지 값을 정해 뒀다. 01·02·04·06번은 스캔·채우기 움직임만 멈추고 빈 칸과 붉은 잔존·배지가 그대로 남아 무엇이 문제인지 보인다. 07번은 폴백 문구가 적용된 상태로 멈추고, 08번은 끊긴 호와 물음표가 보이는 상태로 멈춘다. 09번은 ko 서식 카드를 정지 화면으로 보여준다. 이 설정의 구조는 MDN 의 prefers-reduced-motion 문서 에 정리돼 있다.
FAQ
이 검사들은 코드를 몰라도 쓸 수 있나요?
네, 아홉 개 전부 브라우저의 개발자 도구 콘솔에 그대로 붙여 넣으면 결과가 바로 찍힙니다. zip 안에는 이 검사들을 한 파일로 묶은 스크립트도 넣어 두어서, 사이트 주소만 바꾸면 됩니다. 이 사이트가 데모와 스크립트를 어떻게 검증하는지는 소개 페이지 에 적어 두었습니다.
검사를 돌리면 사이트가 바뀌거나 느려지나요?
아니요, 전부 화면과 파일을 읽기만 합니다. 값을 고치거나 서버로 보내는 명령은 없습니다. fetch 로 언어팩 JSON 을 가져오는 부분도 브라우저가 이미 받아 둔 파일을 다시 읽는 수준이라 체감 속도는 그대로입니다.
React 로 만든 사이트인데도 그대로 되나요?
됩니다. 01~04번은 언어팩 JSON 파일만 보므로 화면을 무엇으로 만들었는지와 무관합니다. 05번의 t() 는 바닐라 예시이고 react-i18next 같은 라이브러리를 쓴다면 그 함수 기준으로 같은 생각을 적용하면 됩니다. 더 많은 검수 글은 사이트 검색 에서 "다국어"로 찾아볼 수 있습니다.