이 사이트의 모든 페이지는 /ko/ 아니면 /en/ 아래에 있습니다. 로케일이 경로에 박혀 있어서 URL 만 보면 언어가 정해집니다. 공유하기도 색인되기도 좋습니다.

그런데 도메인만 치고 들어오는 사람이 있습니다. 명함에 적힌 주소, 앱 스토어 링크, 그냥 기억나는 대로 친 주소 — ceanlab.com 에는 언어 정보가 없습니다.

1. 루트에 무엇을 둘 것인가

선택지는 셋이었습니다.

A. 루트에 언어 선택 화면을 둔다 → 모든 방문자에게 클릭을 한 번 요구한다 B. 루트를 한 언어의 실제 콘텐츠로 만든다 → /ko/ 와 / 가 같은 내용 = 중복 URL 두 개 C. 루트를 판정해서 보내는 통로로만 쓴다 → 콘텐츠는 없고 라우팅만 한다

C 를 골랐습니다. A 는 열에 아홉이 이미 답이 정해진 질문을 모두에게 묻는 방식이고, B 는 같은 내용이 두 URL 에 있게 되어 둘 중 어느 쪽이 원본인지를 계속 관리해야 합니다.

C 를 고르면 루트는 콘텐츠가 아니라 인프라가 됩니다. 사용자는 루트에 머무르지 않고, 검색 결과에도 루트가 아니라 /ko/ 나 /en/ 이 나옵니다.

2. 무엇을 보고 언어를 정하나

판정에 쓸 수 있는 신호가 몇 가지 있는데, 각각 다른 걸 알려줍니다.

접속 국가 (IP) 지금 어디에 있는가 브라우저 언어 어떤 언어로 기기를 쓰는가 Accept-Language 같은 정보의 HTTP 헤더 버전 직접 선택 (?lang=) 본인이 말해준 것

혼동하기 쉬운데 국가와 언어는 다른 축입니다. 한국에 있는 영어 사용자도, 해외에 있는 한국어 사용자도 흔합니다. 어느 쪽도 단독으로는 정확하지 않습니다.

네 신호를 나란히 놓으면

각 신호는 서로 다른 것을 알려주고, 서로 다른 방식으로 틀립니다. 그리고 결정적으로 알 수 있는 시점이 다릅니다 — 이게 층의 순서를 정합니다.

신호 알려주는 것 틀리는 경우 알 수 있는 시점 x-vercel-ip-country 지금 있는 위치 여행 중 · VPN · 해외 거주 한국인 엣지 (요청 즉시) Accept-Language 기기의 언어 설정 기기를 영어로 쓰는 한국어 사용자 서버 (헤더 도착 시) navigator.language 같은 값의 JS 판 위와 같음 브라우저 (스크립트 실행 후) ?lang= 쿼리 본인이 말한 것 없음 어디서나

표에서 맨 오른쪽 열이 층 순서를 그대로 결정합니다. 국가 헤더는 HTML 을 한 바이트도 보내기 전에 알 수 있고, navigator.language 는 HTML 이 도착하고 스크립트가 돌아야 알 수 있습니다. 정확도만 보면 순서가 반대여야 하는데, 사용자가 보는 화면 기준으로는 빠른 쪽이 먼저여야 합니다.

Accept-Language 를 안 쓰는 이유도 이 표에 있습니다. 국가 헤더와 같은 시점에 알 수 있지만, 이 사이트는 정적 호스팅이라 헤더를 읽어 분기하는 서버 로직이 없습니다. 배포 설정으로 처리할 수 있는 건 국가 헤더 쪽이고, 언어 헤더까지 보려면 엣지 함수를 하나 두어야 합니다 — 로케일 두 개짜리 사이트에는 과합니다.

이 사이트는 엣지에서는 국가를, 브라우저에서는 언어를 봅니다. 순서가 그렇게 된 데는 이유가 있습니다.

1차 — 배포 설정 (엣지에서 즉시) x-vercel-ip-country: KR → /ko/ 그 외 → /en/

엣지 판정의 장점은 페이지를 내려받기 전에 끝난다는 것입니다. 브라우저 언어를 보려면 HTML 이 도착하고 스크립트가 돌아야 하는데, 그 사이 사용자는 빈 화면이나 잠깐 보였다 사라지는 페이지를 봅니다. 국가 판정은 그게 없습니다.

단점은 정확도입니다. 그래서 그 뒤에 층을 더 놓습니다.

빠르지만 덜 정확한 판정을 앞에, 정확하지만 느린 판정을 뒤에 둡니다.

대부분은 첫 층에서 끝나고, 첫 층이 틀렸을 때만 뒤쪽 비용을 냅니다.

3. 그런데 왜 폴백 페이지가 존재하나

여기서 이상한 점이 있습니다. 엣지에서 리다이렉트되면 src/index.html 은 아무에게도 안 보일 텐데 왜 있을까요.

리다이렉트가 적용되지 않는 경우가 있기 때문입니다. 배포 설정이 아직 안 붙은 프리뷰 환경, 국가 헤더가 없는 요청, 파일을 그대로 열어보는 경우 등입니다. 이때 루트는 정적 파일 그 자체로 서빙됩니다.

그래서 이 파일 안에 나머지 층이 다 들어 있습니다.

2차 — 자바스크립트 ?lang=ko / ?lang=en → 명시적 선택이 최우선 navigator.language → ko 로 시작하지 않으면 en 3차 — noscript <meta http-equiv="refresh" content="0;url=/ko/"> 4차 — 사람이 직접 "한국어 메인" · "English Main" 버튼

2차에서 ?lang= 을 가장 먼저 보는 게 중요합니다. 사용자가 직접 말한 것이 어떤 추측보다 우선합니다. 이게 있어야 판정이 틀렸을 때 링크로 우회할 수단이 생깁니다 — 한국에 있는 영어 사용자에게 ceanlab.com/?lang=en 을 줄 수 있습니다.

4차가 가장 안 쓰일 것 같지만 여기가 진짜 바닥입니다. 스크립트가 막혔고 meta refresh 도 안 먹는 환경에서, 사용자는 최소한 클릭할 수 있는 링크 두 개를 봅니다. 자동 판정이 전부 실패해도 막다른 길이 되지 않습니다.

각 층이 실제로 무엇을 받아내는가

"층을 쌓았다" 는 말은 각 층이 앞 층의 특정 실패 조건을 하나씩 담당한다는 뜻이어야 합니다. 그렇지 않으면 그냥 같은 일을 네 번 하는 것입니다.

층 판정 수단 앞 층이 못 받아낸 것 이 층도 실패하는 조건 1차 엣지 국가 헤더 (첫 층) 프리뷰 배포 · 헤더 없는 요청 2차 ?lang= → nav.language 배포 설정이 안 붙은 환경 JS 차단 · 스크립트 오류 3차 meta refresh JS 가 꺼진 브라우저 meta refresh 무시 설정 4차 링크 두 개 위 전부 없음 — 사람이 누른다

세로로 읽으면 각 칸이 아래 칸의 존재 이유가 됩니다. 1차의 실패 조건이 2차의 담당 범위고, 2차의 실패 조건이 3차의 담당 범위입니다. 이렇게 연결되지 않는 층은 넣을 필요가 없습니다.

실무에서 가장 자주 마주치는 건 1차의 실패 조건인 프리뷰 배포입니다. 리다이렉트 규칙이 프로덕션 도메인에만 붙어 있으면, 프리뷰 URL 의 루트는 정적 파일 그대로 서빙됩니다. 이때 2차가 없으면 프리뷰에서만 루트가 빈 페이지가 되고, 대개 배포 직전에야 발견됩니다.

4. 리다이렉트를 캐시시키지 않기

루트 리다이렉트에서 가장 중요한 설정은 영구가 아니라 임시(302) 라는 점입니다.

301 을 쓰면 브라우저가 결과를 기억해서 다음부터 판정 자체가 실행되지 않습니다. 한국에서 한 번 들어온 브라우저는 해외로 나가도, 언어를 바꿔도 계속 /ko/ 로 갑니다. 판정이 매번 달라져야 하는 리다이렉트는 캐시되면 안 됩니다. 이 구분은 URL 이전과 리다이렉트에서 더 자세히 다뤘습니다.

같은 이유로 2차의 자바스크립트도 location.replace() 를 씁니다. href 로 옮기면 방문 기록에 루트가 남아서, 뒤로 가기를 누르면 다시 루트로 왔다가 또 튕겨나갑니다. 사용자 입장에서는 뒤로 가기가 고장 난 것처럼 보입니다.

301 의 진짜 함정은 되돌릴 수 없다는 것

301 (영구) 302 (임시) 브라우저 캐시 무기한 보관 요청마다 재판정 판정 재실행 안 됨 됨 여행 후 재방문 예전 결과로 고정 현재 위치로 갱신 ?lang= 로 우회 루트에 도달조차 못 함 정상 동작 검색엔진 해석 원본이 옮겨갔다 임시 안내다 잘못 내보냈을 때 사이트에서 못 고침 다음 요청부터 바로 반영

마지막 줄이 핵심입니다. 301 을 한 번 내보내면 이미 그걸 받은 브라우저는 서버가 설정을 고쳐도 다시 물어보지 않습니다. 캐시가 만료되거나 사용자가 직접 지우기 전까지, 그 브라우저에서 이 사이트의 루트는 영구히 한 언어로 고정됩니다. 사이트 쪽에서 할 수 있는 게 없습니다.

판정 결과가 요청마다 달라질 수 있는 리다이렉트는 전부 이 성질을 갖습니다. 로케일 라우팅, A/B 테스트, 로그인 여부에 따른 분기가 그렇습니다. 반대로 주소가 실제로 옮겨간 경우는 301 이 맞습니다 — 그쪽 판단 기준은 URL 이전과 리다이렉트에 정리해 두었습니다.

구분하는 질문 하나면 충분합니다. "내일 같은 브라우저가 같은 URL 로 왔을 때 다른 곳으로 보내야 할 수도 있는가." 그렇다면 302 입니다.

5. 아무도 안 보는 페이지의 SEO

루트 페이지는 사용자에게는 거의 안 보이지만 크롤러에게는 보일 수 있습니다. 그래서 이 파일의 <head> 에는 세 가지가 들어 있습니다.

<meta name="robots" content="noindex, follow"> <link rel="canonical" href="https://ceanlab.com/ko/"> <link rel="alternate" hreflang="ko" href="https://ceanlab.com/ko/"> <link rel="alternate" hreflang="en" href="https://ceanlab.com/en/"> <link rel="alternate" hreflang="x-default" href="https://ceanlab.com/ko/">

noindex, follow — 이 URL 자체는 색인하지 말되 링크는 따라가라는 뜻입니다. 루트가 검색 결과에 나오면 사용자가 클릭한 뒤 또 리다이렉트되는 경험이 되고, /ko/ 와 내용이 겹치는 URL 이 하나 더 생깁니다.

canonical 을 그럼에도 적어두는 이유는, noindex 를 무시하거나 아직 못 본 크롤러에게도 "원본은 저기"라고 한 번 더 말해두기 위해서입니다. 신호는 겹쳐도 손해가 없습니다.

x-default 는 hreflang 중에서도 헷갈리는 값인데, "어느 쪽도 딱 맞지 않을 때 여기로" 라는 뜻입니다. 정확히 루트가 하는 일과 같습니다. 다만 이 사이트는 x-default 를 루트가 아니라 /ko/ 로 두었습니다 — noindex 인 URL 을 기본값으로 제시하는 건 앞뒤가 안 맞기 때문입니다.

조합을 바꿔보면 왜 이 조합인지 보인다

루트의 조합 결과 noindex + canonical=/ko/ + x-default=/ko/ 채택. 세 신호가 전부 /ko/ 를 가리켜 일관된다 index + canonical=self + x-default=루트 루트가 검색에 노출 → 클릭 후 또 리다이렉트 noindex + x-default=루트 "기본값" 으로 색인 금지 URL 을 제시 — 모순 canonical 만 (noindex 없이) canonical 은 힌트라 무시될 수 있음 → 루트 색인 아무것도 없음 루트와 /ko/ 가 중복 콘텐츠로 경쟁

두 번째 줄이 실제로 흔한 실수입니다. 루트를 정상 색인시켜 두면 검색 결과에 나온 루트를 클릭한 사용자가 또 한 번 리다이렉트를 겪습니다. 게다가 크롤러 입장에서는 / 와 /ko/ 가 같은 내용을 가진 두 URL 이라, 어느 쪽에 순위를 줄지 스스로 정하게 됩니다.

세 번째 줄은 논리적으로 앞뒤가 안 맞는 조합입니다. x-default 는 "여기가 기본 진입점" 이라는 선언인데, 같은 페이지에 noindex 를 걸어 "여기는 색인하지 마라" 고 하는 셈입니다. 크롤러가 둘 중 무엇을 따를지는 보장되지 않습니다.

이 세 태그를 로케일 페이지 쪽과 항상 짝으로 맞추는 것도 같이 필요합니다. /ko/ 와 /en/ 이 서로를 hreflang 으로 가리키고 있어야 루트의 선언이 의미를 갖습니다. 한쪽 로케일에만 페이지가 있으면 이 짝이 깨지는데, 이 사이트는 그걸 빌드가 막도록 해 두었습니다.

6. 실제로 확인해야 하는 것

이 구조는 로컬에서 검증이 안 되는 부분이 많습니다. 국가 헤더는 배포 환경에서만 붙고, 브라우저 언어는 브라우저 설정을 바꿔야 하고, noscript 는 스크립트를 꺼야 봅니다.

확인 목록: / → 로케일 경로로 갔는가 /?lang=en → 국가와 무관하게 en 인가 뒤로 가기 → 루트로 되돌아왔다 다시 튕기지 않는가 스크립트 차단 → 여전히 이동하거나 링크가 보이는가

세 번째가 가장 자주 깨집니다. 그리고 깨져도 아무 에러가 안 나서 직접 눌러보기 전에는 모릅니다.

확인 절차를 명령으로 고정하면

목록만 있으면 매번 빠뜨리는 항목이 생깁니다. 배포 뒤 한 번씩 도는 순서로 고정합니다.

1. 상태 코드와 목적지 curl -sI https://ceanlab.com/ | grep -iE '^(HTTP|location|cache-control)' 기대: 302 · location 이 로케일 경로 · 캐시 지시가 없거나 짧음 실패 신호: 301 이 보이면 즉시 고친다 (이미 받은 브라우저는 못 고침) 2. 명시 선택이 판정을 이기는가 브라우저에서 /?lang=en → 국가와 무관하게 /en/ 브라우저에서 /?lang=ko → 국가와 무관하게 /ko/ 3. 뒤로 가기 / → 로케일 페이지 이동 → 뒤로 가기 기대: 루트로 돌아왔다가 다시 튕기지 않는다 (방문 기록에 루트가 없다) 4. 스크립트를 끈 상태 개발자 도구에서 JS 비활성화 → 루트 접속 기대: meta refresh 로 이동하거나, 최소한 링크 두 개가 보인다 5. 크롤러가 보는 것 curl -s https://ceanlab.com/ | grep -E 'robots|canonical|hreflang' 기대: noindex · canonical · hreflang 3종이 모두 있고 서로 어긋나지 않는다

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 을 손으로 쓰지 않는 것과 같은 이유입니다.