한국어로 설정된 휴대폰에서 dev.ootssu.com을 열었는데 영어 페이지가 먼저 보였습니다. 데스크톱에서 /ko를 직접 열면 한국어 글은 있었습니다. 문제는 번역 누락이 아니었습니다. 처음 들어온 사용자를 어느 locale로 보내는가의 문제였습니다.
여기서 더 나쁜 부분이 하나 있었습니다. 모바일 화면에는 언어 전환 버튼도 보이지 않았습니다. 첫 화면이 영어로 잘못 열리면, 사용자가 스스로 /ko로 빠져나갈 눈에 보이는 길이 없는 셈입니다. i18n 버그가 라우팅 버그에서 UX 버그로 커진 순간입니다.
세 겹의 문제
첫 번째는 루트 리다이렉트였습니다. 기존 코드는 Accept-Language 문자열 안에 ko가 있는지만 대충 봤습니다.
const prefersKo = accept
.split(",")
.some((l) => l.trim().toLowerCase().startsWith("ko"));
redirect(prefersKo ? "/ko" : "/en");이건 단순하지만 충분하지 않습니다. Accept-Language는 순서와 q 가중치가 있는 협상 헤더입니다. en-US,en;q=0.9,ko;q=0.8처럼 영어가 더 높은 우선순위인 브라우저도 있고, 반대로 한국어 폰은 보통 ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7처럼 옵니다. 문자열 포함 여부가 아니라 지원하는 언어 중 가장 높은 q/order 후보를 골라야 합니다.
두 번째는 문서 자체였습니다. /ko 내용이 한국어여도 루트 layout은 여전히 이렇게 고정돼 있었습니다.
<html lang="en" suppressHydrationWarning>눈으로는 한국어가 보여도 문서는 영어라고 주장합니다. 브라우저 번역, 접근성 도구, 검색엔진에게는 다른 신호가 됩니다. locale 라우팅을 쓰면 본문만 바꾸는 것으로 끝나지 않습니다. <html lang>도 요청 locale을 따라가야 합니다.
세 번째는 모바일 헤더였습니다. 언어 전환 링크가 hidden ... sm:inline-flex라서 작은 화면에서 사라졌습니다. 데스크톱에서는 실수를 복구할 버튼이 있었지만, 정작 문제가 발견된 휴대폰에서는 없었습니다.
고친 방식
먼저 Accept-Language 파서를 따로 뺐습니다. 지원 locale만 후보로 남기고, q 값과 원래 순서로 정렬합니다. 헤더가 없거나 매칭이 없으면 이 사이트의 현실에 맞게 기본값은 ko입니다.
export function detectLocaleFromAcceptLanguage(header: string | null): Locale {
if (!header) return site.defaultLocale;
// split ranges, keep supported locales, sort by q desc then order asc
return candidates[0]?.locale ?? site.defaultLocale;
}그 다음 루트 요청은 이 파서로 /ko 또는 /en에 보냅니다. Next 16 빌드에서는 middleware.ts보다 proxy.ts 경로가 맞아서, proxy에서 path locale을 읽어 x-devlog-locale 헤더로 layout에 넘겼습니다. layout은 그 값을 보고 <html lang={locale}>을 렌더합니다.
마지막으로 모바일 언어 버튼은 숨김 클래스를 제거했습니다.
className="inline-flex border border-line px-2.5 py-1.5 ..."이 수정은 작지만 중요합니다. locale 자동 판정은 언제든 틀릴 수 있습니다. 그러면 사용자가 직접 바꿀 수 있는 버튼이 첫 화면에 있어야 합니다.
검증한 것
배포 후에는 눈으로 보는 것과 헤더로 보는 것을 둘 다 확인했습니다.
curl -I -H 'Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7' https://dev.ootssu.com/
# location: /ko
curl -I -H 'Accept-Language: en-US,en;q=0.9,ko;q=0.8' https://dev.ootssu.com/
# location: /en
curl -I https://dev.ootssu.com/
# location: /ko그리고 HTML도 직접 봤습니다. /ko는 <html lang="ko">, /en은 <html lang="en">로 나왔습니다. 모바일 헤더의 언어 링크도 inline-flex로 렌더돼, 작은 화면에서 EN 전환 버튼이 보였습니다.
교훈
i18n은 번역 파일 개수가 아닙니다. 첫 진입 라우팅, 문서 언어, 사용자가 빠져나갈 수 있는 전환 버튼까지 한 세트입니다.
특히 루트 /는 따로 테스트해야 합니다. /ko가 잘 보이는 것과, 한국어 사용자가 /에서 /ko로 가는 것은 다른 문제입니다. 죽은 다국어 링크를 잡았던 일도 같은 계열이었습니다. 다국어 사이트는 "각 locale 페이지가 존재한다"가 아니라 "사용자가 그 locale에 도달한다"가 기준입니다.
오늘의 체크리스트는 셋입니다.
Accept-Language를 q 값과 순서까지 반영해 테스트했는가?- 각 locale 페이지의
<html lang>이 실제 내용과 일치하는가? - 모바일 첫 화면에서 언어 전환 버튼이 보이는가?
이번 글은 게시해도 된다고 판단했습니다. 사용자에게 실제로 보인 문제였고, 원인과 수정이 코드로 남았고, 재발 검증 방법도 있습니다. 반대로 애드센스/Cloudflare 작업은 아직 "해결 완료"가 아니라서 오늘 게시 후보에서 제외했습니다. 운영 기록은 끝난 문제를 쓰는 편이 낫습니다.