이 사이트의 모든 페이지는 /ko/ 아니면 /en/ 아래에 있습니다. 로케일이 경로에 박혀 있어서 URL 만 보면 언어가 정해집니다. 공유하기도 색인되기도 좋습니다.
그런데 도메인만 치고 들어오는 사람이 있습니다. 명함에 적힌 주소, 앱 스토어 링크, 그냥 기억나는 대로 친 주소 — ceanlab.com 에는 언어 정보가 없습니다.
1. 루트에 무엇을 둘 것인가
선택지는 셋이었습니다.
C 를 골랐습니다. A 는 열에 아홉이 이미 답이 정해진 질문을 모두에게 묻는 방식이고, B 는 같은 내용이 두 URL 에 있게 되어 둘 중 어느 쪽이 원본인지를 계속 관리해야 합니다.
C 를 고르면 루트는 콘텐츠가 아니라 인프라가 됩니다. 사용자는 루트에 머무르지 않고, 검색 결과에도 루트가 아니라 /ko/ 나 /en/ 이 나옵니다.
2. 무엇을 보고 언어를 정하나
판정에 쓸 수 있는 신호가 몇 가지 있는데, 각각 다른 걸 알려줍니다.
혼동하기 쉬운데 국가와 언어는 다른 축입니다. 한국에 있는 영어 사용자도, 해외에 있는 한국어 사용자도 흔합니다. 어느 쪽도 단독으로는 정확하지 않습니다.
네 신호를 나란히 놓으면
각 신호는 서로 다른 것을 알려주고, 서로 다른 방식으로 틀립니다. 그리고 결정적으로 알 수 있는 시점이 다릅니다 — 이게 층의 순서를 정합니다.
표에서 맨 오른쪽 열이 층 순서를 그대로 결정합니다. 국가 헤더는 HTML 을 한 바이트도 보내기 전에 알 수 있고, navigator.language 는 HTML 이 도착하고 스크립트가 돌아야 알 수 있습니다. 정확도만 보면 순서가 반대여야 하는데, 사용자가 보는 화면 기준으로는 빠른 쪽이 먼저여야 합니다.
Accept-Language 를 안 쓰는 이유도 이 표에 있습니다. 국가 헤더와 같은 시점에 알 수 있지만, 이 사이트는 정적 호스팅이라 헤더를 읽어 분기하는 서버 로직이 없습니다. 배포 설정으로 처리할 수 있는 건 국가 헤더 쪽이고, 언어 헤더까지 보려면 엣지 함수를 하나 두어야 합니다 — 로케일 두 개짜리 사이트에는 과합니다.
이 사이트는 엣지에서는 국가를, 브라우저에서는 언어를 봅니다. 순서가 그렇게 된 데는 이유가 있습니다.
엣지 판정의 장점은 페이지를 내려받기 전에 끝난다는 것입니다. 브라우저 언어를 보려면 HTML 이 도착하고 스크립트가 돌아야 하는데, 그 사이 사용자는 빈 화면이나 잠깐 보였다 사라지는 페이지를 봅니다. 국가 판정은 그게 없습니다.
단점은 정확도입니다. 그래서 그 뒤에 층을 더 놓습니다.
대부분은 첫 층에서 끝나고, 첫 층이 틀렸을 때만 뒤쪽 비용을 냅니다.
3. 그런데 왜 폴백 페이지가 존재하나
여기서 이상한 점이 있습니다. 엣지에서 리다이렉트되면 src/index.html 은 아무에게도 안 보일 텐데 왜 있을까요.
리다이렉트가 적용되지 않는 경우가 있기 때문입니다. 배포 설정이 아직 안 붙은 프리뷰 환경, 국가 헤더가 없는 요청, 파일을 그대로 열어보는 경우 등입니다. 이때 루트는 정적 파일 그 자체로 서빙됩니다.
그래서 이 파일 안에 나머지 층이 다 들어 있습니다.
2차에서 ?lang= 을 가장 먼저 보는 게 중요합니다. 사용자가 직접 말한 것이 어떤 추측보다 우선합니다. 이게 있어야 판정이 틀렸을 때 링크로 우회할 수단이 생깁니다 — 한국에 있는 영어 사용자에게 ceanlab.com/?lang=en 을 줄 수 있습니다.
4차가 가장 안 쓰일 것 같지만 여기가 진짜 바닥입니다. 스크립트가 막혔고 meta refresh 도 안 먹는 환경에서, 사용자는 최소한 클릭할 수 있는 링크 두 개를 봅니다. 자동 판정이 전부 실패해도 막다른 길이 되지 않습니다.
각 층이 실제로 무엇을 받아내는가
"층을 쌓았다" 는 말은 각 층이 앞 층의 특정 실패 조건을 하나씩 담당한다는 뜻이어야 합니다. 그렇지 않으면 그냥 같은 일을 네 번 하는 것입니다.
세로로 읽으면 각 칸이 아래 칸의 존재 이유가 됩니다. 1차의 실패 조건이 2차의 담당 범위고, 2차의 실패 조건이 3차의 담당 범위입니다. 이렇게 연결되지 않는 층은 넣을 필요가 없습니다.
실무에서 가장 자주 마주치는 건 1차의 실패 조건인 프리뷰 배포입니다. 리다이렉트 규칙이 프로덕션 도메인에만 붙어 있으면, 프리뷰 URL 의 루트는 정적 파일 그대로 서빙됩니다. 이때 2차가 없으면 프리뷰에서만 루트가 빈 페이지가 되고, 대개 배포 직전에야 발견됩니다.
4. 리다이렉트를 캐시시키지 않기
루트 리다이렉트에서 가장 중요한 설정은 영구가 아니라 임시(302) 라는 점입니다.
301 을 쓰면 브라우저가 결과를 기억해서 다음부터 판정 자체가 실행되지 않습니다. 한국에서 한 번 들어온 브라우저는 해외로 나가도, 언어를 바꿔도 계속 /ko/ 로 갑니다. 판정이 매번 달라져야 하는 리다이렉트는 캐시되면 안 됩니다. 이 구분은 URL 이전과 리다이렉트에서 더 자세히 다뤘습니다.
같은 이유로 2차의 자바스크립트도 location.replace() 를 씁니다. href 로 옮기면 방문 기록에 루트가 남아서, 뒤로 가기를 누르면 다시 루트로 왔다가 또 튕겨나갑니다. 사용자 입장에서는 뒤로 가기가 고장 난 것처럼 보입니다.
301 의 진짜 함정은 되돌릴 수 없다는 것
마지막 줄이 핵심입니다. 301 을 한 번 내보내면 이미 그걸 받은 브라우저는 서버가 설정을 고쳐도 다시 물어보지 않습니다. 캐시가 만료되거나 사용자가 직접 지우기 전까지, 그 브라우저에서 이 사이트의 루트는 영구히 한 언어로 고정됩니다. 사이트 쪽에서 할 수 있는 게 없습니다.
판정 결과가 요청마다 달라질 수 있는 리다이렉트는 전부 이 성질을 갖습니다. 로케일 라우팅, A/B 테스트, 로그인 여부에 따른 분기가 그렇습니다. 반대로 주소가 실제로 옮겨간 경우는 301 이 맞습니다 — 그쪽 판단 기준은 URL 이전과 리다이렉트에 정리해 두었습니다.
구분하는 질문 하나면 충분합니다. "내일 같은 브라우저가 같은 URL 로 왔을 때 다른 곳으로 보내야 할 수도 있는가." 그렇다면 302 입니다.
5. 아무도 안 보는 페이지의 SEO
루트 페이지는 사용자에게는 거의 안 보이지만 크롤러에게는 보일 수 있습니다. 그래서 이 파일의 <head> 에는 세 가지가 들어 있습니다.
noindex, follow — 이 URL 자체는 색인하지 말되 링크는 따라가라는 뜻입니다. 루트가 검색 결과에 나오면 사용자가 클릭한 뒤 또 리다이렉트되는 경험이 되고, /ko/ 와 내용이 겹치는 URL 이 하나 더 생깁니다.
canonical 을 그럼에도 적어두는 이유는, noindex 를 무시하거나 아직 못 본 크롤러에게도 "원본은 저기"라고 한 번 더 말해두기 위해서입니다. 신호는 겹쳐도 손해가 없습니다.
x-default 는 hreflang 중에서도 헷갈리는 값인데, "어느 쪽도 딱 맞지 않을 때 여기로" 라는 뜻입니다. 정확히 루트가 하는 일과 같습니다. 다만 이 사이트는 x-default 를 루트가 아니라 /ko/ 로 두었습니다 — noindex 인 URL 을 기본값으로 제시하는 건 앞뒤가 안 맞기 때문입니다.
조합을 바꿔보면 왜 이 조합인지 보인다
두 번째 줄이 실제로 흔한 실수입니다. 루트를 정상 색인시켜 두면 검색 결과에 나온 루트를 클릭한 사용자가 또 한 번 리다이렉트를 겪습니다. 게다가 크롤러 입장에서는 / 와 /ko/ 가 같은 내용을 가진 두 URL 이라, 어느 쪽에 순위를 줄지 스스로 정하게 됩니다.
세 번째 줄은 논리적으로 앞뒤가 안 맞는 조합입니다. x-default 는 "여기가 기본 진입점" 이라는 선언인데, 같은 페이지에 noindex 를 걸어 "여기는 색인하지 마라" 고 하는 셈입니다. 크롤러가 둘 중 무엇을 따를지는 보장되지 않습니다.
이 세 태그를 로케일 페이지 쪽과 항상 짝으로 맞추는 것도 같이 필요합니다. /ko/ 와 /en/ 이 서로를 hreflang 으로 가리키고 있어야 루트의 선언이 의미를 갖습니다. 한쪽 로케일에만 페이지가 있으면 이 짝이 깨지는데, 이 사이트는 그걸 빌드가 막도록 해 두었습니다.
6. 실제로 확인해야 하는 것
이 구조는 로컬에서 검증이 안 되는 부분이 많습니다. 국가 헤더는 배포 환경에서만 붙고, 브라우저 언어는 브라우저 설정을 바꿔야 하고, noscript 는 스크립트를 꺼야 봅니다.
세 번째가 가장 자주 깨집니다. 그리고 깨져도 아무 에러가 안 나서 직접 눌러보기 전에는 모릅니다.
확인 절차를 명령으로 고정하면
목록만 있으면 매번 빠뜨리는 항목이 생깁니다. 배포 뒤 한 번씩 도는 순서로 고정합니다.
1번과 5번은 자동화할 수 있고, 2·3·4번은 사람이 브라우저에서 해야 합니다. 특히 4번은 실제 사용자 중 비중이 작다는 이유로 계속 미뤄지는데, 확인에 걸리는 시간은 30초이고 깨진 걸 모르고 지나가는 기간은 몇 달입니다.
이 검증이 앱 딥링크 폴백과 성격이 같습니다. 둘 다 "정상 경로가 실패했을 때" 만 실행되는 코드라, 평소에는 멀쩡해 보이고 정작 필요한 순간에 깨져 있습니다. 이런 코드는 의도적으로 실패를 만들어서 확인하는 것 말고는 방법이 없습니다.
7. 정리
- 루트는 콘텐츠가 아니라 통로로 둔다. 실제 페이지를 겹쳐두면 중복 URL 관리가 따라옵니다.
- 국가와 언어는 다른 신호다. 어느 쪽도 단독으로는 정확하지 않습니다.
- 빠른 판정을 앞에, 정확한 판정을 뒤에. 대부분은 첫 층에서 끝납니다.
- 사용자가 직접 말한 것이 모든 추측보다 우선한다.
?lang=이 그 통로입니다. - 루트 리다이렉트는 캐시시키지 않는다. 302 와
location.replace(). - 안 보이는 페이지에도 canonical 과 noindex 를 남긴다. 크롤러는 볼 수 있습니다.
- 각 층은 앞 층의 실패 조건 하나를 담당해야 한다. 그렇지 않은 층은 같은 일을 반복할 뿐입니다.
- 301 은 되돌릴 수 없다. 잘못 내보내면 이미 받은 브라우저는 사이트에서 고칠 방법이 없습니다.
로케일 판정은 맞히는 문제가 아니라 틀렸을 때를 설계하는 문제였습니다. 어떤 신호를 써도 일정 비율은 틀립니다. 중요한 건 틀린 사람이 한 번의 클릭으로 원하는 곳에 갈 수 있는가이고, 그건 판정 정확도와는 완전히 다른 작업입니다.
자주 묻는 질문
Vercel 이 아닌 호스팅에서는 어떻게 하나요?
국가 헤더의 이름만 다르고 구조는 같습니다. Cloudflare 는 CF-IPCountry, Netlify 는 엣지 함수의 context.geo.country.code, AWS CloudFront 는 CloudFront-Viewer-Country 를 줍니다. 헤더를 아예 안 주는 정적 호스팅(GitHub Pages 등)이라면 1차 층을 통째로 빼고 2차부터 시작하면 됩니다. 그 경우 루트에서 한 번 깜빡임이 생기지만 나머지 층은 그대로 동작합니다. 구조가 층으로 나뉘어 있으면 이런 이식이 쉬워집니다.
국가 대신 Accept-Language 를 엣지에서 보면 더 정확하지 않나요?
더 정확합니다. 다만 정적 호스팅의 선언형 리다이렉트 규칙으로는 대개 국가 헤더까지만 분기되고, 언어 헤더를 파싱하려면 엣지 함수를 하나 두어야 합니다. 그러면 정적 사이트에 서버 코드가 하나 생깁니다 — 배포 대상이 늘고, 로그를 봐야 할 곳이 늘고, 콜드 스타트를 신경 쓰게 됩니다. 로케일이 둘뿐이고 2차 층이 어차피 navigator.language 를 보고 있다면, 그 비용을 낼 이유가 크지 않습니다. 로케일이 네댓 개로 늘면 계산이 달라집니다.
루트를 /ko/ 로 두고 영어만 따로 빼면 안 되나요?
동작은 합니다. 대신 두 언어가 대칭이 아니게 됩니다. 한국어는 / 와 /ko/ 두 주소를 갖고 영어는 하나만 갖게 되는데, 그러면 canonical 을 어느 쪽으로 둘지, 공유 링크는 무엇으로 만들지, 언어 전환 버튼은 어디를 가리킬지를 언어마다 다르게 처리해야 합니다. 로케일을 나중에 하나 더 추가할 때 이 비대칭이 그대로 비용이 됩니다. 처음부터 모든 언어를 같은 모양으로 두는 게 더 쌉니다.
302 면 매번 리다이렉트가 도는데 느리지 않나요?
체감될 만큼은 아닙니다. 이 판정은 엣지에서 헤더만 보고 끝나서 원본 서버까지 가지 않고, 실제로는 수 ms 수준입니다. 게다가 이 비용을 내는 건 루트로 들어온 요청뿐입니다. 검색·공유·북마크로 들어오는 대부분의 트래픽은 이미 /ko/ 나 /en/ 를 직접 가리켜서 리다이렉트를 아예 거치지 않습니다. 루트는 첫 방문의 통로이지 상시 경로가 아닙니다.
선택한 언어를 쿠키에 저장하면 더 낫지 않나요?
사용자 경험만 보면 낫습니다. 대신 정적 사이트가 아니게 됩니다. 쿠키를 읽어 분기하려면 응답이 요청마다 달라지고, 그러면 CDN 캐시를 그대로 쓸 수 없습니다. 캐시 키에 쿠키를 넣거나 엣지 함수를 두는 쪽으로 가야 하고, 그때부터는 "정적 파일 배포" 가 아니라 "요청마다 계산하는 사이트" 입니다. 이 사이트는 그 교환을 하지 않는 대신, ?lang= 링크를 기억해 두면 되는 형태로 남겼습니다. 언어 전환 버튼이 항상 보이는 것도 같은 이유입니다.
로케일이 3개 이상으로 늘어도 이 구조가 유지되나요?
층 구조는 그대로 갑니다. 바뀌는 건 각 층의 분기가 이분법에서 목록 조회로 변하는 것뿐입니다. 다만 두 가지가 같이 늘어납니다 — 배포 설정의 국가→로케일 매핑 표가 길어지고, hreflang 태그가 모든 페이지에서 n개씩 붙습니다. 후자가 더 성가신 쪽인데, 태그를 페이지마다 손으로 쓰지 않고 레이아웃 한 곳에서 로케일 목록을 돌며 생성하게 해 두면 로케일 추가가 배열에 한 줄 넣는 일이 됩니다. sitemap 을 손으로 쓰지 않는 것과 같은 이유입니다.