대표 주소·수집 지시·사이트맵 구성하기
검색 시스템에 제공하는 주소를 페이지·링크·사이트맵에서 맞춘다. 공개 경로의 수집 지시를 정하고 개편 전 주소가 새 문서에 도달하도록 연결한다.
중복 주소와 언어 주소를 정리한다
새 운영 안내를 게시해도 그 URL로 가는 링크가 없으면 수집기가 주소를 발견할 경로가 부족하다. 주소를 알아도 robots나 서버가 요청을 막으면 본문을 가져오지 못하고, 같은 글이 여러 주소에 있으면 대표를 선택해야 한다. 내부 링크·사이트맵, 수집 지시, canonical은 각각 이 문제를 다룬다. 공개 문서가 발견되고 처리되는 순서에 맞춰 설정을 읽는다.
같은 글이 여러 주소로 열리면 운영할 대표 URL을 먼저 정한다. http와 https, www 유무, 끝 슬래시, 이전 slug의 관계를 목록으로 적는다. 더 이상 쓰지 않을 동등한 주소는 서버나 호스팅의 영구 리다이렉트로 대표 주소에 연결한다. 추적 쿼리만 다른 주소는 본문이 같다면 같은 canonical을 사용할 수 있다. 필터가 실제로 다른 콘텐츠를 제공한다면 중복인지부터 판단한다.
페이지 나누기의 2페이지는 1페이지와 내용이 다르므로 모든 페이지를 무조건 첫 페이지로 대표 지정하지 않는다. 다음 페이지로 가는 실제 a href 링크를 제공한다. 다국어 페이지가 있으면 각 언어의 자기 대표 주소와 상호 hreflang 관계를 만들고, 존재하지 않는 번역 주소는 내보내지 않는다.
사이트맵·내부 링크·canonical·리다이렉트가 서로 다른 대표 URL을 제시하지 않게 맞춘다. Google의 대표 주소 안내를 기준으로 구성하고, 배포 후 검색 시스템이 실제 선택한 대표 URL은 URL 검사에서 확인한다.
개편 전후 URL을 한 줄씩 대응시킨다
개편 전에 기존 주소를 목록으로 저장하고 각 주소에 유지·이전·과거 기록 보존·종료 중 하나를 지정한다. 같은 내용의 주소 이동은 301/308로 새 주소에 연결한다. 현재 안내와 과거 기록은 적용 기간과 문서 목적을 기준으로 구분한다. 과거 기록에는 당시 기준 시점과 현재 안내 링크를 붙이고 별도 주소로 유지할 수 있다.
예를 들어 /notice/old-guide를 /guides/apply로 옮겼다면 리다이렉트, 메뉴·본문 링크, canonical, 사이트맵을 같이 바꾼다. 구 안내를 /archive/2024/apply로 보존한다면 그 본문을 현재 안내 문장으로 덮지 않는다. 아카이브를 검색에서 제외할지는 공개 정책으로 결정하며, 오래됐다는 이유만으로 일괄 noindex를 붙이지 않는다.
라우트 대응표에는 이전 URL·새 URL·기대 상태 코드·대표 주소·사이트맵 포함·유입 링크를 적는다. 이전 주소가 다른 이전 주소를 거쳐 여러 번 이동하거나 루프가 생기지 않는지 확인한다. 운영 도메인이 바뀌면 절대 URL을 만드는 환경값, 공유 이미지 주소, 검색 도구의 속성·소유 확인도 함께 갱신한다. Google의 URL 변경 이전 안내를 참고한다.
이전 주소는 검색 결과·외부 기사·저장한 링크에 계속 남을 수 있다. 같은 글이 새 주소로 이동했을 때 영구 리다이렉트를 제공하면 그 주소를 방문한 사람과 수집기를 새 문서로 안내할 수 있다. 과거 운영 기준을 보존한 기록이라면 당시 질문에 답할 자료이므로 자기 주소와 기간을 유지한다. 모든 옛 주소를 홈으로 보내면 무엇을 찾으러 왔는지 이어 주기 어렵다.
robots.txt와 응답 헤더를 수정한다
Next.js에서는 app/robots.ts 또는 public/robots.txt 중 실제 제공 경로를 확인한다. 다른 서버에서는 웹 루트의 robots.txt와 CDN·프록시의 차단 규칙을 본다. 공개 경로가 Disallow에 포함돼 있으면 공개 정책에 맞게 좁힌다. HTML의 meta robots와 HTTP X-Robots-Tag에 남은 noindex도 함께 조사한다. robots.txt에 적은 noindex는 Google의 색인 제외 지시로 지원되지 않는다.
검색용 봇과 학습용 봇은 목적을 나누어 결정한다. 다음은 검색 수집을 허용하고 OpenAI 학습 수집은 제한하기로 결정한 사이트의 예시다. 학습 수집도 허용하는 사이트는 GPTBot의 Disallow 규칙을 그 정책에 맞게 바꾼다.
User-agent: *
Allow: /
Disallow: /admin$
Disallow: /admin?
Disallow: /admin/
Disallow: /preview$
Disallow: /preview?
Disallow: /preview/
User-agent: OAI-SearchBot
Allow: /
Disallow: /admin$
Disallow: /admin?
Disallow: /admin/
Disallow: /preview$
Disallow: /preview?
Disallow: /preview/
User-agent: GPTBot
Disallow: /
Sitemap: https://example.com/sitemap.xml
특정 봇에 적용할 그룹에는 그 봇의 제외 경로도 명시한다. 일반 그룹의 규칙을 병합하지 않는 처리 방식에서도 제외 조건을 전달하기 위해 예시의 검색 봇 그룹에 경로를 반복했다. 미리보기 경로는 실제 인증으로 보호하고, 공개 본문에 필요한 CSS·JavaScript·이미지·공개 API가 넓은 차단 규칙에 걸리지 않는지 확인한다. WAF가 봇에게 로그인·챌린지·403을 주면 robots 허용만으로 해결되지 않는다.
Google의 noindex 지시는 페이지 응답에 들어간다. 크롤러가 그 응답을 받을 수 있어야 지시를 읽는다. OAI-SearchBot은 검색용 자료를, GPTBot은 모델 학습용 자료를 수집하므로 목적별로 허용 경로를 정한다. Google noindex · OpenAI 봇
/admin/만 쓰면 /admin과 /admin?mode=edit는 일치하지 않는다. 위 규칙은 경로 끝을 뜻하는 $로 정확한 루트를, ?로 루트의 쿼리를, /로 하위 경로를 지정한다. /administrator는 이 세 규칙에 포함되지 않는다. Google이 지원하는 *·$와 경로 비교 규칙을 기준으로 한 예다. 다른 수집기는 해당 제공자의 지원 범위를 확인한다. robots 경로 규칙
사이트맵을 공개 데이터에서 생성한다
사이트맵은 별도 수기 목록보다 공개 조회 결과에서 만드는 편이 누락을 줄인다. Next.js의 app/sitemap.ts에서는 URL·수정일을 반환하고, 다른 스택에서는 같은 데이터로 XML을 생성한다. 아래의 listPublicGuides는 공개 조건을 적용한 전체 목록 조회다. DB 조회가 페이지 단위라면 마지막 페이지까지 순회해야 한다.
다음 블록은 변환 원리를 보여주는 함수 부분이다. 실행할 전체 app/sitemap.ts와 import는 다음 절에 있다.
export default async function sitemap() {
const guides = await listPublicGuides()
return guides.map(g => ({
url: g.info.canonical,
...(g.info.dateModified ? { lastModified: g.info.dateModified } : {})
}))
}
guides는 공개 글 전체 배열이고 map은 글마다 사이트맵 항목을 만든다. url에는 검증한 info.canonical을 넣고 dateModified가 있을 때만 lastModified를 추가한다. ...는 조건에 따라 만든 속성을 객체에 합치는 문법이다. 날짜 문자열을 그대로 사용해 날짜만 아는 수정일에 임의의 자정을 붙이지 않는다.
lastModified에는 본문이나 중요한 링크가 실제로 바뀐 날짜를 넣는다. 배포 시각이나 매 요청의 현재 시각으로 채우지 않는다. 신뢰할 수정일이 없으면 생략한다. 사이트맵 URL은 정상 응답하는 공개 대표 주소여야 하며, 철회·오류·리다이렉트 주소는 제외한다. 사이트맵 작성 지침에 따라 생성 파일을 제공한 뒤 검색 도구에서 제출·처리 상태를 확인한다.
RSS·Atom은 새 글 구독이나 외부 연동에 사용하는 문서 형식이다. 피드(feed)는 그 형식으로 제목·링크·날짜를 모아 제공하는 주소다. 운영한다면 사이트맵과 같은 공개 조건을 적용하고 제목·대표 링크·날짜·안정된 항목 ID를 출력한다. 구독기나 연동 서비스가 사용할 주소를 정하고, 게시·수정·철회 때 피드도 갱신한다.
사이트맵을 공개 조회에서 만드는 이유는 CMS에 글이 늘 때 발견할 주소도 함께 늘어나게 하기 위해서다. 예를 들어 상세 글은 120개인데 첫 목록 20개만 사이트맵에 넣으면 나머지 100개의 발견은 다른 링크 경로에 의존한다. 전체 조회와 정상 주소 검사를 연결하면 이 누락을 찾을 수 있다. lastmod는 바뀐 문서를 알리는 자료로 쓰이므로 실제 중요한 변경 시각과 맞춘다.
예제의 robots와 사이트맵을 완성한다
app/robots.ts는 공개 경로를 수집하도록 안내하고 관리자·미리보기 경로를 제외한다. 인증은 해당 서버 기능에서 별도로 적용한다. app/sitemap.ts는 공개 조회 결과와 실제 보존 페이지를 포함한다.
import type { MetadataRoute } from 'next'
import { SITE_ORIGIN } from '../lib/site'
export default function robots(): MetadataRoute.Robots {
return { rules: [{ userAgent: '*', allow: '/', disallow: ['/admin$', '/admin?', '/admin/', '/preview$', '/preview?', '/preview/'] }],
sitemap: new URL('/sitemap.xml', SITE_ORIGIN).href }
}
import type { MetadataRoute } from 'next'
import { listPublicGuides } from '../lib/guides'
import { SITE_ORIGIN } from '../lib/site'
export const dynamic = 'force-dynamic'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const guides = await listPublicGuides()
return [
{ url: SITE_ORIGIN },
{ url: new URL('/policies/opening-hours', SITE_ORIGIN).href },
{ url: new URL('/archive/opening-2025', SITE_ORIGIN).href },
...guides.map(g => ({ url: g.info.canonical,
...(g.info.dateModified ? { lastModified: g.info.dateModified } : {}) })),
]
}
사이트맵에서 초안·없는 주소·리다이렉트 주소가 빠지고, 각 URL이 정상 공개 문서를 반환하는지 확인한다. URL 수가 커지면 파일을 나누고 사이트맵 인덱스로 연결한다. Google의 일반 사이트맵 한 파일 한도는 URL 50,000개와 압축 전 50MB다. 사이트맵 작성 지침에 따라 파일 크기와 전체 목록의 누락을 확인한다.
첫 코드는 app/robots.ts, 두 번째는 app/sitemap.ts다. robots 함수는 rules를 robots.txt 형식으로, sitemap 함수는 공개 주소 배열을 XML로 변환하도록 Next.js에 전달한다. 홈·과거 기록·근거 문서와 현재 공개 글이 들어가고 초안은 빠진다. curl http://localhost:3027/robots.txt와 curl http://localhost:3027/sitemap.xml로 실제 생성 결과를 읽는다.
현재 예제는 일반 수집 허용 정책을 사용한다. 앞 절의 GPTBot 제한 예를 채택하려면 이 robots 함수의 rules에도 같은 정책을 반영한다. public/robots.txt와 app/robots.ts를 동시에 만들어 어느 파일이 응답하는지 불명확하게 두지 않는다.
수집 가능성과 색인 가능성을 따로 검사한다
crawlability는 크롤러가 자료를 가져올 수 있는 조건이고 indexability는 가져온 자료가 색인 대상이 될 수 있는 조건이다. HTTP 200·robots 허용이어도 noindex나 대표 문서 선택, 콘텐츠 판단에 따라 검색에 포함되지 않을 수 있다. 수집을 막으면 크롤러가 페이지 안의 noindex를 읽지 못할 수 있다.
공개 파일에서 검색 제외만 원하면 접근 가능한 응답에 meta robots 또는 X-Robots-Tag의 noindex를 제공한다. 비공개 자료는 인증과 권한으로 보호한다. 허용하지 않은 사용자의 요청이 실제로 차단되는지 확인한다. robots.txt는 공개된 안내 파일이므로 비밀 경로를 숨기는 수단으로 쓰지 않는다.
crawl budget은 검색엔진이 사이트를 수집하는 자원·수요의 제약과 관련된 개념이다. 매우 큰 사이트와 빠르게 변하는 자료에서 중복·무한 URL·서버 응답 문제가 수집 효율에 영향을 줄 수 있다. 작은 안내 사이트에서는 먼저 중요한 문서의 발견 링크·오류·본문·색인 제외 원인을 확인한다. 대규모 사이트 수집 안내
지역 사업장은 지점별 주소·영업 시간·연락처를 제공한다. 상품은 가격·재고·구매 조건을, 영상은 재생 주소·썸네일·길이를 제공한다. 번역 페이지에는 해당 언어의 본문과 서로 대응하는 hreflang 주소가 필요하다. 콘텐츠 유형에 따라 추가할 정보가 달라진다.
생성형 검색 참여 조건과 본문 사용 범위를 설정한다
Google의 생성형 검색에 참여하려는 페이지는 색인되어 있고 검색 결과에서 스니펫 표시가 가능해야 한다. Search Console의 Settings → Search generative AI에서 해당 속성과 상위 속성의 적용값도 확인한다. Include가 기본값이고 하위 속성은 부모 값을 상속할 수 있다. 검색 노출이 있는데 생성형 기능에 참여하지 못한다면 색인 상태, 스니펫 지시, 이 설정을 순서대로 대조한다. 참여 조건 · Search generative AI 설정
<!-- 전체 페이지의 스니펫을 제외하는 선택: 예제 사이트 기본값이 아님 -->
<meta name="robots" content="nosnippet">
<!-- 다른 선택: 스니펫 최대 길이 제한 -->
<meta name="robots" content="max-snippet:160">
<!-- 페이지 일부를 스니펫에서 제외하는 선택 -->
<p>일요일 10–17시, 첫째 일요일 휴관.</p>
<div data-nosnippet>화면 이용을 위한 부가 문구</div>
세 코드는 서로 다른 선택이다. nosnippet은 텍스트 스니펫을 막으며 Google AI Overviews·AI Mode의 직접 입력으로 콘텐츠를 쓰는 것도 제한한다. max-snippet:0은 같은 제외를 뜻하고, 양수는 스니펫과 직접 입력의 길이에 영향을 준다. data-nosnippet은 span·div·section에 붙여 해당 부분을 제외한다. 핵심 휴관 조건을 그 안에 넣으면 필요한 답의 근거가 빠질 수 있다. 별도 허가·계약이나 구조화 데이터 등에는 적용 예외가 있으므로 해당 지시의 범위를 문서에서 확인한다. Google 스니펫 지시
Googlebot은 Google 검색 수집을 담당한다. Google-Extended는 일부 Gemini 모델 학습과 Google 검색 밖의 지정된 grounding 사용을 관리하는 robots 제품 토큰이다. Google Search의 순위나 생성형 검색 참여를 그 토큰만으로 제어하지 않는다. Search generative AI 제외 설정은 검색의 지정된 생성형 기능에 적용되며 다른 검색 순위나 모델 학습을 제어하지 않는다. Google 크롤러 범위 · 설정의 적용 범위
OpenAI의 OAI-SearchBot과 GPTBot도 검색과 학습이라는 별도 용도를 가진다. 공개 경로를 허용한 뒤에는 실제 응답의 robots 헤더와 본문을 읽고 서버 로그의 요청 결과를 남긴다. 검색 결과 참여와 학습 허용을 한 개의 “AI 허용” 스위치로 합치면 어느 목적을 제한했는지 설명하기 어려워진다.
최근 글을 RSS로 제공하고 수정·철회에 맞춰 갱신한다
RSS는 구독 도구가 같은 주소를 주기적으로 읽어 새 글을 찾는 형식이다. 홈페이지를 매번 방문하지 않는 독자나 다른 서비스에 소식을 전달할 수 있다. 사이트맵은 사이트의 공개 주소를 검색 도구에 알리고, 이 실습의 RSS는 최근 게시물 20건의 제목·대표 주소·요약을 보낸다. Google은 RSS·Atom도 사이트맵으로 받을 수 있지만 최근 글만 담는 피드는 전체 URL 목록을 대신하지 못한다. 사이트맵 형식 안내
아래 feed.mjs를 identity-time.mjs와 같은 폴더에 저장한다. renderFeed는 원본 행 전체에서 공개 문서를 고른 뒤 게시일순으로 20건을 선택한다. guid에는 문서 ID를, link에는 현재 대표 주소를 넣는다. 제목이나 slug를 바꿔도 같은 글이라는 관계를 유지한다. RSS 2.0 명세
import { describeDocument, instant } from './identity-time.mjs';
export function escapeXml(value) {
// XML 1.0이 허용하지 않는 문자와 홀로 남은 surrogate를 거부한다.
for (const char of String(value)) {
const n = char.codePointAt(0);
if (!(n === 9 || n === 10 || n === 13 || (n >= 32 && n <= 0xD7FF)
|| (n >= 0xE000 && n <= 0xFFFD) || n >= 0x10000)) throw new Error('Invalid XML character');
}
return String(value).replace(/[&<>"']/g,
c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
export function renderFeed(rows, origin, asOf) {
const x = escapeXml;
const publicRows = rows.map(doc => ({ doc, identity: describeDocument(doc, asOf, origin) }))
.filter(row => row.identity)
.sort((a, b) => order(b.doc.publishedAt) - order(a.doc.publishedAt))
.slice(0, 20);
const items = publicRows.map(({ doc, identity }) => {
// RSS pubDate는 시각을 요구한다. 날짜만 아는 문서에는 선택 속성을 생략한다.
const date = identity.datePublished;
const pubDate = date?.includes('T') ? `<pubDate>${new Date(instant(date)).toUTCString()}</pubDate>` : '';
return `<item><title>${x(doc.title)}</title><link>${x(identity.canonical)}</link>
<guid isPermaLink="false">${x(identity.id)}</guid>${pubDate}
<description>${x(x(doc.summary))}</description></item>`;
}).join('');
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel><title>가상 도서관 소식</title>
<link>${x(origin)}</link><description>최근 공개 소식 20건</description>${items}</channel></rss>`;
}
// 날짜만 있는 항목의 정렬에는 한국 달력의 시작점을 사용한다. pubDate에는 쓰지 않는다.
function order(value) {
return value === null ? -Infinity : instant(value.length === 10 ? value + 'T00:00:00+09:00' : value);
}
XML의 &와 < 같은 문자는 문서 구문으로 쓰이므로 escapeXml이 텍스트를 이스케이프한다. RSS description은 수신기가 HTML로 읽을 수 있어 요약에 두 번 적용한다. 첫 변환은 HTML의 글자, 둘째 변환은 그 HTML을 담는 XML의 글자를 보존한다. 허용하지 않는 제어 문자는 오류로 드러낸다. pubDate에는 최초 게시 시각을 넣고 날짜만 아는 경우에는 이 선택 항목을 생략한다.
본문을 고치면 같은 guid의 요약과 링크를 갱신한다. 이 RSS에는 항목별 수정 시각을 별도로 넣지 않았다. 수정 시각이 필요한 연동에서는 Atom의 updated처럼 수신 도구가 지원하는 필드를 선택한다. 철회한 글은 다음 피드에서 빠지지만 구독기가 이미 저장한 글은 남을 수 있다. 원문 접근을 막고 정정 사실을 별도 글로 전달할지 결정해야 한다.
이미지 실습 서버가 /rss.xml에서 이 결과를 application/rss+xml로 응답한다. 홈페이지 head에는 rel="alternate" 링크가 있다. RSS 주소를 직접 열어 XML을 읽는 검사와, 실제 구독 도구가 새 글·동일 ID 수정·철회를 어떻게 처리하는지 보는 검사를 나눠 수행한다.