BinaryHub 개발기 ⑦ 도메인 이전 — 서브도메인을 path prefix로 옮기기
BinaryHub 개발기. 옛 주소도 살리되, 새 주소를 자연스럽게 권하는 법.
왜 옮기는가
④ 글에서 StudyAce를 study.binaryhub.club 서브도메인에 띄웠다고 적었다. 한동안은 그걸로 충분했다.
그런데 시간이 지나면서 거슬리는 게 생겼다.
하나는 우산 브랜드의 무게중심 문제다. 친구들이 가장 많이 들어가는 사이트는 StudyAce인데, 거기서 쌓이는 신뢰·체류 시간·검색 신호는 전부 study.binaryhub.club에 쌓인다. 루트 도메인 binaryhub.club은 포털 인덱스 한 페이지뿐이라 같은 우산이어도 신호가 따로 논다. AdSense 측면에서도, 루트 도메인의 평판이 곧 우산 전체에 inherit되는 구조라 신호를 한 곳에 모으는 게 유리하다.
다른 하나는 사용자 입장에서의 인지 부담이다. binaryhub.club과 study.binaryhub.club이 같은 우산이라는 걸 모르는 사람이 더 많다. 그냥 별개 사이트로 보인다.
그래서 결론은 — StudyAce를 binaryhub.club/studyace 아래로 끌고 들어온다.
같은 Worker 한 벌이 두 호스트를 받게
가장 단순한 방법은 옛 도메인을 죽이고 새 도메인으로 301 리다이렉트하는 것이다. 하지만 친구들 북마크가 옛 주소에 잡혀 있고, 검색 결과에도 옛 주소가 인덱싱돼 있다. 한 번에 끊으면 둘 다 깨진다.
그래서 두 호스트를 동시에 받는 구조로 갔다. 같은 Cloudflare Worker 한 벌이 두 라우팅을 모두 받는다.
study.binaryhub.club/*— 옛 Custom Domainbinaryhub.club/studyace/*— 새 Worker Route (wrangler.jsonc에 추가)
같은 자산, 같은 D1, 같은 API. 차이는 path뿐이다.
post-build wrapper로 prefix 처리
새 주소의 path가 binaryhub.club/studyace/login.html처럼 들어오면, Worker는 그걸 내부적으로는 /login.html로 봐야 한다. 내부 라우팅 코드를 전부 prefix 인식하게 고치는 건 침투적이라, 빌드 산출물을 한 번 더 감싸기로 했다.
// scripts/wrap-studyace-prefix.mjs
// dist/worker/index.js 의 default export를 감싼다
const wrapped = {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith("/studyace/")) {
url.pathname = url.pathname.slice("/studyace".length);
request = new Request(url.toString(), request);
}
return innerWorker.fetch(request, env, ctx);
}
};내부 코드는 prefix가 있는지 모른다. 그냥 /login.html 요청을 받았다고 생각한다. 정적 자산도, API도, 다 같은 path로 동작한다.
클라이언트 쪽도 같은 트릭을 쓴다. HTML 안에 inline으로 한 줄 박는다.
var API_BASE = location.pathname.startsWith('/studyace') ? '/studyace' : '';그리고 모든 fetch는 fetch(API_BASE + '/api/...') 패턴을 따른다. 두 호스트에서 같은 빌드 결과가 작동한다.
함정 하나 — ASSETS와 Worker의 라우팅 순서
Cloudflare Workers + Static Assets는 정적 자산이 있는 path를 Worker보다 먼저 처리한다. 즉 /login.html처럼 dist에 있는 파일은 wrapper의 fetch가 호출되기 전에 자산 binding이 가로채서 응답한다.
이게 한 번 발목을 잡았다. 옛 주소에 들어온 사용자를 wrapper에서 302 리다이렉트로 인터스티셜로 보내려고 했는데, .html 요청이 wrapper에 도달하지 않아 그대로 옛 페이지가 떴다.
해결은 두 가지 동시 — 서버 쪽 wrapper에 라우팅 분기를 두되, ASSETS가 먼저 잡는 경우를 대비해 각 HTML의 <head>에 클라이언트사이드 호스트 체크 스크립트도 박았다.
<script>
if (location.hostname === 'study.binaryhub.club' &&
!new URLSearchParams(location.search).has('_skip')) {
location.replace('/_choose-host.html?next=' +
encodeURIComponent(location.pathname + location.search));
}
</script>서버 분기가 안 잡아도 클라이언트가 잡고, 클라이언트가 무력화돼도 서버 분기가 잡는다. 이중 안전망이다.
옛 주소도 죽이지 않는다 — 매번 안내 인터스티셜
옛 주소로 들어온 사용자를 자동으로 새 주소로 던지는 건 위험하다. 친구들이 옛 주소 그대로 쓰고 싶을 수도 있고, fetch가 진행 중인 세션이 깨질 수도 있다.
그래서 인터스티셜 페이지를 만들었다. 큰 버튼으로 새 주소를 권하고, 작은 sub-link로 "이번엔 기존 주소로 계속"을 둔다.
StudyAce
새롭게 이전되었습니다
최신 버전으로 접속해주세요.
새 주소: binaryhub.club/studyace
┌─────────────────────────┐
│ 최신 버전으로 이동 → │
└─────────────────────────┘
또는
이번엔 기존 주소로 계속
처음엔 사용자의 선택을 쿠키에 저장해서 한 번만 묻고 그 다음부터 자동 통과시킬 생각이었다. 그런데 그러면 한 번 "기존 주소"를 누른 사람은 영영 안내 페이지를 보지 못한다. 결국 옛 주소 사용자는 자연스럽게 새 주소로 옮겨가지 않는다.
그래서 쿠키를 빼고 매번 띄우는 쪽으로 갔다. 옛 주소를 살리되, 그 길은 매번 한 번 더 클릭해야 한다. 약간의 마찰은 의도된 것이다.
1회용 통과 토큰
쿠키를 안 쓰면 한 가지 문제가 생긴다. "기존 주소로 계속"을 누른 직후 그 페이지로 이동하면, 서버가 또 인터스티셜로 리다이렉트한다. 무한 루프다.
해결은 1회용 query 토큰이다. 인터스티셜에서 "기존 주소" 버튼은 next path에 ?_skip=1을 붙여서 navigate한다.
var sep = next.indexOf("?") >= 0 ? "&" : "?";
location.replace(next + sep + "_skip=1");서버 wrapper와 클라이언트 host check 둘 다 _skip=1이 있으면 우회한다. 그 페이지에 머무는 동안은 새로고침해도 통과되고, 다른 페이지로 이동하면 _skip이 빠져서 다시 인터스티셜이 뜬다.
매 페이지마다 한 번씩의 마찰 — 새 주소로 옮길 동기는 충분히 된다.
통과시켜야 하는 것들
인터스티셜이 전부 잡으면 안 되는 path도 있다.
/api/*— 클라이언트가 fetch로 호출하는 API. 인터스티셜 HTML로 응답하면 fetch가 깨진다/css,/js,/img,*.txt,*.xml— 정적 자산. 인터스티셜로 가면 페이지 자체가 깨진다/_choose-host.html— 인터스티셜 자체. 안 그러면 무한 루프
그래서 wrapper의 조건은 "HTML path만, 그리고 인터스티셜 아니고 API 아니고 _skip 없으면" 인터스티셜로 보낸다.
const hasFileExt = /\.[a-zA-Z0-9]{1,6}$/.test(url.pathname);
const isHtml = !hasFileExt || url.pathname.endsWith(".html");
const isApi = url.pathname.startsWith("/api/");
const isInterstitial = url.pathname === "/_choose-host.html";
const skipOnce = url.searchParams.has("_skip");
if (!isInterstitial && !isApi && isHtml && !skipOnce) {
return Response.redirect(/* interstitial */, 302);
}검색 봇이 /robots.txt나 /sitemap.xml을 가져갈 때도 정상 응답을 받는다. SEO 측면에서 옛 도메인이 갑자기 인덱스에서 사라지지 않는다.
결과
두 주소 모두 살아 있다. 친구들 북마크는 그대로 작동하고, 옛 검색 결과도 깨지지 않는다. 다만 옛 주소로 들어가면 매번 "최신 버전으로 접속해주세요"를 본다.
며칠 운영해 보니 친구들이 자연스럽게 새 주소로 옮겨가고 있다. 강제 리다이렉트가 아니라, 한 번 더 클릭하는 것이 거슬려서 그렇게 된다. 점진 이전은 결국 사용자 마찰 곡선을 어떻게 설계하느냐의 문제다.
검색 신호도 점차 새 도메인 쪽으로 쌓이기 시작한다. 우산 브랜드 한가운데로 무게중심이 옮겨가는 중이다.