robots.txt 및 sitemap.xml 설정
robots.ts로 크롤링 허용 범위를 정하고 sitemap.ts로 색인할 URL과 갱신 정보를 검색 엔진에 제공합니다.
SEO를 최적화할 때 robots.txt와 sitemap.xml은 검색 엔진 크롤러에게 주는 핵심 지침입니다. 두 파일은 웹사이트가 얼마나 노출되는지뿐 아니라, 검색 엔진이 사이트 구조를 효율적으로 이해하고 색인하는 데도 큰 영향을 줍니다. Next.js는 이 파일들을 효과적으로 관리하는 방법을 제공해 SEO 친화적인 애플리케이션을 만들도록 돕습니다. 이 글에서는 robots.txt와 sitemap.xml의 역할과 중요성, App Router에서 두 파일을 설정하는 방법, SEO를 위한 최적화 전략을 알아봅니다.
robots.txt
robots.txt는 웹사이트의 루트 디렉터리에 두는 텍스트 파일로, 검색 엔진 크롤러(웹 로봇)에게 사이트 안에서 어떤 URL에 접근해도 되고 어떤 URL은 안 되는지 알려 줍니다. 검색 엔진이 어떤 URL을 크롤링할지 범위를 조절하고, 불필요한 요청으로 생기는 서버 부하를 줄이는 데 씁니다.
robots.txt는 특정 디렉터리나 파일을 크롤링하지 말라고 검색 엔진에 요청해 크롤링 범위를 조절하고, 불필요한 페이지에 대한 크롤링 요청을 줄여 서버 자원의 낭비를 막습니다. 중복 URL이나 필터 URL보다 대표 콘텐츠를 먼저 탐색하도록 범위를 정리하는 데도 쓸 수 있습니다.
다만 robots.txt는 보안 장치가 아닙니다. 이 파일은 크롤러에게 "이곳은 가지 말아 달라"고 요청할 뿐이므로, 악의적인 봇이나 URL을 직접 입력하는 사용자를 막을 수 없습니다. 민감한 정보는 서버의 인증이나 다른 보안 조치로 보호해야 합니다.
robots.txt 파일 구조
robots.txt는 User-agent, Disallow, Allow, Sitemap 지시어로 이루어집니다. User-agent는 규칙을 적용할 검색 엔진 봇을 지정하며, User-agent: *는 모든 봇, User-agent: Googlebot은 구글 봇, User-agent: bingbot은 빙 봇을 뜻합니다. Disallow는 크롤링하지 말라고 요청할 경로로, Disallow: /admin/은 admin 디렉터리 전체를, Disallow: /private-page.html은 특정 파일을 가리킵니다. Allow는 Disallow 규칙 안에서 특정 하위 경로를 허용할 때 쓰며, Disallow: /private/와 함께 Allow: /private/public-page.html처럼 씁니다. Sitemap은 사이트맵 파일의 위치를 지정해 크롤러가 사이트맵을 쉽게 찾게 돕습니다.
App Router에서 robots.txt 설정
App Router에서는 정적인 src/app/robots.txt와 코드로 만드는 src/app/robots.ts 가운데 하나를 고릅니다. 두 파일 모두 /robots.txt를 만들기 때문에 한 프로젝트에서 함께 쓰지 않으며, 이 교재에서는 환경이나 배포 주소에 따라 규칙을 넓히기 쉬운 src/app/robots.ts 한 가지 방식으로 통일합니다.
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*', // 모든 봇
allow: '/', // 모든 페이지 허용
disallow: ['/admin/', '/private/'], // 특정 경로 비허용
},
{
userAgent: 'Googlebot', // Googlebot만
allow: '/',
disallow: ['/admin/', '/private/', '/temp/'],
},
],
sitemap: 'https://docs-lab.dev/sitemap.xml', // 사이트맵 경로
};
}구체적인 Googlebot 그룹은 * 그룹의 규칙을 자동으로 이어받지 않으므로, 두 그룹에서 모두 막아야 하는 경로는 각 그룹에 명시합니다. Next.js는 이 반환값을 /robots.txt로 제공하며, 코드로 만들더라도 기본적으로 캐시됩니다. 실제 서비스 주소는 배포 환경에 맞춥니다.
위 경로는 접두사 규칙입니다. 예를 들어 /admin/은 /admin/users에는 적용되지만 /admin과는 다른 경로입니다. /temp/는 Googlebot 그룹에만 추가되어 있습니다. 같은 봇에 여러 경로 규칙이 맞으면 더 구체적인 규칙이 우선하며, 길이가 같아 충돌하면 Google은 허용 규칙을 고릅니다.
sitemap.xml: 검색 엔진을 위한 웹사이트 지도
sitemap.xml은 웹사이트의 중요한 페이지 목록을 검색 엔진에 알려 주는 XML 파일로, 검색 엔진이 콘텐츠를 효율적으로 발견하고 크롤링하고 색인하도록 돕습니다. 특히 내부 링크가 적거나 깊이 숨겨진 페이지, 동적으로 만들어지는 페이지처럼 검색 엔진이 스스로 발견하기 어려운 페이지를 알리는 데 중요합니다.
사이트맵은 검색 엔진이 사이트의 중요한 URL을 빠르고 효율적으로 찾게 해 주고, 내부 링크만으로 찾기 어려운 페이지를 알려 줍니다. 다만 제출은 힌트일 뿐 모든 URL의 수집과 색인을 보장하지 않습니다. 각 URL의 최종 수정일, 변경 빈도, 상대적 우선순위 같은 추가 정보도 담을 수 있습니다.
sitemap.xml 파일 구조
sitemap.xml은 <urlset> 루트 요소 아래에 URL마다 <url> 요소를 둡니다. 각 <url>에는 필수인 <loc>에 페이지의 전체 URL(절대 경로)을 넣고, 선택 항목으로 페이지가 마지막으로 수정된 날짜인 <lastmod>, 내용이 바뀌는 빈도인 <changefreq>(always, hourly, daily, weekly, monthly, yearly, never), 사이트 안에서 이 URL의 상대적 중요도인 <priority>(0.0에서 1.0, 기본값 0.5)를 넣을 수 있습니다.
App Router에서 sitemap.xml 설정
App Router는 src/app/sitemap.ts나 .js 파일로 sitemap.xml을 만들 수 있게 지원합니다. src/app/sitemap.ts로 사이트맵을 만들어 보겠습니다.
import { MetadataRoute } from 'next';
// 가상의 블로그 게시물 데이터
interface Post {
slug: string;
updatedAt: string;
}
async function getPosts(): Promise<Post[]> {
// 실제로는 여기서 DB 또는 API를 호출하여 모든 게시물의 slug와 업데이트 날짜를 가져옵니다.
// 예시를 위해 더미 데이터를 반환합니다.
return [
{ slug: 'nextjs-seo-guide', updatedAt: '2024-06-20T10:00:00Z' },
{ slug: 'dynamic-metadata-tutorial', updatedAt: '2024-06-21T15:30:00Z' },
{ slug: 'image-optimization-tips', updatedAt: '2024-06-19T09:00:00Z' },
];
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://docs-lab.dev'; // 실제 웹사이트 URL로 변경
// 1. 정적인 페이지 목록
const staticRoutes: MetadataRoute.Sitemap = [
{
url: baseUrl,
changeFrequency: 'daily', // 매일 변경될 가능성
priority: 1, // 가장 높은 우선순위
},
{
url: `${baseUrl}/about`,
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/contact`,
changeFrequency: 'yearly',
priority: 0.7,
},
{
url: `${baseUrl}/blog`, // 블로그 목록 페이지
changeFrequency: 'weekly',
priority: 0.9,
}
];
// 2. 동적 블로그 게시물 목록
const posts = await getPosts();
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly', // 블로그 게시물은 보통 weekly 또는 monthly
priority: 0.6,
}));
// 3. 모든 URL 목록을 반환
return [...staticRoutes, ...postRoutes];
}실제 수정 시각을 모르는 정적 페이지는 lastModified를 생략합니다. 매번 new Date()를 돌려주면 바뀌지 않은 페이지까지 방금 수정된 것처럼 보이므로, CMS나 원본 데이터에 저장된 수정 시각이 있을 때만 제공합니다.
Next.js는 돌려받은 목록으로 /sitemap.xml을 제공하며, 이 특수 Route Handler도 기본적으로 캐시됩니다. 코드상으로는 정적 URL 4개와 게시물 URL 3개를 돌려주지만, URL을 나열하는 것만으로 페이지를 만들거나 응답 상태를 검사하지는 않습니다. 이 글의 게시물 목록은 별도의 모의 데이터이므로, 13장 2절과 함께 실행한다면 image-optimization-tips를 포함한 목록을 실제 게시물 저장소와 맞춥니다.
NEXT · 13.4
크롤링 규칙과 URL 발견 힌트는 서로 다른 역할입니다
본문 robots.ts의 실제 경로 규칙과 sitemap.ts가 반환하는 목록을 비교합니다. robots의 Sitemap 주소는 URL 목록 위치를 알려 줍니다.
robots.txt · 그룹별 접근 지침
| 경로 예시 | * 그룹 | Googlebot 그룹 |
|---|---|---|
/admin/users | 크롤링 비허용 | 크롤링 비허용 |
/admin | 허용 | 허용 |
/private/a | 크롤링 비허용 | 크롤링 비허용 |
/temp/task | 허용 | 크롤링 비허용 |
/admin/users- * 그룹: 크롤링 비허용 · Googlebot 그룹: 크롤링 비허용
/admin- * 그룹: 허용 · Googlebot 그룹: 허용
/private/a- * 그룹: 크롤링 비허용 · Googlebot 그룹: 크롤링 비허용
/temp/task- * 그룹: 허용 · Googlebot 그룹: 크롤링 비허용
/admin/ 접두사는 /admin과 다릅니다. Googlebot 그룹의 규칙은 별도로 선언하며 * 그룹을 자동 상속하지 않습니다.
sitemap.xml · 공개 URL 목록
- 정적 URL 4개
- 사이트 루트, /about, /contact, /blog를 반환합니다. 실제 수정 시각을 모르는 이 네 항목에는 lastModified가 없습니다.
- 게시물 URL 3개
- getPosts의 slug를 /blog/{slug} URL로 바꾸고 저장된 updatedAt을 lastModified로 전달합니다.
- 최종 URL 목록 7개
- 두 배열을 합쳐 /sitemap.xml로 제공합니다. URL 나열은 페이지 생성이나 정상 응답 확인을 수행하지 않습니다.
Disallow는 인증·인가나 검색 결과 제외를 보장하지 않습니다. noindex는 크롤러가 읽을 수 있어야 합니다. 사이트맵도 수집·색인 보장이 아니며 Google은 changefreq·priority를 무시합니다. 게시물 목록은 실제 저장소와 맞추어야 합니다.
위 그림은 robots.txt의 크롤링 규칙과 sitemap.xml의 URL 발견 힌트가 서로 다른 역할을 한다는 점을 보여 줍니다. 위쪽 표는 본문 robots.ts의 그룹별 접두사 규칙을 경로 예시에 적용한 결과입니다. /admin/users와 /private/a는 두 그룹 모두에서 크롤링이 허용되지 않고, /admin은 두 그룹 모두 허용되며, /temp/task는 * 그룹에서는 허용이지만 Googlebot 그룹에서는 허용되지 않습니다. /admin/ 접두사는 /admin과 다르며, Googlebot 그룹의 규칙은 따로 선언되어 * 그룹을 이어받지 않습니다.
아래쪽은 sitemap.ts가 돌려주는 공개 URL 목록입니다. 사이트 루트, /about, /contact, /blog라는 정적 URL 4개에는 실제 수정 시각을 모르므로 lastModified가 없고, 게시물 URL 3개는 getPosts의 slug를 /blog/{slug}로 바꾸고 저장된 updatedAt을 lastModified로 넘깁니다. 두 배열을 합친 7개가 /sitemap.xml로 제공됩니다. 그림의 설명처럼 Disallow는 인증이나 검색 결과 제외를 보장하지 않고, noindex는 크롤러가 읽을 수 있어야 하며, 사이트맵도 수집과 색인을 보장하지 않습니다.
SEO를 위한 최적화 전략
Next.js에서는 정적 파일과 App Router의 MetadataRoute 파일을 상황에 맞게 고릅니다. robots.txt는 Disallow 규칙으로 크롤링을 제어하고 sitemap.xml은 발견되기를 원하는 공개 대표 URL을 나열하므로, 두 파일은 서로를 보완합니다. robots.txt에 Sitemap: 지시어로 사이트맵의 전체 URL을 적어 두면 검색 엔진이 사이트맵을 쉽게 찾습니다. Google Search Console에서는 사이트 속성을 확인하고 sitemap.xml을 제출하며, robots.txt는 크롤러가 루트 경로에서 직접 읽으므로 실제 응답과 URL 검사 결과를 함께 확인합니다.
크롤링과 색인은 구분해야 합니다. robots.txt의 Disallow는 크롤링을 제한할 뿐 검색 결과에서 빠지는 것을 보장하지 않습니다. 공개 페이지를 검색에서 빼려면 크롤러가 읽을 수 있는 noindex를 쓰고, 관리자 페이지와 개인정보는 인증과 인가로 보호합니다.
lastmod에는 중요한 본문이 실제로 바뀐 시각만 기록합니다. changefreq와 priority는 Sitemap 프로토콜의 선택 필드이지만 Google은 두 값을 무시하므로, 크롤링 순서를 조정하는 수단으로 쓰지 않습니다. 블로그나 상품 URL은 sitemap.ts에서 저장소를 조회해 대표(canonical) URL을 나열하며, 같은 목록을 빌드할 때 미리 렌더링하는 경우에만 generateStaticParams와 데이터 조회 로직을 함께 씁니다.
robots.txt의 잘못된 규칙은 중요한 페이지의 크롤링을 막을 수 있으므로, 바꾼 뒤에는 실제 파일과 Search Console의 보고서, URL 검사를 함께 확인합니다. sitemap.xml 파일 하나에는 URL 50,000개 이하, 압축하지 않은 크기 50MB 이하만 담을 수 있으므로, 이보다 큰 웹사이트는 여러 사이트맵 파일로 나누고 사이트맵 색인 파일(sitemap index file)로 관리합니다.
robots.txt와 sitemap.xml은 웹사이트의 기술적 SEO의 기반이 되는 파일이며, App Router의 내장 기능을 쓰면 두 파일을 효율적으로 관리해 검색 엔진에서의 노출을 높일 수 있습니다.
더 깊이: sitemap.ts가 다시 만들어지는 시점과 나누는 방법
Next.js 문서의 sitemap.xml에 따르면 sitemap.ts는 특수한 Route Handler이며, 요청 시점 API나 동적 설정을 쓰지 않는 한 기본으로 캐시됩니다. Next.js 15에서 일반 GET Route Handler의 기본값이 캐시하지 않음으로 바뀌었지만, sitemap.ts와 robots.ts 같은 메타데이터 파일은 그 뒤에도 기본으로 정적입니다. 그래서 위 예제의 getPosts를 실제 저장소 조회로 바꾸더라도 사이트맵은 빌드할 때의 목록으로 굳고, 새 글을 올려도 다시 빌드하거나 재검증하기 전에는 사이트맵에 나타나지 않습니다. 게시물이 자주 늘어나는 사이트라면 라우트 세그먼트의 revalidate 설정 같은 재검증 정책을 함께 정합니다.
큰 사이트맵을 나누는 방법은 두 가지입니다. app/sitemap.ts와 app/products/sitemap.ts처럼 여러 라우트 세그먼트에 사이트맵 파일을 두거나, generateSitemaps가 돌려주는 id마다 사이트맵을 만들어 /product/sitemap/1.xml 같은 주소로 제공합니다. Next.js 16에서는 사이트맵 함수가 받는 id가 문자열로 끝나는 Promise로 바뀌었으므로, await한 뒤 필요하면 숫자로 바꿔 범위를 계산합니다. 한 파일에 언어별 대체 URL을 넣는 alternates.languages와, 이미지·동영상 사이트맵을 위한 images, videos 속성도 지원합니다.
정리
robots.txt는 크롤러에게 접근할 경로를 요청하는 파일일 뿐 보안 장치가 아니며, App Router에서는src/app/robots.ts나 정적robots.txt가운데 하나로 만듭니다.- 구체적인 봇 그룹은
*그룹을 이어받지 않으며, 경로 규칙은 접두사로 비교되어/admin/과/admin은 다른 경로입니다. sitemap.ts는 공개할 대표 URL 목록을 돌려주어/sitemap.xml로 제공하며, 제출은 수집과 색인의 힌트일 뿐입니다.lastModified는 실제 수정 시각이 있을 때만 넣고, Google은changefreq와priority를 무시합니다.- 검색에서 빼려면 크롤러가 읽을 수 있는
noindex를, 접근을 막으려면 서버의 인증과 인가를 씁니다.
연습 문제
1. 그룹, 접두사, 보호 조건
robots.ts를 바꾸지 않았을 때 Googlebot의 /temp/task, 다른 봇의 /temp/task, Googlebot의 /admin은 각각 크롤링이 허용될까요? 마지막 URL을 관리 화면으로 만들었다면 이 규칙만으로 일반 사용자의 접근을 막을 수 있을까요?
해설 보기
Googlebot의 /temp/task는 허용되지 않고, * 그룹을 따르는 다른 봇에게는 허용됩니다. /admin은 끝에 슬래시가 없는 별도의 경로이므로 /admin/ 규칙에 맞지 않아 허용됩니다.
실제 관리 화면은 10장처럼 서버의 인증과 인가로 보호해야 하며, robots 지침을 접근 검사로 쓰지 않습니다. robots.txt는 크롤러에게 요청할 뿐, 사용자의 접근을 막지 못합니다.
2. 사이트맵의 URL과 실제 페이지
이 글의 sitemap.ts는 몇 개의 URL을 돌려줄까요? 그 가운데 13장 2절의 게시물 페이지에서 실제로 열리지 않을 수 있는 URL은 무엇일까요? priority: 1을 지정하면 그 페이지가 반드시 색인될까요?
해설 보기
정적 URL 네 개와 게시물 URL 세 개를 합쳐 일곱 개를 돌려줍니다. 이 가운데 image-optimization-tips는 사이트맵의 모의 목록에는 있지만 13장 2절 게시물 페이지의 모의 목록에는 없으므로, 그 페이지에서는 notFound()로 끝납니다. 사이트맵을 제출하기 전에 두 저장소를 맞추고 실제 페이지의 응답을 확인해야 하는 이유가 여기에 있습니다.
priority: 1을 지정해도 없는 페이지를 만들거나 색인을 보장하지 않으며, Google은 이 값을 무시합니다.
3. 검색 결과에서 빼고 싶은 페이지
이미 색인된 공개 페이지 /old-event를 검색 결과에서 빼려고 robots.ts의 disallow에 /old-event를 추가했습니다. 이 방법으로 충분할까요?
해설 보기
충분하지 않습니다. Disallow는 크롤링을 막을 뿐이므로, 크롤러가 페이지를 다시 읽지 못해 오히려 noindex 같은 지침도 볼 수 없게 됩니다. 이미 색인된 URL은 다른 페이지의 링크로도 검색 결과에 남을 수 있습니다. 검색에서 빼려면 크롤링을 허용한 채 그 페이지의 metadata에 robots: { index: false }를 넣어 noindex를 알리고, 크롤러가 이를 읽어 반영한 뒤에 필요하면 크롤링 규칙을 정리합니다.
robots.txt와 sitemap.xml 파일 규칙은 Next.js 공식 문서의 robots.txt 레퍼런스와 sitemap.xml 레퍼런스에서 확인할 수 있습니다.
이것으로 13장을 마칩니다. 13장에서는 정적 메타데이터와 동적 메타데이터, Open Graph 태그, robots.txt와 sitemap.xml로 검색 엔진과 소셜 공유를 위한 정보를 설정하는 방법을 정리했습니다. 다음 장의 단위 테스트 설정부터는 Next.js 애플리케이션을 테스트하는 방법을 알아봅니다.