Cloudflare Workers로 구현하는 엣지 SEO
CDN 엣지에서 SEO를 고치고 최적화하기 — 태그 주입, 대규모 리다이렉트, 수정할 수 없는 CMS 우회.
엣지 SEO란 브라우저나 크롤러가 받는 내용을, 오리진 서버가 페이지를 생성한 이후이면서 클라이언트에 도달하기 이전에 바꾸는 기법이다. 이 재작성(rewriting)은 CDN에서 일어난다. 여러분의 경우, 방문자와 사이트 사이에 위치한 Cloudflare Worker에서 처리된다.
우체국에 서 있는 교정자(copy editor)를 떠올려 보자. 저자(여러분의 CMS)는 이미 편지를 다 써서 봉인했다. 저자에게 다시 쓰게 만들 수는 없다. 레거시 플랫폼일 수도 있고, 템플릿을 다른 팀이 소유하고 있을 수도 있으며, 변경이 긴급할 수도 있다. 그래서 교정자가 마지막 순간에 봉투를 열어 canonical 태그의 오타를 고치고, 죽은 URL에 리다이렉트 도장을 찍은 뒤 그대로 보낸다. 수신자는 편지가 손대졌다는 사실을 전혀 모른다.
이 사이트는 이미 Cloudflare Pages 위에서 돌아가고 있으므로, 여러분은 이 기능에서 Worker 하나만큼 떨어져 있을 뿐이다. 새로운 공급업체도, 태그 매니저도, 오리진 접근 권한도 필요 없다. 필요한 것은 fetch 핸들러와, Cloudflare의 스트리밍 HTML 파서인 HTMLRewriter다. 이 가이드의 나머지에서는 언제 이것이 올바른 선택인지, 알아둘 만한 패턴, 완전한 작동 예제, 그리고 그것이 조용히 잘못될 수 있는 방식들을 보여준다.
언제 사용할 것인가
엣지 SEO는 정밀 도구이지 기본값이 아니다. 일반적인 경로 — 오리진에서 템플릿을 수정하는 것 — 가 막혔거나 너무 느릴 때 꺼내 든다.
| 상황 | 엣지가 이기는 이유 |
|---|---|
| 레거시 또는 잠긴 CMS | 플랫폼이 <head>를 렌더링하는데 템플릿에 손댈 수 없다. 엣지는 백엔드와 무관하게 출력을 재작성한다. |
| SaaS / 호스팅 플랫폼 | Shopify, 마케팅 사이트 빌더, 끝까지 제어할 수 없는 헤드리스 스토어프런트. SSH로 들어갈 서버가 없지만, Worker는 그 앞에 설 수 있다. |
| 사이트 전체 일관성 | 50,000개 URL에서 동일한 canonical 또는 hreflang 로직이 필요하다. Worker 하나가 이를 강제한다. 50,000개 템플릿이 서로 일치하리라고 믿지 않아도 된다. |
| 긴급 핫픽스 | 잘못된 배포가 프로덕션에 noindex를 내보냈다. 개발 스프린트는 2주 뒤다. Worker 수정은 60초 만에 적용되고 되돌릴 수 있다. |
| A/B SEO 실험 | 템플릿을 분기하지 않고도 특정 섹션 트래픽의 50%에 title 태그 패턴을 테스트한다. |
🧑💻 개발자 관점: 변경이 횡단적(cross-cutting) 이거나(규칙에 따라 다수 페이지에 적용) 시간이 절박할 때(오리진 배포가 병목) 엣지가 올바른 계층이다. 변경이 진정으로 페이지별이고 템플릿을 제어할 수 있다면 오리진에서 하라. 거기가 그것이 속한 곳이며, 다음 엔지니어가 찾아볼 곳이다.
반대 측면: 모든 엣지 규칙은 여러분 레포의 일반적인 리뷰 흐름 바깥에 사는 로직 조각이다. 포스트잇이 아니라 프로덕션 인프라로 다뤄라. 이 점은 「주의사항」에서 다시 다룬다.
흔한 패턴
다음은 일꾼들이다. 각각은 응답을 스트리밍하면서 CSS 셀렉터에 핸들러를 붙일 수 있게 해주는 HTMLRewriter 몇 줄로 이뤄진다. 그래서 문서 전체를 메모리에 버퍼링하는 일이 결코 없다.
canonical과 hreflang 주입 또는 수정
가장 흔한 엣지 SEO 작업이다. CMS가 잘못된 호스트(http, 스테이징 도메인, 끝 슬래시 불일치)를 가리키는 canonical을 내보내거나, 이중 언어 사이트에서 hreflang을 아예 누락한다.
class CanonicalFixer {
constructor(url) {
this.canonical = `https://example.com${new URL(url).pathname}`;
this.found = false;
}
element(el) {
// Rewrite an existing canonical to the correct absolute URL.
el.setAttribute("href", this.canonical);
this.found = true;
}
}
이것을 <head>의 end 핸들러와 짝지어, 태그가 누락되었을 때 주입한다. 아래 작동 예제에서 전체를 보여준다.
대규모 리다이렉트 (301 / 302)
마이그레이션은 수천 개의 죽은 URL을 남긴다. 오리진 설정이나 _redirects를 부풀리는 대신, 맵(또는 대규모 집합의 경우 KV)을 기반으로 Worker에서 처리하라.
const REDIRECTS = {
"/old-pricing": "/pricing",
"/blog/2019/seo-tips": "/guides/seo-basics",
};
export default {
async fetch(request) {
const url = new URL(request.url);
const target = REDIRECTS[url.pathname];
if (target) {
return Response.redirect(`${url.origin}${target}`, 301);
}
return fetch(request); // pass through to origin
},
};
💡 팁: 수만 개의 리다이렉트라면 맵을 Workers KV로 옮기고 키로 조회하라. KV 읽기는 엣지에 캐시되므로 조회가 빠르게 유지되고, 콜드 스타트마다 거대한 객체를 실어 보내는 일을 피할 수 있다.
meta 태그와 구조화 데이터 패치
누락된 meta description을 추가하거나, 떠도는 noindex를 제거하거나, CMS가 만들어내지 못하는 JSON-LD를 주입한다. 구조화 데이터 주입은 그저 <head>에 <script type="application/ld+json">을 덧붙이는 것이다.
class JsonLdInjector {
constructor(data) {
this.json = JSON.stringify(data);
}
element(head) {
head.append(
`<script type="application/ld+json">${this.json}</script>`,
{ html: true }
);
}
}
SEO A/B 실험
트래픽을 결정론적으로 분할하고(해시된 쿠키나 경로 버킷으로), 한 버킷에 변형된 title/description을 제공한 뒤, Search Console에서 노출수와 CTR의 차이를 측정하라. 사용자에게는 콘텐츠를 동일하게 유지하라. 여러분이 테스트하는 것은 SERP에 노출되는 스니펫이지 서로 다른 페이지를 보여주는 클로킹이 아니다.
User-Agent 기반 크롤러 인지 처리
Googlebot이나 Bingbot을 감지해 규칙을 적용한다. 예를 들어 완전히 렌더링된 대체 페이지를 제공하거나, 크롤러에 맞춰 조정한 캐싱 헤더를 추가한다. 이는 크롤러와 사용자가 동일하고 의미 있는 콘텐츠를 받을 때만 정당하다. 콘텐츠 자체를 분기하는 것은 클로킹이다. 「주의사항」을 참고하라.
const ua = request.headers.get("user-agent") || "";
const isBot = /googlebot|bingbot|duckduckbot/i.test(ua);
작동 예제
다음은 이 사이트 같은 이중 언어 사이트에 가장 가치 있는 단 한 가지를 수행하는, 완전하고 프로덕션 형태를 갖춘 Worker다. 바로 모든 페이지에서 올바른 canonical과 일치하는 hreflang 세트를 보장하는 것 — 있으면 태그를 고치고, 없으면 주입한다. 멱등(idempotent)하며(두 번 실행해도 아무것도 바뀌지 않음), 나머지 모든 것은 그대로 통과시킨다.
const SITE = "https://example.com";
// Map a path to its language alternates. Adapt to your routing.
function alternatesFor(pathname) {
const en = pathname.startsWith("/zh/")
? pathname.replace(/^\/zh\//, "/en/")
: pathname;
const zh = pathname.startsWith("/en/")
? pathname.replace(/^\/en\//, "/zh/")
: pathname;
return [
{ lang: "en", href: `${SITE}${en}` },
{ lang: "zh", href: `${SITE}${zh}` },
{ lang: "x-default", href: `${SITE}${en}` },
];
}
// Rewrite an existing canonical to the canonical absolute URL.
class CanonicalRewriter {
constructor(canonical) {
this.canonical = canonical;
this.seen = false;
}
element(el) {
el.setAttribute("href", this.canonical);
this.seen = true;
}
}
// On </head>, inject anything the page was missing.
class HeadCloser {
constructor(canonical, alternates, rewriter) {
this.canonical = canonical;
this.alternates = alternates;
this.rewriter = rewriter;
}
element(head) {
if (!this.rewriter.seen) {
head.append(
`<link rel="canonical" href="${this.canonical}">`,
{ html: true }
);
}
// Always (re)assert hreflang. We strip old ones first (below),
// so appending here yields exactly one correct set.
for (const alt of this.alternates) {
head.append(
`<link rel="alternate" hreflang="${alt.lang}" href="${alt.href}">`,
{ html: true }
);
}
}
}
export default {
async fetch(request) {
const response = await fetch(request);
// Only rewrite HTML; leave assets, JSON, redirects untouched.
const type = response.headers.get("content-type") || "";
if (!type.includes("text/html")) return response;
const url = new URL(request.url);
const canonical = `${SITE}${url.pathname}`;
const alternates = alternatesFor(url.pathname);
const rewriter = new CanonicalRewriter(canonical);
return new HTMLRewriter()
// Remove any stale hreflang so we don't duplicate them.
.on('link[rel="alternate"][hreflang]', { element: (el) => el.remove() })
.on('link[rel="canonical"]', rewriter)
.on("head", new HeadCloser(canonical, alternates, rewriter))
.transform(response);
},
};
이것을 안전하게 배포할 수 있게 만드는 요소들:
- 콘텐츠 타입으로 범위를 한정한다. HTML이 아닌 응답은 글자 그대로 통과한다. 이미지나 JSON API를 절대 손상시키지 않는다.
- 멱등하다. 오래된 hreflang 태그를 벗겨낸 뒤 정확히 하나의 올바른 세트를 덧붙인다. 다시 실행해도 동일한 결과를 만든다.
- 스트리밍한다.
HTMLRewriter는 응답이 흐르는 대로 처리한다. 문서 전체 버퍼가 없고 지연 비용도 거의 없다. - canonical을 한 번에 수정-또는-주입한다.
CanonicalRewriter가 기존 태그를 바로잡고,HeadCloser는 아무것도 발견되지 않았을 때만 하나를 추가한다.
Wrangler로 배포하고 여러분의 존(zone)에 라우팅하라.
npx wrangler deploy
# Then bind a route in wrangler.toml, e.g.
# routes = [{ pattern = "example.com/*", zone_name = "example.com" }]
🧑💻 개발자 관점:
SITE상수와alternatesFor매핑을 한곳에 두고 그 매핑 함수에 대해 단위 테스트를 작성하라. 언어 라우팅 로직이야말로 엣지 SEO 버그가 숨는 곳이다./en/en/이나 홈페이지를 잘못 처리하는 정규식 하나가 사이트 전체에 걸쳐 조용히 잘못된 alternates를 내보낼 수 있다.
주의사항
엣지 SEO가 강력한 이유는 정확히 보이지 않기 때문인데, 바로 그 점이 여러분을 무는 방식이기도 하다. Worker를 프로덕션 트래픽 위에 라우팅하기 전에 다음을 체화하라.
- 모든 재작성을 멱등하게 만들어라. 오리진이 나중에 여러분이 주입하던 태그를 내보내기 시작하면, Worker가 중복을 만들어서는 안 된다. 위 패턴이 바로 이 이유로 벗겨낸 뒤 덧붙인다. 중복 canonical은 없는 것보다 나쁘다.
- 오리진과 싸우지 마라. 엣지와 CMS가 서로 다른 canonical이나 충돌하는
robots지시어를 주장해서는 안 된다. 미래의 템플릿 변경이 엣지 규칙과 충돌하면, Google은 모순된 신호를 보고 인덱싱이 비결정적이 된다. 오리진 팀이 엣지 규칙의 존재를 알 수 있도록 모든 규칙을 문서화하라. - 캐시에 유의하라. Cloudflare가 재작성된 HTML을 캐시할 수 있다. 재작성이 요청(User-Agent, 쿠키, 지역)에 의존한다면 그에 맞춰 캐시 키를 변형해야 한다. 그러지 않으면 한 변형을 모두에게 제공하게 된다. 요청 의존 로직의 경우
Cache-Control/Vary를 의도적으로 설정하거나, 해당 경로의 캐시를 우회하라. - 지연 시간과 오류 예산에 주의하라. 엣지 케이스에서 예외를 던지는 Worker는 크롤러에게 빈 페이지를 보여줄 수 있다. 위험한 로직은
try/catch로 감싸고 실패 시 열어둬라(fail open). 500 대신 원래response를 손대지 않고 반환하라. - 오리진 코드처럼 모니터링하라.
wrangler tail을 연결하고, 재작성 횟수를 로깅하며, 변경할 때마다 Search Console의 페이지 및 삭제 보고서를 지켜봐라. 엣지 버그는 스택 트레이스가 아니라 인덱싱 이상으로 나타난다. - 절대 클로킹에 사용하지 마라. 크롤러에게 사용자와 다른 콘텐츠 — Googlebot에게는 키워드를 욱여넣은 텍스트, 사람에게는 깨끗한 페이지 — 를 제공하는 것은 Google의 스팸 정책 위반이며 수동 조치(manual action)의 위험을 부른다. UA 기반 처리는 전달과 캐싱에는 괜찮지만, 두 개의 다른 페이지를 보여줄 면허가 아니다.
⚠️ 주의: 엣지 SEO의 대죄는 사용자가 보는 것과 크롤러가 보는 것 사이의 조용한 괴리다. Search Console의 URL 검사(“크롤링된 페이지 보기”)로, 봇이 받은 렌더링된 HTML이 일반 브라우저가 받는 것과 일치하는지 검증하라. 둘이 실질적으로 다르다면 의도했든 아니든 여러분은 클로킹을 하고 있는 것이다.
이것이 들어맞는 위치
엣지 SEO는 스택의 더 넓은 빌드 계층 안에 있는 전술이다. 템플릿이 할 수 없을 때 기술적 SEO를 강제하는 방법이다. 그 위에 얹히는 토대(렌더링, canonical 전략, 내부 링크)는 빌드 계층을 참고하라. 그리고 Worker가 응답을 빚어내기 시작하면, 크롤 지시어가 그것과 일치하는지 확인하라. robots & sitemap 도구로 robots.txt와 sitemap을 검증해, 엣지와 오리진과 크롤 규칙이 모두 Google에게 같은 이야기를 하도록 하라.
핵심 요약
- ✅ 엣지를 사용해 오리진 이후, 클라이언트 이전에 SEO를 고쳐라. 잠긴 CMS, 호스팅 플랫폼, 사이트 전체 규칙, 긴급 핫픽스에 이상적이다.
- ✅
HTMLRewriter는 응답을 스트리밍하므로 canonical, hreflang, meta, JSON-LD 주입에 지연 비용이 거의 없고 문서 전체 버퍼도 없다. - ✅ 모든 재작성을 멱등하게(벗겨낸 뒤 덧붙이기) 만들어, 오리진이 나중에 내보낼 수 있는 태그를 절대 중복시키지 마라.
- ✅ 재작성 범위를
text/html로 한정하고, 로직을try/catch로 감싸며, 원래 응답으로 실패 시 열어둬라(fail open). - ✅ Worker를 프로덕션 코드로 다뤄라. 버전을 관리하고, 오리진과 충돌하지 않도록 각 규칙을 문서화하며, 변경할 때마다 Search Console을 모니터링하라.
- ✅ 사용자와 크롤러 사이에서 콘텐츠를 절대 분기하지 마라. UA 처리는 전달을 위한 것이지 클로킹을 위한 것이 아니다.