~/posts/infra/noindex-pages-dev.md

date:
tags: [cloudflare-pages, seo, robots-txt, astro]
read: 7분

pages.dev 주소를 검색에서 빼기, robots.txt 대신 헤더로

안녕하세요, 개발자 브론입니다. 팀 랜딩페이지를 Cloudflare Pages에 올리면서, 검색에 잡히면 안 되는 주소들을 빼낸 과정을 정리해 보려고 합니다.

랜딩페이지라 특별한 기능은 필요 없었습니다. 빌드한 정적 파일만 올리면 됐습니다. 다만 SEO는 꼭 챙겨야 했습니다.

Cloudflare Pages는 이 둘에 잘 맞았습니다. 빌드한 파일을 올리면 바로 배포되고, 브랜치마다 미리보기 주소도 따로 생깁니다. 응답 헤더는 _headers 파일 하나로 붙일 수 있습니다. 이 글에서 검색을 막는 데 쓴 것도 이 파일입니다. (Cloudflare Pages 문서)

Cloudflare Pages가 만드는 주소들

배포를 하면 주소가 네 개 생깁니다.

주소 무엇
<운영 도메인> 운영 (커스텀 도메인)
<project>.pages.dev 운영 별칭. 운영과 같은 배포
<브랜치>.<project>.pages.dev PR 미리보기
<해시>.<project>.pages.dev 배포 하나하나의 고정 주소

운영 도메인을 빼면, 나머지는 제가 만든 게 아닙니다. Cloudflare Pages가 알아서 만듭니다.

  • <project>.pages.dev는 프로젝트를 만들면 늘 있습니다. 커스텀 도메인을 붙여도 그대로 남습니다.
  • 배포마다 해시 주소가 하나씩 생깁니다.
  • 브랜치 별칭은 그 브랜치의 마지막 배포를 가리킵니다. chore/30-custom-domain 브랜치라면 chore-30-custom-domain.<project>.pages.dev가 됩니다.

(Cloudflare Pages 문서: Preview deployments)

main에 머지하면 운영으로, PR이면 그 브랜치 이름으로 미리보기가 올라갑니다. PR마다 미리보기 주소에서 화면을 확인하고 머지했습니다. 운영과 같은 환경에서 로컬처럼 잘 보이는지, 느리지는 않은지 보고 싶었기 때문입니다.

그런데 문제는 이 주소들이 모두 같은 페이지를 보여 준다는 점입니다. 검색에는 운영 도메인 하나만 나오게 하고 싶었습니다. 들어오는 길을 하나로 만들고 싶었습니다.

같은 페이지가 여러 주소로 검색에 걸리면 SEO에도 좋지 않을 것 같았습니다. 같은 내용이 여러 주소에 있으면, Google은 그중 하나를 골라 검색 결과에 보여 줍니다. 어느 주소를 고를지는 Google이 정합니다. pages.dev 주소가 뽑힐 수도 있다는 뜻입니다. (Google 검색 센터: 중복 URL 통합)

처음 방법: robots.txt를 빌드 때 나누기

처음에는 robots.txt를 빌드할 때 나눴습니다. main 브랜치 빌드만 허용하고, 나머지는 전부 막았습니다.

src/pages/robots.txt.ts (처음)
import type { APIRoute } from 'astro';
// 운영(main) 빌드만 검색 허용 — PR 미리보기·로컬 빌드는 막는다.
// BRANCH는 deploy.yml의 빌드 단계에서 넘겨준다.
export const GET: APIRoute = ({ site }) => {
const body =
process.env.BRANCH === 'main'
? `User-agent: *\nAllow: /\n\nSitemap: ${new URL('sitemap-index.xml', site)}\n`
: 'User-agent: *\nDisallow: /\n';
return new Response(body, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
};

미리보기의 /robots.txt는 Disallow: /가 됐습니다. 미리보기는 robots.txt가 다 막아 주니, 이걸로 끝났다고 생각했습니다.

왜 안 됐나

막히지 않은 주소가 하나 있었습니다. 바로 운영 별칭(<project>.pages.dev)입니다.

사실 미리보기 주소에는 Cloudflare가 처음부터 noindex를 붙여 줍니다. 따로 막지 않아도 검색에서 빠져 있었던 셈입니다. (Cloudflare Pages 문서: Preview deployments)

main 빌드 결과는 운영 도메인에도, <project>.pages.dev에도 똑같이 올라갑니다. 같은 파일이니 robots.txt도 같습니다. 한쪽(운영 도메인)은 허용하고, 다른 쪽(<project>.pages.dev)은 막아야 합니다. 하지만 빌드할 때는 이 둘을 나눌 수 없습니다.

주소를 나누려면 요청을 받는 쪽에서 해야 합니다. 어느 호스트로 들어온 요청인지 보고요.

운영 별칭을 운영 도메인으로 보내는 리다이렉트도 생각했습니다. 하지만 리다이렉트는 운영 별칭 하나만 해결합니다. _headers 규칙 두 개면 pages.dev 주소 전부를 한 번에 맡을 수 있습니다. 운영 별칭, 미리보기, 배포 해시까지요. 그래서 이번에는 주소를 그대로 두고 noindex로 막기로 했습니다.

robots.txt로 막는 것도 생각보다 약한 방법이었습니다. robots.txt로 막은 페이지는 크롤러가 가져가지 않습니다. 그래서 noindex가 있어도 보지 못합니다. 다른 곳에 링크가 있으면 주소가 검색 결과에 뜰 수도 있습니다.

(Google 검색 센터: robots.txt 소개, noindex로 색인 막기)

바꾼 방법: 호스트 기준 X-Robots-Tag

그래서 robots.txt는 늘 허용으로 되돌렸습니다. 막는 일은 _headers가 맡습니다.

_headers는 Cloudflare Pages가 읽는 설정 파일입니다. 정적 파일과 함께 올리면, 적힌 대로 응답 헤더를 붙여 줍니다. Astro라면 public/에 두면 됩니다.

X-Robots-Tag는 noindex를 HTML이 아니라 응답 헤더로 알리는 방법입니다. <meta name="robots" content="noindex">와 뜻이 같습니다. 헤더라서 HTML을 고치지 않고 붙일 수 있습니다. (Google 검색 센터: robots 메타 태그와 X-Robots-Tag)

public/_headers (일부 바꿈)
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
# pages.dev 주소(운영 별칭·미리보기 브랜치·배포 해시)는 검색에서 뺀다 — 운영은 <운영 도메인>만
https://:project.pages.dev/*
X-Robots-Tag: noindex
https://:version.:project.pages.dev/*
X-Robots-Tag: noindex
src/pages/robots.txt.ts (바꾼 뒤)
import type { APIRoute } from 'astro';
// 항상 검색 허용 — pages.dev 주소(미리보기도)는 public/_headers의 X-Robots-Tag로 막는다.
export const GET: APIRoute = ({ site }) =>
new Response(`User-agent: *\nAllow: /\n\nSitemap: ${new URL('sitemap-index.xml', site)}\n`, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});

_headers 규칙은 앞에 https://호스트를 쓸 수 있습니다. 그러면 주소마다 다른 헤더를 붙일 수 있습니다.

:project와 :version은 자리표시입니다. 이름에 뜻은 없습니다. 첫 규칙은 *.pages.dev 꼴인 운영 별칭을, 둘째 규칙은 *.*.pages.dev 꼴인 미리보기와 해시 주소를 맡습니다. 운영 도메인은 어디에도 걸리지 않습니다.

이 두 규칙은 Cloudflare 문서의 예시 그대로입니다. (Cloudflare Pages 문서: Headers)

빌드에 넘기던 BRANCH 환경 변수도 이제 필요 없습니다.

확인

배포한 뒤 주소마다 헤더를 봤습니다.

curl -sI https://<project>.pages.dev/ | grep -i x-robots-tag
주소 X-Robots-Tag
운영 <운영 도메인> 없음
운영 별칭 <project>.pages.dev noindex
미리보기 <브랜치>.<project>.pages.dev noindex
배포 해시 <해시>.<project>.pages.dev noindex

운영 도메인만 “없음”이어야 맞습니다. 검색에 나와야 하는 주소는 이것 하나뿐이니까요.

지금은 운영 별칭을 운영 도메인으로 301 리다이렉트도 하고 있습니다. 운영 별칭으로 들어오면 운영 도메인으로 옮겨 갑니다. noindex와 같이 써도 됩니다. noindex는 검색에서 빼고, 리다이렉트는 들어온 사람을 운영 도메인으로 보냅니다.

하나 덧붙이면, pages.dev 주소로 들어와도 페이지 안의 주소는 모두 운영 도메인을 가리킵니다. 사이트 주소는 astro.config.mjs의 site 한 줄에서만 만들어집니다. canonical, OG, sitemap, robots.txt, JSON-LD가 모두 그 한 줄을 씁니다. 그래서 도메인을 옮길 때도 코드에서 고친 곳은 그 한 줄뿐이었습니다.

이 글을 쓰다가 이 블로그도 같은 상태라는 걸 알았습니다. curiousbk.pages.dev가 noindex 없이 열려 있었습니다. 같은 규칙 두 개를 넣어 막았습니다.

배운 것

사실 저는 원래 백엔드 개발자입니다. 그래서 프론트엔드는 아직 낯섭니다. 열심히 배우고 있지만, 쉽지 않습니다.

예전에도 블로그를 한 적이 있습니다. 그때는 “왜 내 블로그는 검색에 안 나오지?” 하다가 흥미를 잃고 그만뒀습니다. 이번에는 반대로 검색에서 빼는 일을 했는데, 하다 보니 그때 무엇을 몰랐는지 보였습니다. 검색에 무엇이 잡히고 무엇이 빠지는지, 그 규칙을 몰랐던 겁니다.

가장 큰 오해는 robots.txt였습니다. Disallow: / 한 줄이면 봇이 안 가져가니, 검색에도 안 나온다고 생각했습니다. 그런데 아니었습니다. 봇이 가져가지 않을 뿐, 다른 곳에 링크가 있으면 주소는 검색에 뜰 수 있습니다.

Cloudflare Pages가 해 주는 것도 생각보다 많았습니다. 서버가 없는 정적 사이트인데도, _headers 파일 하나로 주소마다 다른 헤더를 붙일 수 있었습니다. 미리보기 주소에는 처음부터 noindex가 붙어 있었다는 것도 나중에 알았습니다.

esc

// 최근 글