58.1 앱은 사용자 언어를 어떻게 알아채요?

국제화를 깔았다면 다음 질문은 이거예요. "이 사용자한테 어떤 언어를 보여줄까?" 들어올 때마다 "당신 언어는 뭐예요?" 하고 묻는 건 촌스럽잖아요. 좋은 앱은 처음 들어온 순간 알아서 그 사람 언어로 맞춰줘요.


단서는 세 곳에서 와요. 브라우저가 알려주는 값, 요청에 딸려 오는 Accept-Language 신호, 그리고 사용자가 직접 고른 값이죠. 이 셋을 어떻게 읽고 지원 언어와 짝지어 주는지 풀게요.

58.2 브라우저는 뭘로 언어를 알려줘요?

브라우저 안에서 돌아가는 코드라면 navigator.language부터 봐요. 사용자가 브라우저나 운영체제에 설정해둔 언어를 담고 있어요. 한국어 환경이면 대개 이런 값이 나와요.


상황: 브라우저에 설정된 언어를 읽어요. (브라우저 콘솔에서 동작)
navigator.language;
// 예: "ko-KR"
navigator.languages;
// 예: ["ko-KR", "ko", "en-US", "en"]


navigator.language가장 선호하는 언어 하나예요. 저는 실무에서 navigator.languages(복수형, 뒤에 s)를 더 좋아해요. 사용자가 선호하는 순서대로 나열한 목록이라, 첫째를 못 맞추면 둘째, 셋째로 물러설 수 있어 유연하거든요.


다만 이 값들은 브라우저 안에서만 읽혀요. 서버는 navigator가 없거든요. 게다가 화면이 그려진 뒤 자바스크립트로 언어를 바꾸면 영어 화면이 한국어로 바뀌는 깜빡임이 보이니, 첫 화면부터 제 언어로 주려면 서버 쪽 신호가 필요해요.

58.3 서버는 Accept-Language를 어떻게 읽어요?

브라우저는 서버에 요청을 보낼 때마다 Accept-Language라는 헤더(요청에 딸려 가는 꼬리표)를 함께 보내요. 값이 이렇게 생겼어요. ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7


여기서 q는 quality(선호도)예요. 0에서 1 사이 숫자로 높을수록 더 원하는 언어고, 없으면 1로 봐요. 이 사용자는 ko-KR을 제일 원하고 안 되면 ko, 그다음 en 순서죠. 이걸 파싱해서 선호 순으로 정렬하는 코드를 돌려봤어요.


상황: 헤더를 잘라 q 순으로 정렬해요.
function parseAcceptLanguage(header) {
return header.split(",").map(part => {
const [tag, q] = part.trim().split(";q=");
return { tag: tag.trim(), q: q ? parseFloat(q) : 1 };
}).sort((a, b) => b.q - a.q);
}
parseAcceptLanguage("ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7");


결과:
[ { tag: "ko-KR", q: 1 },
{ tag: "ko", q: 0.9 },
{ tag: "en-US", q: 0.8 },
{ tag: "en", q: 0.7 } ]


이제 서버가 사용자가 뭘 원하는지 순서대로 알게 됐어요. 첫 화면을 그리기 전에 언어를 정하니 깜빡임도 없고, 이 헤더는 브라우저가 자동으로 채워줘요.

58.4 지원하는 언어랑 어떻게 맞춰요?

여기서 초보들이 자주 실수해요. 사용자가 ko-KR을 원한다고 우리 사전에 딱 ko-KR이 있어야 하는 건 아니에요. ko 하나로 한국어를 다 처리할 수도 있잖아요. 그래서 원하는 언어와 우리가 실제 지원하는 언어를 짝지어주는 협상(negotiation)이 필요해요.


핵심은 단계적 후퇴예요. ko-KR을 못 맞추면 지역 꼬리표를 떼고 ko로 넓혀 찾고, 그래도 없으면 기본 언어로 떨어져요.


상황: 원하는 순서대로 훑되, 지역이 안 맞으면 기본 언어로 넓혀 찾아요.
function negotiate(header, supported) {
const wanted = parseAcceptLanguage(header).map(x => x.tag);
for (const w of wanted) {
const base = w.split("-")[0];
const hit = supported.find(s => s === w || s.split("-")[0] === base);
if (hit) return hit;
}
return supported[0]; // 아무것도 못 맞추면 기본값
}
negotiate("ko-KR,ko;q=0.9,en-US;q=0.8", ["en", "ko", "ja"]);
negotiate("fr-FR,fr;q=0.9", ["en", "ko", "ja"]);


결과:
"ko" // ko-KR은 없지만 ko로 넓혀서 맞춤
"en" // 프랑스어는 지원 목록에 없어 기본값으로 떨어짐


후퇴 사슬이 안전망이에요. 세상 모든 언어를 지원할 순 없으니 못 맞추면 뭘 보여줄지를 반드시 정해둬야 해요. 저는 기본값을 대개 영어로 둬요. 그나마 가장 많은 사람이 읽으니까요.

58.5 사용자가 직접 고른 언어는 어떻게 기억해요?

자동 감지가 똑똑해도 틀릴 때가 있어요. 해외에 사는 한국인이 영어 노트북을 쓰면 브라우저는 en을 보내지만, 그 사람은 한국어를 보고 싶을 수 있죠. 그래서 언어 선택 메뉴는 꼭 있어야 하고, 한 번 고르면 기억해줘야 해요.


상황: 사용자가 고른 언어를 저장해두고, 다음에 먼저 확인해요.
function saveLang(lang) {
localStorage.setItem("lang", lang); // 브라우저에 저장
}
function pickLang(supported, acceptHeader) {
const saved = localStorage.getItem("lang");
if (saved) return saved; // 1순위: 직접 고른 값
return negotiate(acceptHeader, supported); // 2순위: 자동 감지
}


우선순위가 중요해요. 사용자가 직접 고른 값이 언제나 1순위, 그다음이 자동 감지죠. 이 순서를 뒤집으면 한국어로 바꿔놨는데 새로고침하면 영어로 돌아가는 짜증나는 버그가 나요. 저장은 localStorage(브라우저에 남는 저장소)나 쿠키를 쓰는데, localStorage는 서버가 못 읽으니 서버도 언어를 정하려면 쿠키가 편해요.

58.6 언어 코드는 왜 이렇게 복잡해요?

ko, ko-KR, en-US... 이 코드들엔 BCP 47(언어 태그 국제 표준)이라는 규칙이 있어요. 대충 언어-지역 꼴이에요. 앞은 언어(ko는 한국어), 뒤는 지역(KR은 한국)이죠. en-US와 en-GB처럼 같은 언어라도 지역이 다르면 날짜나 철자가 달라져서 나눠요.


문제는 사용자나 옛 데이터가 대소문자를 뒤죽박죽으로 보낼 때예요. EN-us, Ko-kr 같은 것들요. 표준 꼴로 정리해주는 Intl.getCanonicalLocales, 태그를 부품별로 뜯는 Intl.Locale을 돌려봤어요.


상황: 지저분한 태그를 표준 꼴로 정리하고, 부품을 뜯어봐요.
Intl.getCanonicalLocales(["EN-us", "Ko-kr"]);
const loc = new Intl.Locale("ko-KR");
loc.language;
loc.region;


결과:
["en-US", "ko-KR"] // 대소문자 규칙대로 정리됨
"ko" // 언어 부분
"KR" // 지역 부분


보면 언어는 소문자, 지역은 대문자가 표준이에요. 사용자 입력을 그대로 믿지 말고 이렇게 한 번 정규화해두면, "en-US랑 EN-us가 다른 언어로 처리돼요" 같은 황당한 버그를 미리 막아요.

58.7 오늘 내용을 정리해볼게요

사용자 언어를 알아내고 고르는 흐름을 훑었어요.


신호는 세 곳에서 와요. 브라우저의 navigator.language / navigator.languages, 요청에 딸려 오는 Accept-Language 헤더(q 값으로 선호 순서 표시), 그리고 사용자가 직접 고른 값이죠. 원하는 언어와 우리가 지원하는 언어는 협상으로 짝짓되, 지역이 안 맞으면 기본 언어로 넓혀 후퇴하게 안전망을 깔았어요.


우선순위는 직접 고른 값이 1순위, 자동 감지가 2순위예요. 이 순서만 지켜도 "바꿨는데 새로고침하면 돌아가요" 민원이 사라져요. 언어 코드는 Intl.getCanonicalLocales로 정규화해, 사소한 대소문자 차이가 사고로 번지지 않게 다듬으세요.